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} + ) : ( + + )} +
+
+
{currentRequester.nickname}
+
+ +
+ {shouldShowError ? ( +
{error.message}
+ ) : null} +
+ {PLACEHOLDER_PROFILE_FIELDS.map((field) => ( +
+
{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.track.title} + {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 ( +
    + + +
    + ); +} 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={ + + } left={ } - bottom={FILTER} + bottom={ + + } />
    ) : 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({ >
    -
    큐 모달임
    +