"많이 쓰는 음악 사이트처럼" 이라는 요구에 맞춰, 형태가 관행과 달랐던 두 곳을 바꿨다. 1) 검색 결과: 그리드 카드 -> 리스트 행 그리드(커버 중심)는 앨범·재생목록을 훑을 때 쓰는 형태이고, 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(번호/썸네일/제목·아티스트/재생시간) 형식이다. 행 전체가 버튼이라 터치로도 바로 누를 수 있고, 마우스 환경에서는 번호 자리에 재생 아이콘이 겹치는 스포티파이 곡 목록 방식을 따랐다. 좁은 화면은 hover 가 없어 썸네일에 재생 아이콘을 항상 얹는다. 2) 대기열 접기/펴기 재생바 우측(스포티파이와 같은 자리)에 대기열 버튼을 두어 넓은 화면에서도 접을 수 있게 했다. 화면 크기별 기본값이 달라(좁은 화면=접힘, 넓은 화면=펼침) 상태를 둘로 나눴다. 창 크기를 읽어 한 상태로 합치면 SSR/하이드레이션이 어긋나고 setState-in-effect 경고도 난다. 검증: tsc 통과, lint 에러 0. 데스크톱(1440x900)·모바일(390x844)에서 검색 결과 행 렌더링과 대기열 접기/펴기 동작을 실제 화면으로 확인. README 5-3 비교표에 두 항목 추가.
153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { ListMusic, X } from "lucide-react";
|
|
import TopNav from "@/components/layout/TopNav";
|
|
import LeftSidebar from "@/components/layout/LeftSidebar";
|
|
import MainContent from "@/components/player/MainContent";
|
|
import QueueSidebar from "@/components/player/QueueSidebar";
|
|
import PlayerBar from "@/components/player/PlayerBar";
|
|
import type { DiscordServer, Track } from "@/types/music";
|
|
|
|
// 화면 모드 타입 정의
|
|
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
|
|
|
export default function MusicPlayerLayout() {
|
|
const { status } = useSession();
|
|
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
|
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
// 대기열 표시 상태는 화면 크기별로 기본값이 달라서 두 개로 나눠 둔다.
|
|
// (창 크기를 읽어 한 상태로 합치면 SSR·하이드레이션이 어긋난다)
|
|
// · 좁은 화면: 기본 접힘 → 버튼으로 슬라이드 패널을 연다
|
|
// · 넓은 화면: 기본 펼침 → 재생바의 대기열 버튼으로 접을 수 있다
|
|
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
|
const [isQueueDocked, setIsQueueDocked] = useState(true);
|
|
// 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함).
|
|
const [nowPlaying, setNowPlaying] = useState<Track | null>(null);
|
|
const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []);
|
|
|
|
const isAuthed = status === "authenticated";
|
|
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
|
|
const showPlayback = isAuthed && !!selectedServer;
|
|
|
|
const handleHome = () => {
|
|
if (selectedServer) {
|
|
setViewMode("SERVER_DETAIL");
|
|
} else {
|
|
setViewMode("SERVER_LIST");
|
|
setSelectedServer(null);
|
|
setSearchQuery("");
|
|
}
|
|
};
|
|
|
|
const handleSearch = (query: string) => {
|
|
setSearchQuery(query);
|
|
setViewMode("SEARCH_RESULT");
|
|
};
|
|
|
|
const handleSelectServer = (server: DiscordServer) => {
|
|
setSelectedServer(server);
|
|
setViewMode("SERVER_DETAIL");
|
|
};
|
|
|
|
const handleBackToServers = () => {
|
|
setViewMode("SERVER_LIST");
|
|
setSelectedServer(null);
|
|
setSearchQuery("");
|
|
setIsQueueOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-dvh flex-col overflow-hidden bg-neutral-950 font-sans text-white">
|
|
<TopNav
|
|
onSearch={handleSearch}
|
|
onHome={handleHome}
|
|
selectedServer={selectedServer}
|
|
/>
|
|
|
|
<div className="relative flex flex-1 overflow-hidden">
|
|
{/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */}
|
|
{showPlayback && (
|
|
<LeftSidebar selectedServer={selectedServer} onBackToServers={handleBackToServers} />
|
|
)}
|
|
|
|
<MainContent
|
|
viewMode={viewMode}
|
|
setViewMode={setViewMode}
|
|
selectedServer={selectedServer}
|
|
setSelectedServer={setSelectedServer}
|
|
searchQuery={searchQuery}
|
|
setSearchQuery={setSearchQuery}
|
|
onSelectServer={handleSelectServer}
|
|
/>
|
|
|
|
{showPlayback && (
|
|
<>
|
|
{/* 모바일에서 대기열이 열렸을 때 뒤쪽을 덮는 배경(탭하면 닫힘) */}
|
|
{isQueueOpen && (
|
|
<button
|
|
type="button"
|
|
aria-label="대기열 닫기"
|
|
onClick={() => setIsQueueOpen(false)}
|
|
className="absolute inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
|
|
/>
|
|
)}
|
|
|
|
{/* 대기열: 데스크톱은 고정 컬럼, 모바일은 슬라이드 패널 (인스턴스는 하나만 유지) */}
|
|
<div
|
|
className={`absolute inset-y-0 right-0 z-40 w-[22rem] max-w-[88%] transform border-l border-neutral-800 bg-neutral-950 shadow-2xl transition-transform duration-300 ease-out
|
|
${isQueueOpen ? "translate-x-0" : "translate-x-full"}
|
|
${isQueueDocked
|
|
? "lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none"
|
|
: "lg:hidden"}`}
|
|
>
|
|
<QueueSidebar
|
|
selectedServer={selectedServer}
|
|
nowPlaying={nowPlaying}
|
|
onClose={() => setIsQueueOpen(false)}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{showPlayback && (
|
|
<PlayerBar
|
|
selectedServer={selectedServer}
|
|
onToggleQueue={() => setIsQueueOpen((v) => !v)}
|
|
isQueueOpen={isQueueOpen}
|
|
onToggleDock={() => setIsQueueDocked((v) => !v)}
|
|
isQueueDocked={isQueueDocked}
|
|
onNowPlayingChange={handleNowPlayingChange}
|
|
/>
|
|
)}
|
|
|
|
{/* 모바일 전용 대기열 열기 버튼: 플레이어바에도 있지만, 재생 전에도 접근할 수 있게 둔다. */}
|
|
{showPlayback && !isQueueOpen && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsQueueOpen(true)}
|
|
className="fixed bottom-24 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-black shadow-xl transition hover:bg-green-400 active:scale-95 lg:hidden"
|
|
aria-label="대기열 열기"
|
|
>
|
|
<ListMusic size={24} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 모바일 패널 닫기 버튼(패널 위에 떠 있음) */}
|
|
{showPlayback && isQueueOpen && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsQueueOpen(false)}
|
|
className="fixed right-4 top-20 z-50 flex h-11 w-11 items-center justify-center rounded-full bg-neutral-800 text-white shadow-lg transition hover:bg-neutral-700 lg:hidden"
|
|
aria-label="대기열 닫기"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|