diff --git a/package-lock.json b/package-lock.json
index ef8d453..1b89785 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "queuing-org",
"version": "0.1.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@ssgoi/react": "^4.2.1",
"@stomp/stompjs": "^7.2.1",
"@tanstack/react-query": "^5.90.12",
@@ -267,6 +270,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
diff --git a/package.json b/package.json
index e0f1f7f..0c9a454 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,9 @@
"lint": "eslint"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@ssgoi/react": "^4.2.1",
"@stomp/stompjs": "^7.2.1",
"@tanstack/react-query": "^5.90.12",
diff --git a/player-removechild-analysis.md b/player-removechild-analysis.md
new file mode 100644
index 0000000..04e9781
--- /dev/null
+++ b/player-removechild-analysis.md
@@ -0,0 +1,208 @@
+# Room Player `removeChild` 에러 분석
+
+## 1. 문제현상
+
+방 화면에서 곡이 끝난 직후 STOMP 이벤트를 받은 뒤, 브라우저 콘솔에 아래 에러가 발생한다.
+
+```text
+Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node':
+The node to be removed is not a child of this node.
+```
+
+관찰된 흐름은 다음과 같다.
+
+1. 곡 종료 시점에 방 이벤트(`/topic/room/{slug}/events`)를 수신한다.
+2. `TRACK_ENDED` 또는 재생 상태 변경에 따라 현재 곡 정보가 비워진다.
+3. 화면이 유튜브 플레이어 대신 placeholder를 렌더링하려는 순간 React DOM 정리 단계에서 예외가 발생한다.
+4. 그 뒤 `[STOMP] >>> UNSUBSCRIBE` 로그가 보이지만, 이는 에러 이후 cleanup 과정에서 같이 보인 로그로 판단된다.
+
+즉, 표면적으로는 "곡이 끝난 뒤 플레이어가 사라질 때" 터지는 에러다.
+
+## 2. 이유분석 (코드와 함께)
+
+### 2-1. 곡 종료 후 현재 비디오 ID가 `null`이 되는 흐름
+
+`src/app/room/[slug]/page.tsx`에서는 방 이벤트를 구독하고 있다.
+
+```ts
+if (
+ event.type === "QUEUE_ADDED" ||
+ event.type === "QUEUE_REMOVED" ||
+ event.type === "TRACK_STARTED" ||
+ event.type === "TRACK_ENDED"
+) {
+ void refetchRoomState();
+ return;
+}
+```
+
+이후 현재 재생할 비디오 ID는 `getCurrentVideoId()`로 계산된다.
+
+```ts
+const playbackStatus = getLatestPlaybackState(
+ roomState?.playbackStatus,
+ livePlaybackStatus,
+);
+const currentVideoId = getCurrentVideoId(roomState, playbackStatus);
+```
+
+`getCurrentVideoId()`는 `playbackStatus.videoId`도 없고 `roomState.currentEntry.track.videoId`도 없으면 `null`을 반환한다.
+
+```ts
+function getCurrentVideoId(
+ roomState: RoomStateSnapshot | undefined,
+ playbackStatus: PlaybackState | RoomStateSnapshot["playbackStatus"] | null,
+) {
+ const playbackVideoId = playbackStatus?.videoId;
+ if (typeof playbackVideoId === "string" && playbackVideoId.trim()) {
+ return playbackVideoId.trim();
+ }
+
+ const currentTrackVideoId = roomState?.currentEntry?.track.videoId;
+ if (typeof currentTrackVideoId === "string" && currentTrackVideoId.trim()) {
+ return currentTrackVideoId.trim();
+ }
+
+ return null;
+}
+```
+
+즉, 마지막 곡이 끝나거나 현재 트랙이 비워지면 `currentVideoId`가 `null`로 바뀐다.
+
+### 2-2. `videoId === null`이면 플레이어 DOM을 placeholder로 교체한다
+
+`src/features/playlist/player/ui/YouTubePlayer.tsx`에서는 `videoId`가 없으면 플레이어를 정리하고 placeholder를 렌더링한다.
+
+```ts
+useEffect(() => {
+ if (!videoId) {
+ destroyPlayer();
+ return;
+ }
+
+ // player setup...
+}, [applyDesiredPlayback, destroyPlayer, onPlaybackStateChange, onPlayerReady, videoId]);
+```
+
+```tsx
+if (!videoId) {
+ return (
+
+ 재생할 유튜브 영상이 아직 없습니다.
+
+ );
+}
+```
+
+즉, 현재 구조는 "곡이 없어지면 React가 플레이어 영역을 통째로 다른 DOM으로 바꾸는 방식"이다.
+
+### 2-3. 그런데 YouTube Iframe API가 React가 만든 노드를 직접 교체한다
+
+같은 파일에서 유튜브 플레이어는 아래 코드로 생성된다.
+
+```ts
+createdPlayer = new YT.Player(containerRef.current, {
+ videoId: videoId ?? undefined,
+ playerVars: {
+ autoplay: 1,
+ controls: 1,
+ playsinline: 1,
+ rel: 0,
+ origin: window.location.origin,
+ },
+});
+```
+
+렌더링된 React DOM은 원래 아래와 같다.
+
+```tsx
+return (
+
+
+ {playerError ?
...
: null}
+
+);
+```
+
+문제는 `YT.Player(containerRef.current, ...)`가 React가 만든 ``를 그대로 유지하는 것이 아니라, 내부적으로 iframe 기반 플레이어로 바꿔 관리한다는 점이다.
+
+그 결과 React 입장에서는 아직 "`containerRef`가 가리키던 div가 DOM에 있다"고 생각하지만, 실제 브라우저 DOM에서는 그 노드가 이미 YouTube API에 의해 교체되었을 수 있다.
+
+### 2-4. 최종적으로 `removeChild` 충돌이 난다
+
+곡이 끝나서 `currentVideoId`가 `null`이 되면 React는 기존 플레이어 subtree를 제거하고 placeholder를 넣으려 한다.
+
+하지만 실제 DOM에서는 React가 제거하려는 노드가 이미 부모의 자식이 아니기 때문에 다음과 같은 예외가 발생한다.
+
+```text
+Failed to execute 'removeChild' on 'Node':
+The node to be removed is not a child of this node.
+```
+
+정리하면 원인은 다음과 같다.
+
+1. STOMP 이벤트가 상태 변화를 유발한다.
+2. 상태 변화로 `currentVideoId`가 `null`이 된다.
+3. React가 플레이어 DOM을 제거하려고 한다.
+4. 그런데 그 DOM은 이미 YouTube API가 교체하거나 직접 관리하고 있다.
+5. 그래서 React의 DOM 정리 단계에서 `removeChild` 예외가 발생한다.
+
+즉, 직접 원인은 STOMP가 아니라 **React와 YouTube Iframe API가 같은 DOM 노드의 소유권을 동시에 가진 구조**다.
+
+## 3. 해결과정
+
+### 3-1. 빠른 완화 방향
+
+질문한 방향대로, **곡이 끝났을 때 placeholder로 바꾸지 않고 마지막 유튜브 창 상태를 그대로 유지하는 방식**은 현재 증상을 줄이는 데 유효할 가능성이 높다.
+
+이 방식의 핵심은 다음과 같다.
+
+1. `videoId`가 `null`이 되어도 플레이어 subtree를 React가 제거하지 않는다.
+2. 유튜브 iframe이 떠 있는 영역을 그대로 둔다.
+3. 필요하면 `pauseVideo()`만 호출하거나, "현재 재생 중인 곡이 없습니다" 같은 안내 문구를 overlay로만 얹는다.
+
+이렇게 하면 "곡 종료 직후 placeholder로 교체되는 순간"이 사라지므로, 현재 재현된 에러는 없어질 가능성이 높다.
+
+### 3-2. 다만 이건 증상 완화에 가깝다
+
+이 접근은 현재 보이는 현상에는 효과적일 수 있지만, 구조적인 문제를 완전히 없애는 것은 아니다.
+
+이유는 다음과 같다.
+
+1. React는 여전히 자신이 만든 노드를 추적하고 있다.
+2. YouTube API는 여전히 그 노드를 직접 교체하거나 별도 관리한다.
+3. 따라서 이후 페이지 이탈, 컴포넌트 언마운트, 다른 재생 흐름 변경 시 비슷한 충돌이 다시 나타날 가능성이 남아 있다.
+
+### 3-3. 근본적인 수정 방향
+
+근본 해결은 **React가 소유하는 wrapper**와 **YouTube가 소유하는 실제 player host**를 분리하는 쪽이 더 안전하다.
+
+권장 방향은 다음과 같다.
+
+1. React는 항상 고정된 바깥 wrapper만 렌더링한다.
+2. wrapper 내부에 YouTube용 host element를 명시적으로 만들고, 그 내부는 YouTube API가 관리하게 둔다.
+3. 곡이 끝났을 때는 wrapper를 제거하지 않고, player를 pause 상태로 두거나 overlay만 표시한다.
+4. 컴포넌트 cleanup 시에는 React가 직접 교체된 노드를 제거하려 들지 않도록, YouTube 전용 영역을 명시적으로 정리한다.
+
+예상 구조는 아래와 비슷하다.
+
+```tsx
+return (
+
+
+ {!videoId ?
현재 재생 중인 곡이 없습니다.
: null}
+
+);
+```
+
+핵심은 "placeholder로 전체 subtree를 교체"하는 대신, **항상 같은 껍데기 DOM을 유지하고 상태만 바꾸는 것**이다.
+
+## 4. 결과
+
+현재까지의 결론은 다음과 같다.
+
+1. 에러의 직접 원인은 STOMP 자체가 아니라 React DOM과 YouTube Iframe API의 DOM 소유권 충돌이다.
+2. 곡 종료 후 `currentVideoId`가 `null`이 되면서 placeholder로 교체되는 순간에 문제가 드러난다.
+3. 질문한 방식처럼 "placeholder로 바꾸지 않고 마지막 유튜브 창을 그대로 유지"하는 것은 빠른 완화책으로 타당하다.
+4. 다만 근본적으로는 React와 YouTube가 서로 다른 DOM 경계를 갖도록 구조를 정리하는 편이 더 안전하다.
+5. 아직 코드는 수정하지 않았고, 현재 문서는 원인 분석과 수정 방향 정리 단계다.
diff --git a/public/icons/home_exit.svg b/public/icons/home_exit.svg
new file mode 100644
index 0000000..11d99dd
--- /dev/null
+++ b/public/icons/home_exit.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/app/room/[slug]/page.tsx b/src/app/room/[slug]/page.tsx
index de88306..6c3e8c9 100644
--- a/src/app/room/[slug]/page.tsx
+++ b/src/app/room/[slug]/page.tsx
@@ -28,6 +28,7 @@ import RoomButtonControlBar from "@/src/widgets/room/ui/RoomControlBar";
import { useFloatingWidgetsState } from "@/src/widgets/room/model/useFloatingWidgetsState";
import RoomFloatingWidgets from "@/src/widgets/room/ui/RoomFloatingWidgets";
import ChatArea from "@/src/features/room/chat/ui/ChatArea";
+import type { CurrentRequesterProfile } from "@/src/features/room/profile/model/types";
type JoinStatus = "joining" | "joined" | "error" | "needs-password";
@@ -89,6 +90,30 @@ function getCurrentVideoId(
return null;
}
+function getCurrentRequesterProfile(
+ roomState: RoomStateSnapshot | undefined,
+): CurrentRequesterProfile | null {
+ const requester = roomState?.currentEntry?.addedBy;
+ if (!requester) {
+ return null;
+ }
+
+ const matchedParticipant = roomState?.participants.find((participant) => {
+ if (requester.userId !== null) {
+ return participant.userId === requester.userId;
+ }
+
+ return participant.nickname === requester.nickname;
+ });
+
+ return {
+ avatarUrl: requester.avatarUrl ?? matchedParticipant?.profileImageUrl ?? null,
+ nickname: requester.nickname,
+ slug: matchedParticipant?.slug ?? null,
+ userId: requester.userId,
+ };
+}
+
export default function RoomPage() {
const params = useParams<{ slug: string }>();
const queryClient = useQueryClient();
@@ -120,7 +145,8 @@ export default function RoomPage() {
livePlaybackStatus,
);
const currentVideoId = getCurrentVideoId(roomState, playbackStatus);
- const currentRequester = roomState?.currentEntry?.addedBy ?? null;
+ const currentRequester = getCurrentRequesterProfile(roomState);
+ const currentTrackTitle = roomState?.currentEntry?.track.title ?? null;
const cleanupRoomSubscription = useCallback(() => {
if (!roomSubscriptionRef.current) {
@@ -186,6 +212,9 @@ export default function RoomPage() {
event.type === "TRACK_STARTED" ||
event.type === "TRACK_ENDED"
) {
+ void queryClient.invalidateQueries({
+ queryKey: ["roomQueue", roomSlug],
+ });
void refetchRoomState();
return;
}
@@ -367,6 +396,10 @@ export default function RoomPage() {
{
+ const res = await axiosInstance.patch>(
+ `/api/v1/rooms/${encodeURIComponent(
+ normalizeRoomSlug(slug),
+ )}/playlist/me/move`,
+ {
+ beforeEntryId,
+ movedEntryId,
+ },
+ {
+ headers: password
+ ? {
+ "X-Room-Password": password,
+ }
+ : undefined,
+ },
+ );
+
+ if (!res.data.result) {
+ throw new ApiError({
+ message: "큐 순서를 변경하지 못했습니다.",
+ status: 200,
+ });
+ }
+
+ return res.data.result;
+}
diff --git a/src/entities/playlist/model/types.ts b/src/entities/playlist/model/types.ts
index b33c74a..9265869 100644
--- a/src/entities/playlist/model/types.ts
+++ b/src/entities/playlist/model/types.ts
@@ -14,6 +14,14 @@ export type RoomQueueRequestParams = PlaylistProtectedRequestParams & {
size?: number;
};
+export type MoveMyQueueEntryPayload = {
+ movedEntryId: string;
+ beforeEntryId: string | null;
+};
+
+export type MoveMyQueueEntryParams = PlaylistProtectedRequestParams &
+ MoveMyQueueEntryPayload;
+
export type TrackProvider = "YOUTUBE" | (string & {});
export type PlaylistTrack = {
diff --git a/src/entities/playlist/model/useMoveMyQueueEntry.ts b/src/entities/playlist/model/useMoveMyQueueEntry.ts
new file mode 100644
index 0000000..5a7bc28
--- /dev/null
+++ b/src/entities/playlist/model/useMoveMyQueueEntry.ts
@@ -0,0 +1,97 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { moveMyQueueEntry } from "../api/moveMyQueueEntry";
+import type {
+ MoveMyQueueEntryParams,
+ RoomQueueResult,
+} from "./types";
+import type { ApiError } from "@/src/shared/api/api-error";
+
+type MoveMyQueueEntryVariables = MoveMyQueueEntryParams & {
+ orderedPendingEntryIds: string[];
+};
+
+type RoomQueueSnapshot = [readonly unknown[], RoomQueueResult | undefined];
+
+function applyPendingEntryOrder(
+ currentEntries: RoomQueueResult | undefined,
+ orderedPendingEntryIds: string[],
+) {
+ if (!currentEntries || orderedPendingEntryIds.length < 2) {
+ return currentEntries;
+ }
+
+ const orderedEntriesById = new Map(
+ currentEntries
+ .filter((entry) => orderedPendingEntryIds.includes(entry.entryId))
+ .map((entry) => [entry.entryId, entry]),
+ );
+ const reorderedEntries = orderedPendingEntryIds
+ .map((entryId) => orderedEntriesById.get(entryId))
+ .filter((entry) => !!entry);
+
+ if (reorderedEntries.length !== orderedPendingEntryIds.length) {
+ return currentEntries;
+ }
+
+ let reorderedIndex = 0;
+
+ return currentEntries.map((entry) => {
+ if (!orderedEntriesById.has(entry.entryId)) {
+ return entry;
+ }
+
+ const reorderedEntry = reorderedEntries[reorderedIndex];
+ reorderedIndex += 1;
+
+ return reorderedEntry ?? entry;
+ });
+}
+
+export function useMoveMyQueueEntry() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ beforeEntryId, movedEntryId, password, slug }) =>
+ moveMyQueueEntry({
+ beforeEntryId,
+ movedEntryId,
+ password,
+ slug,
+ }),
+ onMutate: async ({ orderedPendingEntryIds, slug }) => {
+ await queryClient.cancelQueries({ queryKey: ["roomQueue", slug] });
+
+ const previousRoomQueueSnapshots =
+ queryClient.getQueriesData({
+ queryKey: ["roomQueue", slug],
+ });
+
+ queryClient.setQueriesData(
+ { queryKey: ["roomQueue", slug] },
+ (currentEntries) =>
+ applyPendingEntryOrder(currentEntries, orderedPendingEntryIds),
+ );
+
+ return { previousRoomQueueSnapshots };
+ },
+ onError: (_error, variables, context) => {
+ context?.previousRoomQueueSnapshots.forEach(([queryKey, previousData]) => {
+ queryClient.setQueryData(queryKey, previousData);
+ });
+
+ queryClient.invalidateQueries({ queryKey: ["roomQueue", variables.slug] });
+ },
+ onSuccess: async (_result, variables) => {
+ await queryClient.invalidateQueries({
+ queryKey: ["roomQueue", variables.slug],
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["roomState", variables.slug],
+ });
+ },
+ });
+}
diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts
index c99a77a..634f6b9 100644
--- a/src/entities/user/model/types.ts
+++ b/src/entities/user/model/types.ts
@@ -1,4 +1,5 @@
export interface User {
+ userId?: number | null;
nickname: string;
slug: string; //ex) "user-123abc45"
profileImageUrl: string | null;
diff --git a/src/features/playlist/player/ui/YouTubePlayer.tsx b/src/features/playlist/player/ui/YouTubePlayer.tsx
index 983a899..4b62de8 100644
--- a/src/features/playlist/player/ui/YouTubePlayer.tsx
+++ b/src/features/playlist/player/ui/YouTubePlayer.tsx
@@ -151,7 +151,7 @@ export default function YouTubePlayer({
onPlayerReady,
onPlaybackStateChange,
}: YouTubePlayerProps) {
- const containerRef = useRef(null);
+ const playerMountRef = useRef(null);
const playerRef = useRef(null);
const isReadyRef = useRef(false);
const loadedVideoIdRef = useRef(null);
@@ -160,10 +160,24 @@ export default function YouTubePlayer({
playbackStatus,
currentTimeMs,
});
+ const onPlayerReadyRef = useRef(onPlayerReady);
+ const onPlaybackStateChangeRef = useRef(onPlaybackStateChange);
const [playerError, setPlayerError] = useState(null);
+ const [hasCreatedPlayer, setHasCreatedPlayer] = useState(false);
+
+ useEffect(() => {
+ onPlayerReadyRef.current = onPlayerReady;
+ }, [onPlayerReady]);
+
+ useEffect(() => {
+ onPlaybackStateChangeRef.current = onPlaybackStateChange;
+ }, [onPlaybackStateChange]);
const destroyPlayer = useCallback(() => {
if (!playerRef.current) {
+ if (playerMountRef.current) {
+ playerMountRef.current.replaceChildren();
+ }
return;
}
@@ -178,6 +192,21 @@ export default function YouTubePlayer({
loadedVideoIdRef.current = null;
}, []);
+ const ensurePlayerHost = useCallback(() => {
+ if (!playerMountRef.current) {
+ return null;
+ }
+
+ const host = document.createElement("div");
+ host.className = "h-full w-full";
+
+ // Keep React in charge of the wrapper only. The actual YouTube host node
+ // lives inside this mount point and can be replaced by the iframe API.
+ playerMountRef.current.replaceChildren(host);
+
+ return host;
+ }, []);
+
const applyDesiredPlayback = useCallback(() => {
const player = playerRef.current;
const desiredPlayback = desiredPlaybackRef.current;
@@ -191,7 +220,12 @@ export default function YouTubePlayer({
(desiredPlayback.currentTimeMs ?? 0) / 1000,
);
- if (!player || !isReadyRef.current || !nextVideoId || !nextStatus) {
+ if (!player || !isReadyRef.current) {
+ return;
+ }
+
+ if (!nextVideoId || !nextStatus) {
+ player.pauseVideo();
return;
}
@@ -225,8 +259,7 @@ export default function YouTubePlayer({
}, []);
useEffect(() => {
- if (!videoId) {
- destroyPlayer();
+ if (!videoId || playerRef.current) {
return;
}
@@ -238,11 +271,16 @@ export default function YouTubePlayer({
setPlayerError(null);
const YT = await loadYouTubeIframeApi();
- if (isCancelled || !containerRef.current || playerRef.current) {
+ if (isCancelled || playerRef.current) {
+ return;
+ }
+
+ const host = ensurePlayerHost();
+ if (!host) {
return;
}
- createdPlayer = new YT.Player(containerRef.current, {
+ createdPlayer = new YT.Player(host, {
videoId: videoId ?? undefined,
playerVars: {
autoplay: 1,
@@ -253,8 +291,12 @@ export default function YouTubePlayer({
},
events: {
onReady: () => {
+ if (isCancelled) {
+ return;
+ }
+
isReadyRef.current = true;
- onPlayerReady?.();
+ onPlayerReadyRef.current?.();
applyDesiredPlayback();
},
onStateChange: (event) => {
@@ -263,7 +305,7 @@ export default function YouTubePlayer({
return;
}
- onPlaybackStateChange?.({
+ onPlaybackStateChangeRef.current?.({
status: mappedStatus,
currentTimeMs: Math.round(event.target.getCurrentTime() * 1000),
});
@@ -272,6 +314,7 @@ export default function YouTubePlayer({
});
playerRef.current = createdPlayer;
+ setHasCreatedPlayer(true);
} catch (error) {
if (isCancelled) {
return;
@@ -289,11 +332,15 @@ export default function YouTubePlayer({
return () => {
isCancelled = true;
- if (createdPlayer && playerRef.current === createdPlayer) {
- destroyPlayer();
+ if (createdPlayer && playerRef.current !== createdPlayer) {
+ try {
+ createdPlayer.destroy();
+ } catch {
+ // ignore player teardown failures during navigation/remount
+ }
}
};
- }, [applyDesiredPlayback, destroyPlayer, onPlaybackStateChange, onPlayerReady, videoId]);
+ }, [applyDesiredPlayback, ensurePlayerHost, videoId]);
useEffect(() => {
desiredPlaybackRef.current = {
@@ -307,17 +354,27 @@ export default function YouTubePlayer({
applyDesiredPlayback();
}, [applyDesiredPlayback, currentTimeMs, playbackStatus, videoId]);
- if (!videoId) {
- return (
-
- 재생할 유튜브 영상이 아직 없습니다.
-
- );
- }
+ useEffect(() => destroyPlayer, [destroyPlayer]);
+
+ const showEmptyState = !videoId && !hasCreatedPlayer;
+ const showPlayerFrame = !!videoId || hasCreatedPlayer;
return (
-
-
+
+
+
+ {showEmptyState ? (
+
+ 재생할 유튜브 영상이 아직 없습니다.
+
+ ) : null}
+
{playerError ? (
{playerError}
diff --git a/src/features/room/profile/model/types.ts b/src/features/room/profile/model/types.ts
new file mode 100644
index 0000000..9e298b6
--- /dev/null
+++ b/src/features/room/profile/model/types.ts
@@ -0,0 +1,6 @@
+export type CurrentRequesterProfile = {
+ avatarUrl: string | null;
+ nickname: string;
+ slug: string | null;
+ userId: number | null;
+};
diff --git a/src/features/room/profile/ui/RoomProfilePanel.module.css b/src/features/room/profile/ui/RoomProfilePanel.module.css
new file mode 100644
index 0000000..d2192df
--- /dev/null
+++ b/src/features/room/profile/ui/RoomProfilePanel.module.css
@@ -0,0 +1,128 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 0px 18px 20px;
+ background: #242424;
+ color: #ffffff;
+}
+
+.hero {
+ display: flex;
+ gap: 12px;
+ min-width: 0;
+ align-items: center;
+}
+
+.avatarWrap {
+ position: relative;
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #f4f4f4;
+}
+
+.avatar {
+ object-fit: cover;
+}
+
+.avatarFallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ color: #1f1f1f;
+ font-size: 1.6rem;
+ font-weight: 800;
+}
+
+.nameBlock {
+ flex: 1;
+ min-width: 0;
+}
+
+.name {
+ overflow: hidden;
+ font-size: 18px;
+ font-weight: 900;
+ line-height: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.followButton {
+ flex-shrink: 0;
+ min-width: 92px;
+ height: 32px;
+ padding: 0 24px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.04);
+ color: #ffffff;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.followButton:disabled {
+ cursor: default;
+ opacity: 0.72;
+}
+
+.error {
+ margin-top: 12px;
+ color: #ffb4b4;
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px 18px;
+ margin-top: 26px;
+}
+
+.card {
+ min-width: 0;
+}
+
+.cardTitle {
+ color: #d9d9d9;
+ font-size: 16px;
+ font-weight: var(--fw-bold);
+}
+
+.cardValue {
+ margin-top: 6px;
+ color: #8d8d8d;
+ font-size: 14px;
+ font-weight: var(--font-semibold);
+ line-height: 1.45;
+}
+
+.empty {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 10px;
+ text-align: center;
+}
+
+.emptyTitle {
+ font-size: 1rem;
+ font-weight: 800;
+}
+
+.emptyText {
+ color: rgba(255, 255, 255, 0.58);
+ font-size: 0.86rem;
+ font-weight: 600;
+ line-height: 1.5;
+}
diff --git a/src/features/room/profile/ui/RoomProfilePanel.tsx b/src/features/room/profile/ui/RoomProfilePanel.tsx
new file mode 100644
index 0000000..33f7cc5
--- /dev/null
+++ b/src/features/room/profile/ui/RoomProfilePanel.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import { useMe } from "@/src/entities/user/hooks/useMe";
+import { useSendFriendRequest } from "@/src/features/friend/requests/hooks/useSendFriendRequest";
+import type { CurrentRequesterProfile } from "../model/types";
+import styles from "./RoomProfilePanel.module.css";
+
+type Props = {
+ currentRequester: CurrentRequesterProfile | null;
+ currentTrackTitle?: string | null;
+};
+
+const PLACEHOLDER_PROFILE_FIELDS = [
+ "칭호",
+ "최애곡",
+ "큐잉 횟수",
+ "이용 시간",
+ "음악력",
+] as const;
+
+function isCurrentUserProfile(
+ currentRequester: CurrentRequesterProfile | null,
+ me: ReturnType
["data"],
+) {
+ if (!currentRequester || !me) {
+ return false;
+ }
+
+ if (currentRequester.slug && me.slug === currentRequester.slug) {
+ return true;
+ }
+
+ if (
+ typeof me.userId === "number" &&
+ typeof currentRequester.userId === "number" &&
+ me.userId === currentRequester.userId
+ ) {
+ return true;
+ }
+
+ return me.nickname === currentRequester.nickname;
+}
+
+export default function RoomProfilePanel({ currentRequester }: Props) {
+ const { data: me } = useMe();
+ const { error, isPending, mutate, reset } = useSendFriendRequest();
+ const [lastRequestedKey, setLastRequestedKey] = useState(null);
+ const [lastMutationKey, setLastMutationKey] = useState(null);
+ const currentRequesterKey = currentRequester
+ ? `${currentRequester.slug ?? ""}:${currentRequester.userId ?? ""}:${currentRequester.nickname}`
+ : null;
+
+ const isSelf = isCurrentUserProfile(currentRequester, me);
+ const canFollow = !!currentRequester?.slug && !isSelf;
+ const hasRequestedFollow =
+ currentRequesterKey !== null && lastRequestedKey === currentRequesterKey;
+ const shouldShowError =
+ !!error &&
+ currentRequesterKey !== null &&
+ lastMutationKey === currentRequesterKey;
+
+ function handleFollow() {
+ if (
+ !currentRequester?.slug ||
+ !currentRequesterKey ||
+ isPending ||
+ hasRequestedFollow
+ ) {
+ return;
+ }
+
+ reset();
+ setLastMutationKey(currentRequesterKey);
+ mutate(
+ { targetSlug: currentRequester.slug },
+ {
+ onSuccess: () => {
+ setLastRequestedKey(currentRequesterKey);
+ },
+ },
+ );
+ }
+
+ let buttonLabel = "팔로우";
+ if (!currentRequester) {
+ buttonLabel = "대상 없음";
+ } else if (isSelf) {
+ buttonLabel = "나";
+ } else if (hasRequestedFollow) {
+ buttonLabel = "팔로잉";
+ } else if (isPending) {
+ buttonLabel = "요청 중...";
+ } else if (!currentRequester.slug) {
+ buttonLabel = "준비 중";
+ }
+
+ return (
+
+ {currentRequester ? (
+ <>
+
+
+ {currentRequester.avatarUrl ? (
+
+ ) : (
+
+ {currentRequester.nickname.slice(0, 1)}
+
+ )}
+
+
+
{currentRequester.nickname}
+
+
+ {buttonLabel}
+
+
+ {shouldShowError ? (
+
{error.message}
+ ) : null}
+
+ {PLACEHOLDER_PROFILE_FIELDS.map((field) => (
+
+ ))}
+
+ >
+ ) : (
+
+
표시할 프로필이 없습니다.
+
+ 현재 재생 중인 곡이 생기면 신청자 프로필이 여기에 표시됩니다.
+
+
+ )}
+
+ );
+}
diff --git a/src/features/room/queue/model/roomQueue.ts b/src/features/room/queue/model/roomQueue.ts
new file mode 100644
index 0000000..c4a7e71
--- /dev/null
+++ b/src/features/room/queue/model/roomQueue.ts
@@ -0,0 +1,51 @@
+import type { PlaylistEntry } from "@/src/entities/playlist/model/types";
+import type { User } from "@/src/entities/user/model/types";
+
+export type QueueTab = "all" | "mine";
+export type QueueEntryStatusTone = "active" | "played" | "queued" | "skipped";
+
+export function formatQueueDuration(durationMs: number) {
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
+}
+
+export function getQueueEntryStatus(entry: PlaylistEntry): {
+ label: string;
+ tone: QueueEntryStatusTone;
+} {
+ if (entry.status.isActive) {
+ return { label: "재생 중", tone: "active" };
+ }
+
+ if (entry.status.skipped) {
+ return { label: "건너뜀", tone: "skipped" };
+ }
+
+ if (entry.status.isPlayed) {
+ return { label: "재생 완료", tone: "played" };
+ }
+
+ return { label: "대기 중", tone: "queued" };
+}
+
+export function isPendingQueueEntry(entry: PlaylistEntry) {
+ return !entry.status.isActive && !entry.status.isPlayed && !entry.status.skipped;
+}
+
+export function isEntryRequestedByUser(
+ entry: PlaylistEntry,
+ currentUser: User | null | undefined,
+) {
+ if (!currentUser) {
+ return false;
+ }
+
+ if (typeof currentUser.userId === "number") {
+ return entry.addedBy.userId === currentUser.userId;
+ }
+
+ return entry.addedBy.nickname === currentUser.nickname;
+}
diff --git a/src/features/room/queue/ui/RoomQueueCard.module.css b/src/features/room/queue/ui/RoomQueueCard.module.css
new file mode 100644
index 0000000..46f89ec
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueCard.module.css
@@ -0,0 +1,145 @@
+.item {
+ display: grid;
+ grid-template-columns: 46px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ padding: 14px 16px;
+ background: #262626;
+ transition:
+ transform 180ms ease,
+ box-shadow 180ms ease,
+ opacity 180ms ease;
+}
+
+.item:nth-child(even) {
+ background: #303030;
+}
+
+.item[data-active="true"] {
+ background: #5a5a5a;
+}
+
+.item[data-dragging="true"] {
+ opacity: 0.4;
+}
+
+.item[data-drag-overlay="true"] {
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
+ opacity: 1;
+}
+
+.thumbnailWrap {
+ position: relative;
+ width: 46px;
+ height: 46px;
+ overflow: hidden;
+ border-radius: 2px;
+ background: #171717;
+}
+
+.thumbnail {
+ object-fit: cover;
+}
+
+.nowPlaying {
+ position: absolute;
+ left: 13px;
+ bottom: 2px;
+ padding: 0px 2px;
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.72);
+ color: #ffffff;
+ font-size: 9px;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+}
+
+.meta {
+ min-width: 0;
+}
+
+.title {
+ overflow: hidden;
+ color: #f1f0ec;
+ font-size: 14px;
+ font-weight: var(--fw-bold);
+ line-height: 1.4;
+ text-overflow: clip;
+ white-space: nowrap;
+}
+
+.detailRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 4px;
+}
+
+.story {
+ min-width: 0;
+ overflow: hidden;
+ color: #d9d9d9;
+ font-size: 12px;
+ line-height: 1.4;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.duration {
+ flex-shrink: 0;
+ color: #d9d9d9;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.dragHandle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ align-self: stretch;
+ width: 28px;
+ min-width: 28px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.42);
+ cursor: grab;
+ transition:
+ color 180ms ease,
+ opacity 180ms ease,
+ transform 180ms ease;
+}
+
+.dragHandle:hover {
+ color: rgba(255, 255, 255, 0.72);
+}
+
+.dragHandle:active {
+ cursor: grabbing;
+ transform: scale(0.96);
+}
+
+.dragHandle:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.45);
+ outline-offset: 2px;
+ border-radius: 6px;
+}
+
+.dragHandle:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+.dragHandleIcon {
+ position: relative;
+ width: 4px;
+ height: 4px;
+ border-radius: 1px;
+ background: currentColor;
+ box-shadow:
+ 0 8px 0 currentColor,
+ 8px 0 0 currentColor,
+ 8px 8px 0 currentColor;
+}
diff --git a/src/features/room/queue/ui/RoomQueueCard.tsx b/src/features/room/queue/ui/RoomQueueCard.tsx
new file mode 100644
index 0000000..0d24204
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueCard.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import type { ComponentPropsWithoutRef, Ref } from "react";
+import { forwardRef } from "react";
+import Image from "next/image";
+import type { PlaylistEntry } from "@/src/entities/playlist/model/types";
+import { formatQueueDuration } from "../model/roomQueue";
+import styles from "./RoomQueueCard.module.css";
+
+type Props = {
+ dragHandleProps?: Omit, "children">;
+ dragHandleRef?: Ref;
+ entry: PlaylistEntry;
+ showDragHandle?: boolean;
+} & ComponentPropsWithoutRef<"li">;
+
+const RoomQueueCard = forwardRef(function RoomQueueCard(
+ { className, dragHandleProps, dragHandleRef, entry, showDragHandle = false, ...props },
+ ref,
+) {
+ return (
+
+
+
+ {entry.status.isActive ? (
+
PLAY
+ ) : null}
+
+
+
+ {entry.addedBy.nickname} - {entry.track.title}
+
+
+
사연이 나옵니다.
+
+ {formatQueueDuration(entry.track.durationMs)}
+
+
+
+ {showDragHandle ? (
+
+
+
+ ) : null}
+
+ );
+});
+
+export default RoomQueueCard;
diff --git a/src/features/room/queue/ui/RoomQueueList.module.css b/src/features/room/queue/ui/RoomQueueList.module.css
new file mode 100644
index 0000000..abb5eba
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueList.module.css
@@ -0,0 +1,18 @@
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100%;
+ padding: 32px 20px;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 0.95rem;
+ font-weight: 600;
+ line-height: 1.5;
+ text-align: center;
+}
diff --git a/src/features/room/queue/ui/RoomQueueList.tsx b/src/features/room/queue/ui/RoomQueueList.tsx
new file mode 100644
index 0000000..40619b5
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueList.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import type { PlaylistEntry } from "@/src/entities/playlist/model/types";
+import RoomQueueCard from "./RoomQueueCard";
+import styles from "./RoomQueueList.module.css";
+
+type Props = {
+ emptyMessage: string;
+ entries: PlaylistEntry[];
+ errorMessage?: string;
+ isLoading?: boolean;
+ listClassName?: string;
+};
+
+export default function RoomQueueList({
+ emptyMessage,
+ entries,
+ errorMessage,
+ isLoading = false,
+ listClassName,
+}: Props) {
+ if (isLoading) {
+ return 플레이리스트를 불러오는 중입니다.
;
+ }
+
+ if (errorMessage) {
+ return {errorMessage}
;
+ }
+
+ if (entries.length === 0) {
+ return {emptyMessage}
;
+ }
+
+ return (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/room/queue/ui/RoomQueuePanel.module.css b/src/features/room/queue/ui/RoomQueuePanel.module.css
new file mode 100644
index 0000000..bd40a0f
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueuePanel.module.css
@@ -0,0 +1,36 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: #242424;
+ overflow-x: hidden;
+}
+
+.listArea {
+ flex: 1;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.refreshing {
+ flex-shrink: 0;
+ padding: 8px 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ background: #1f1f1f;
+ color: rgba(255, 255, 255, 0.56);
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-align: center;
+}
+
+.error {
+ flex-shrink: 0;
+ padding: 8px 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ background: #442626;
+ color: #ffd6d6;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-align: center;
+}
diff --git a/src/features/room/queue/ui/RoomQueuePanel.tsx b/src/features/room/queue/ui/RoomQueuePanel.tsx
new file mode 100644
index 0000000..45ec933
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueuePanel.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import { useState } from "react";
+import { useRoomQueue } from "@/src/entities/playlist/model/useRoomQueue";
+import { useMoveMyQueueEntry } from "@/src/entities/playlist/model/useMoveMyQueueEntry";
+import { useMe } from "@/src/entities/user/hooks/useMe";
+import { isEntryRequestedByUser, type QueueTab } from "../model/roomQueue";
+import RoomQueueList from "./RoomQueueList";
+import RoomQueueSortableList from "./RoomQueueSortableList";
+import RoomQueueTabs from "./RoomQueueTabs";
+import styles from "./RoomQueuePanel.module.css";
+
+type Props = {
+ roomPassword?: string | null;
+ roomSlug: string;
+};
+
+export default function RoomQueuePanel({ roomPassword, roomSlug }: Props) {
+ const [activeTab, setActiveTab] = useState("all");
+ const [moveErrorMessage, setMoveErrorMessage] = useState("");
+ const { data: currentUser, isLoading: isMeLoading } = useMe();
+ const {
+ data: entries,
+ error,
+ isLoading,
+ isRefetching,
+ } = useRoomQueue(roomSlug, roomPassword, 0, 200);
+ const moveMyQueueEntry = useMoveMyQueueEntry();
+
+ const allEntries = entries ?? [];
+ const myEntries = allEntries.filter((entry) =>
+ isEntryRequestedByUser(entry, currentUser),
+ );
+ const errorMessage = error?.message || "플레이리스트를 불러오지 못했습니다.";
+
+ let emptyMessage = "플레이리스트가 아직 비어 있습니다.";
+ if (activeTab === "mine") {
+ if (isMeLoading) {
+ emptyMessage = "내 신청곡 정보를 확인하는 중입니다.";
+ } else if (!currentUser) {
+ emptyMessage = "내 신청곡을 확인할 수 없습니다.";
+ } else {
+ emptyMessage = "내가 신청한 곡이 아직 없습니다.";
+ }
+ }
+
+ return (
+
+
+
+ {activeTab === "all" ? (
+
+ ) : (
+ {
+ setMoveErrorMessage("");
+ moveMyQueueEntry.mutate(
+ {
+ beforeEntryId,
+ movedEntryId,
+ orderedPendingEntryIds,
+ password: roomPassword,
+ slug: roomSlug,
+ },
+ {
+ onError: (moveError) => {
+ setMoveErrorMessage(
+ moveError.message || "큐 순서를 변경하지 못했습니다.",
+ );
+ },
+ },
+ );
+ }}
+ />
+ )}
+
+ {activeTab === "mine" && moveErrorMessage ? (
+
{moveErrorMessage}
+ ) : null}
+ {activeTab === "mine" && moveMyQueueEntry.isPending ? (
+
큐 순서를 변경하는 중...
+ ) : null}
+ {isRefetching ? (
+
최신 목록으로 갱신 중...
+ ) : null}
+
+ );
+}
diff --git a/src/features/room/queue/ui/RoomQueueSortableList.module.css b/src/features/room/queue/ui/RoomQueueSortableList.module.css
new file mode 100644
index 0000000..b9cb4a9
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueSortableList.module.css
@@ -0,0 +1,23 @@
+.root {
+ min-height: 100%;
+ overflow-x: hidden;
+}
+
+.fixedTopList {
+ list-style: none;
+ overflow-x: hidden;
+}
+
+.sortableList {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow-x: hidden;
+}
+
+.fixedList {
+ margin: 8px 0 0;
+ padding: 0;
+ list-style: none;
+ overflow-x: hidden;
+}
diff --git a/src/features/room/queue/ui/RoomQueueSortableList.tsx b/src/features/room/queue/ui/RoomQueueSortableList.tsx
new file mode 100644
index 0000000..ad411f8
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueSortableList.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ closestCenter,
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type DragStartEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { PlaylistEntry } from "@/src/entities/playlist/model/types";
+import { isPendingQueueEntry } from "../model/roomQueue";
+import RoomQueueCard from "./RoomQueueCard";
+import listStyles from "./RoomQueueList.module.css";
+import styles from "./RoomQueueSortableList.module.css";
+import { createPortal } from "react-dom";
+
+type MovePayload = {
+ movedEntryId: string;
+ beforeEntryId: string | null;
+ orderedPendingEntryIds: string[];
+};
+
+type Props = {
+ emptyMessage: string;
+ entries: PlaylistEntry[];
+ errorMessage?: string;
+ isLoading?: boolean;
+ isMovePending?: boolean;
+ onMove?: (payload: MovePayload) => void;
+};
+
+type SortableQueueCardProps = {
+ disabled: boolean;
+ entry: PlaylistEntry;
+};
+
+function SortableQueueCard({ disabled, entry }: SortableQueueCardProps) {
+ const {
+ attributes,
+ isDragging,
+ listeners,
+ setActivatorNodeRef,
+ setNodeRef,
+ transform,
+ transition,
+ } = useSortable({
+ disabled,
+ id: entry.entryId,
+ });
+
+ return (
+
+ );
+}
+
+export default function RoomQueueSortableList({
+ emptyMessage,
+ entries,
+ errorMessage,
+ isLoading = false,
+ isMovePending = false,
+ onMove,
+}: Props) {
+ const [pendingEntries, setPendingEntries] = useState(
+ entries.filter(isPendingQueueEntry),
+ );
+ const [activeEntryId, setActiveEntryId] = useState(null);
+ const activeFixedEntries = entries.filter((entry) => entry.status.isActive);
+ const fixedEntries = entries.filter(
+ (entry) => !isPendingQueueEntry(entry) && !entry.status.isActive,
+ );
+ const activeEntry = useMemo(
+ () =>
+ activeEntryId
+ ? pendingEntries.find((entry) => entry.entryId === activeEntryId) ?? null
+ : null,
+ [activeEntryId, pendingEntries],
+ );
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ useEffect(() => {
+ setPendingEntries(entries.filter(isPendingQueueEntry));
+ }, [entries]);
+
+ function handleDragStart({ active }: DragStartEvent) {
+ setActiveEntryId(String(active.id));
+ }
+
+ function handleDragCancel() {
+ setActiveEntryId(null);
+ }
+
+ function handleDragEnd({ active, over }: DragEndEvent) {
+ const activeEntryId = String(active.id);
+ const overEntryId = over ? String(over.id) : null;
+ setActiveEntryId(null);
+
+ if (!overEntryId || activeEntryId === overEntryId || isMovePending) {
+ return;
+ }
+
+ const oldIndex = pendingEntries.findIndex(
+ (entry) => entry.entryId === activeEntryId,
+ );
+ const newIndex = pendingEntries.findIndex(
+ (entry) => entry.entryId === overEntryId,
+ );
+
+ if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) {
+ return;
+ }
+
+ const reorderedEntries = arrayMove(pendingEntries, oldIndex, newIndex);
+ setPendingEntries(reorderedEntries);
+
+ onMove?.({
+ beforeEntryId: reorderedEntries[newIndex + 1]?.entryId ?? null,
+ movedEntryId: activeEntryId,
+ orderedPendingEntryIds: reorderedEntries.map((entry) => entry.entryId),
+ });
+ }
+
+ if (isLoading) {
+ return (
+ 플레이리스트를 불러오는 중입니다.
+ );
+ }
+
+ if (errorMessage) {
+ return {errorMessage}
;
+ }
+
+ if (entries.length === 0) {
+ return {emptyMessage}
;
+ }
+
+ return (
+
+ {activeFixedEntries.length > 0 ? (
+
+ {activeFixedEntries.map((entry) => (
+
+ ))}
+
+ ) : null}
+ {pendingEntries.length > 0 ? (
+
+ entry.entryId)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {pendingEntries.map((entry) => (
+
+ ))}
+
+
+ {typeof document !== "undefined"
+ ? createPortal(
+
+ {activeEntry ? (
+
+ ) : null}
+ ,
+ document.body,
+ )
+ : null}
+
+ ) : null}
+ {fixedEntries.length > 0 ? (
+
+ {fixedEntries.map((entry) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/room/queue/ui/RoomQueueTabs.module.css b/src/features/room/queue/ui/RoomQueueTabs.module.css
new file mode 100644
index 0000000..be6c3d0
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueTabs.module.css
@@ -0,0 +1,48 @@
+.tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ flex-shrink: 0;
+ background: #242424;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.tab {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-width: 0;
+ padding: 0px 12px 8px;
+ border: 0;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.54);
+ font-size: 14px;
+ font-weight: var(--fw-semibold);
+ cursor: pointer;
+}
+
+.tab[data-active="true"] {
+ color: #ffffff;
+}
+
+.tab[data-active="true"]::after {
+ content: "";
+ position: absolute;
+ left: 16px;
+ right: 16px;
+ bottom: 0;
+ height: 1px;
+ border-radius: 999px;
+ background: #ffffff;
+}
+
+.tabCount {
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+
+.tab[data-active="true"] .tabCount {
+ color: rgba(255, 255, 255, 0.82);
+}
diff --git a/src/features/room/queue/ui/RoomQueueTabs.tsx b/src/features/room/queue/ui/RoomQueueTabs.tsx
new file mode 100644
index 0000000..a9ebf35
--- /dev/null
+++ b/src/features/room/queue/ui/RoomQueueTabs.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import type { QueueTab } from "../model/roomQueue";
+import styles from "./RoomQueueTabs.module.css";
+
+type Props = {
+ activeTab: QueueTab;
+ allCount: number;
+ myCount: number;
+ onChange: (nextTab: QueueTab) => void;
+};
+
+export default function RoomQueueTabs({
+ activeTab,
+ allCount,
+ myCount,
+ onChange,
+}: Props) {
+ return (
+
+ onChange("all")}
+ >
+ 전체 트랙
+ {allCount}
+
+ onChange("mine")}
+ >
+ 내 신청곡
+ {myCount}
+
+
+ );
+}
diff --git a/src/shared/ui/radial-control/RadialControl.module.css b/src/shared/ui/radial-control/RadialControl.module.css
index 1b2beca..9e74de7 100644
--- a/src/shared/ui/radial-control/RadialControl.module.css
+++ b/src/shared/ui/radial-control/RadialControl.module.css
@@ -25,12 +25,14 @@
top: 12px;
left: 50%;
transform: translateX(-50%);
+ cursor: pointer;
}
.bottomSlot {
bottom: 12px;
left: 50%;
transform: translateX(-50%);
+ cursor: pointer;
}
.leftSlot {
diff --git a/src/widgets/home/ui/HomeControlPanelShell.module.css b/src/widgets/home/ui/HomeControlPanelShell.module.css
new file mode 100644
index 0000000..e8643bc
--- /dev/null
+++ b/src/widgets/home/ui/HomeControlPanelShell.module.css
@@ -0,0 +1,105 @@
+.panel {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 14px;
+ background:
+ linear-gradient(rgba(32, 32, 32, 0.96), rgba(32, 32, 32, 0.96)),
+ rgba(32, 32, 32, 0.96);
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26);
+ color: #f6f6f6;
+ backdrop-filter: blur(14px);
+ animation: panelEnter 180ms ease;
+}
+
+.menuPanel {
+ width: min(92vw, 380px);
+ padding: 12px 14px;
+}
+
+.menuRow {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.menuItem,
+.optionChip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ padding: 0 14px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: var(--fw-extrabold);
+ line-height: 1;
+ letter-spacing: 0.02em;
+ color: #f1f1f1;
+ background: transparent;
+ white-space: nowrap;
+}
+
+.activeChip {
+ background: #f5f5f5;
+ color: #161616;
+}
+
+.filterPanel {
+ width: min(92vw, 460px);
+ padding: 18px;
+}
+
+.filterSection + .filterSection {
+ margin-top: 22px;
+}
+
+.sectionTitle {
+ display: block;
+ margin-bottom: 12px;
+ font-size: 14px;
+ font-weight: var(--fw-medium);
+ color: #d2d2d2;
+}
+
+.optionGrid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.optionChip {
+ min-width: 86px;
+}
+
+@keyframes panelEnter {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@media (max-width: 900px) {
+ .menuPanel {
+ width: min(92vw, 340px);
+ }
+
+ .menuRow {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .filterPanel {
+ padding: 16px;
+ }
+
+ .optionGrid {
+ gap: 10px;
+ }
+
+ .optionChip {
+ min-width: calc(50% - 5px);
+ }
+}
diff --git a/src/widgets/home/ui/HomeControlPanelShell.tsx b/src/widgets/home/ui/HomeControlPanelShell.tsx
new file mode 100644
index 0000000..0216b34
--- /dev/null
+++ b/src/widgets/home/ui/HomeControlPanelShell.tsx
@@ -0,0 +1,78 @@
+import styles from "./HomeControlPanelShell.module.css";
+
+type HomeControlPanelVariant = "menu" | "filter";
+
+export const HOME_CONTROL_PANEL_IDS = {
+ menu: "home-menu-panel",
+ filter: "home-filter-panel",
+} as const;
+
+type Props = {
+ variant: HomeControlPanelVariant;
+};
+
+const menuItems = ["QUE", "CREATE", "FRIEND", "SETTING"];
+
+const filterSections = [
+ {
+ title: "Genre",
+ options: ["ALL", "POP", "K-POP", "J-POP", "ANIMATION", "BAND", "HIP-HOP"],
+ },
+ {
+ title: "Date",
+ options: ["RANDOM", "OLD", "NEW"],
+ },
+ {
+ title: "Participants",
+ options: ["RANDOM", "HIGH", "LOW"],
+ },
+] as const;
+
+export default function HomeControlPanelShell({ variant }: Props) {
+ const panelId = HOME_CONTROL_PANEL_IDS[variant];
+
+ if (variant === "menu") {
+ return (
+
+
+ {menuItems.map((item, index) => (
+
+ {item}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {filterSections.map((section) => (
+
+
{section.title}
+
+ {section.options.map((option, index) => (
+
+ {option}
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/home/ui/HomeScreen.module.css b/src/widgets/home/ui/HomeScreen.module.css
index 576ef87..ce3158a 100644
--- a/src/widgets/home/ui/HomeScreen.module.css
+++ b/src/widgets/home/ui/HomeScreen.module.css
@@ -13,8 +13,31 @@
z-index: 20;
}
+.panelAnchor {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 18px);
+ transform: translateX(-50%);
+ display: flex;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.panelAnchor > * {
+ pointer-events: auto;
+}
+
+.controlToggle {
+ letter-spacing: 0.04em;
+ transition: color 160ms ease;
+}
+
@media (max-width: 900px) {
.controlWrap {
bottom: 32px;
}
+
+ .panelAnchor {
+ bottom: calc(100% + 14px);
+ }
}
diff --git a/src/widgets/home/ui/HomeScreen.tsx b/src/widgets/home/ui/HomeScreen.tsx
index 32fdde1..8ba4bbb 100644
--- a/src/widgets/home/ui/HomeScreen.tsx
+++ b/src/widgets/home/ui/HomeScreen.tsx
@@ -1,15 +1,22 @@
"use client";
+import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useRoomsQuery } from "@/src/entities/room/hooks/useFetchRooms";
import { useRoomNavigator } from "@/src/shared/lib/useRoomNavigator";
import RadialControl from "@/src/shared/ui/radial-control/RadialControl";
+import HomeControlPanelShell, {
+ HOME_CONTROL_PANEL_IDS,
+} from "./HomeControlPanelShell";
import HomeTopBar from "./HomeTopBar";
import HomeRoomStage from "@/src/features/room/list/ui/HomeRoomStage";
import styles from "./HomeScreen.module.css";
+type HomePanelKey = "menu" | "filter";
+
export default function HomeScreen() {
+ const [openPanel, setOpenPanel] = useState(null);
const { data, isLoading, isError, error } = useRoomsQuery();
const rooms = data?.rooms ?? [];
const {
@@ -22,6 +29,10 @@ export default function HomeScreen() {
goNext,
} = useRoomNavigator(rooms);
+ const togglePanel = (panel: HomePanelKey) => {
+ setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
+ };
+
if (isLoading) return 방 목록 로딩중...
;
if (isError)
return (
@@ -40,9 +51,25 @@ export default function HomeScreen() {
/>
{currentRoom ? (
+ {openPanel ? (
+
+
+
+ ) : null}
MENU}
+ top={
+ togglePanel("menu")}
+ aria-controls={HOME_CONTROL_PANEL_IDS.menu}
+ aria-expanded={openPanel === "menu"}
+ data-active={openPanel === "menu"}
+ >
+ {openPanel === "menu" ? "X" : "MENU"}
+
+ }
left={
}
- bottom={FILTER }
+ bottom={
+ togglePanel("filter")}
+ aria-controls={HOME_CONTROL_PANEL_IDS.filter}
+ aria-expanded={openPanel === "filter"}
+ data-active={openPanel === "filter"}
+ >
+ {openPanel === "filter" ? "X" : "FILTER"}
+
+ }
/>
) : null}
diff --git a/src/widgets/room/model/useFloatingWidgetsState.ts b/src/widgets/room/model/useFloatingWidgetsState.ts
index ccc13cc..31a1303 100644
--- a/src/widgets/room/model/useFloatingWidgetsState.ts
+++ b/src/widgets/room/model/useFloatingWidgetsState.ts
@@ -74,7 +74,7 @@ const WIDGET_CONFIG: Record = {
},
queue: {
bottom: 140,
- height: 407,
+ height: 535,
left: 24,
offsetStorageKey: "queueWidgetOffset",
openStorageKey: "isQueueOpen",
@@ -157,10 +157,7 @@ function clampWidgetOffset(
};
}
-function getStoredWidgetOffset(
- key: string,
- widgetId: WidgetId,
-): WidgetOffset {
+function getStoredWidgetOffset(key: string, widgetId: WidgetId): WidgetOffset {
if (typeof window === "undefined") {
return { x: 0, y: 0 };
}
diff --git a/src/widgets/room/ui/FloatingRoomPanelShell.tsx b/src/widgets/room/ui/FloatingRoomPanelShell.tsx
index 7cb2f12..a5388ed 100644
--- a/src/widgets/room/ui/FloatingRoomPanelShell.tsx
+++ b/src/widgets/room/ui/FloatingRoomPanelShell.tsx
@@ -5,12 +5,14 @@ import styles from "./FloatingRoomPanelShell.module.css";
type Props = {
children: ReactNode;
+ contentClassName?: string;
height: number;
width: number;
};
export default function FloatingRoomPanelShell({
children,
+ contentClassName,
height,
width,
}: Props) {
@@ -23,7 +25,11 @@ export default function FloatingRoomPanelShell({
>
-
{children}
+
+ {children}
+
);
}
diff --git a/src/widgets/room/ui/RoomFloatingWidgets.module.css b/src/widgets/room/ui/RoomFloatingWidgets.module.css
index 76494cd..d609656 100644
--- a/src/widgets/room/ui/RoomFloatingWidgets.module.css
+++ b/src/widgets/room/ui/RoomFloatingWidgets.module.css
@@ -23,3 +23,13 @@
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
+
+.queuePanelContent {
+ padding: 0;
+ overflow: hidden;
+}
+
+.profilePanelContent {
+ padding: 0;
+ overflow: auto;
+}
diff --git a/src/widgets/room/ui/RoomFloatingWidgets.tsx b/src/widgets/room/ui/RoomFloatingWidgets.tsx
index b47ccc4..e57830a 100644
--- a/src/widgets/room/ui/RoomFloatingWidgets.tsx
+++ b/src/widgets/room/ui/RoomFloatingWidgets.tsx
@@ -7,18 +7,29 @@ import type {
FloatingWidgetsView,
WidgetId,
} from "@/src/widgets/room/model/useFloatingWidgetsState";
+import type { CurrentRequesterProfile } from "@/src/features/room/profile/model/types";
+import RoomProfilePanel from "@/src/features/room/profile/ui/RoomProfilePanel";
+import RoomQueuePanel from "@/src/features/room/queue/ui/RoomQueuePanel";
import FloatingRoomPanelShell from "./FloatingRoomPanelShell";
import styles from "./RoomFloatingWidgets.module.css";
type Props = {
+ currentRequester: CurrentRequesterProfile | null;
+ currentTrackTitle?: string | null;
onActivateWidget: (widgetId: WidgetId) => void;
onWidgetStop: (widgetId: WidgetId, data: DraggableData) => void;
+ roomPassword?: string | null;
+ roomSlug: string;
widgets: FloatingWidgetsView;
};
export default function RoomFloatingWidgets({
+ currentRequester,
+ currentTrackTitle,
onActivateWidget,
onWidgetStop,
+ roomPassword,
+ roomSlug,
widgets,
}: Props) {
const profileWidgetRef = useRef
(null);
@@ -45,10 +56,14 @@ export default function RoomFloatingWidgets({
>
@@ -72,10 +87,11 @@ export default function RoomFloatingWidgets({
>