feat(page): 곡 검색 결과를 리스트 행으로, 대기열 접기/펴기 추가 — 익숙한 형태로 정렬

"많이 쓰는 음악 사이트처럼" 이라는 요구에 맞춰, 형태가 관행과 달랐던 두 곳을 바꿨다.

1) 검색 결과: 그리드 카드 -> 리스트 행
   그리드(커버 중심)는 앨범·재생목록을 훑을 때 쓰는 형태이고, 개별 곡 검색 결과는
   스포티파이·유튜브뮤직 모두 행(번호/썸네일/제목·아티스트/재생시간) 형식이다.
   행 전체가 버튼이라 터치로도 바로 누를 수 있고, 마우스 환경에서는 번호 자리에
   재생 아이콘이 겹치는 스포티파이 곡 목록 방식을 따랐다. 좁은 화면은 hover 가 없어
   썸네일에 재생 아이콘을 항상 얹는다.

2) 대기열 접기/펴기
   재생바 우측(스포티파이와 같은 자리)에 대기열 버튼을 두어 넓은 화면에서도 접을 수 있게 했다.
   화면 크기별 기본값이 달라(좁은 화면=접힘, 넓은 화면=펼침) 상태를 둘로 나눴다.
   창 크기를 읽어 한 상태로 합치면 SSR/하이드레이션이 어긋나고 setState-in-effect 경고도 난다.

검증: tsc 통과, lint 에러 0. 데스크톱(1440x900)·모바일(390x844)에서 검색 결과 행 렌더링과
대기열 접기/펴기 동작을 실제 화면으로 확인. README 5-3 비교표에 두 항목 추가.
This commit is contained in:
2026-09-29 14:11:06 +09:00
parent 2b7a91774d
commit 3406ba64c4
4 changed files with 65 additions and 27 deletions

View File

@@ -124,6 +124,8 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
| 관행 | 예전 | 현재 |
|------|------|------|
| 곡 검색 결과 표시 | 그리드 카드 | 리스트 행(번호·썸네일·제목/아티스트·재생시간). 그리드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태이고, 개별 곡은 두 서비스 모두 행 형식이다 |
| 대기열 접기/펴기 | 넓은 화면에서 항상 고정 | 재생바 우측 대기열 버튼으로 접고 펼 수 있음(스포티파이와 같은 위치) |
| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 |
| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 |
| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) |

View File

@@ -18,8 +18,12 @@ export default function MusicPlayerLayout() {
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), []);
@@ -95,8 +99,9 @@ export default function MusicPlayerLayout() {
<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"}
lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none`}
aria-hidden={!isQueueOpen && typeof window !== "undefined" && window.innerWidth < 1024}
${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}
@@ -113,6 +118,8 @@ export default function MusicPlayerLayout() {
selectedServer={selectedServer}
onToggleQueue={() => setIsQueueOpen((v) => !v)}
isQueueOpen={isQueueOpen}
onToggleDock={() => setIsQueueDocked((v) => !v)}
isQueueDocked={isQueueDocked}
onNowPlayingChange={handleNowPlayingChange}
/>
)}

View File

@@ -197,34 +197,49 @@ export default function MainContent({
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
// 카드 전체가 눌리는 버튼이다. 예전에는 hover 시에만 나타나는 작은 아이콘만 눌러야 해서
// 터치 기기(hover 없음)에서는 재생 방법을 찾기 어려웠다.
const renderTrackCard = (track: SearchTrack) => (
/**
* 곡 하나는 '리스트 행'으로 보여준다.
* 그리드 카드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태고,
* 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(제목·아티스트·재생시간) 형식이라
* 사용자에게 익숙한 쪽으로 맞춘다. 행 전체가 버튼이라 터치로도 바로 누를 수 있다.
*/
const renderTrackRow = (track: SearchTrack, index: number) => (
<button
key={track.videoId || track.id || track.url || track.title}
type="button"
onClick={() => handleMusicAction('player_play', track)}
aria-label={`${track.title} 재생목록에 추가`}
className="group w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 text-left shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:bg-neutral-700/60"
>
<span className="relative mb-2 block aspect-square overflow-hidden rounded-md bg-neutral-700 shadow-lg">
{/* 번호 자리에 hover 시 재생 아이콘이 겹치는 건 스포티파이 곡 목록과 같은 방식 */}
<span className="hidden w-5 flex-shrink-0 text-center text-sm tabular-nums text-neutral-500 group-hover:hidden sm:block">
{index + 1}
</span>
<span className="hidden w-5 flex-shrink-0 items-center justify-center text-white sm:group-hover:flex">
<Play fill="currentColor" size={14} aria-hidden />
</span>
<span className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded bg-neutral-700">
{track.thumbnail && (
// eslint-disable-next-line @next/next/no-img-element
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" />
)}
{/* 터치 기기에서는 항상 보이고, 마우스 환경에서는 hover 시 강조된다. */}
<span className="absolute bottom-2 right-2 flex h-11 w-11 items-center justify-center rounded-full bg-green-500 shadow-xl transition-all group-hover:bg-green-400 sm:h-10 sm:w-10 sm:opacity-90 sm:group-hover:opacity-100">
<Play fill="black" stroke="black" size={18} aria-hidden />
{/* 좁은 화면은 hover 가 없으니 재생 아이콘을 항상 얹어 눌러야 할 곳을 알려준다. */}
<span className="absolute inset-0 flex items-center justify-center bg-black/40 sm:hidden">
<Play fill="white" stroke="white" size={16} aria-hidden />
</span>
</span>
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
<span className="flex items-center justify-between gap-2 text-xs text-neutral-400">
<span className="truncate">{track.artist}</span>
{/* 재생시간 표기는 어느 음악 서비스에나 있는 정보다(같은 제목의 짧은 클립/전체 곡 구분에 필요). */}
{track.duration ? (
<span className="flex-shrink-0 tabular-nums text-neutral-500">{formatDuration(track.duration)}</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
<span className="block truncate text-xs text-neutral-400">{track.artist}</span>
</span>
{track.duration ? (
<span className="flex-shrink-0 text-xs tabular-nums text-neutral-500">
{formatDuration(track.duration)}
</span>
) : null}
</button>
);
@@ -360,8 +375,8 @@ export default function MainContent({
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
<Disc size={24} /> Spotify 결과
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
{searchResults.spotify.map(renderTrackCard)}
<div className="flex flex-col">
{searchResults.spotify.map(renderTrackRow)}
</div>
</section>
)}
@@ -372,8 +387,8 @@ export default function MainContent({
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
<Music size={24} /> YouTube Music
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
{searchResults.youtubeMusic.map(renderTrackCard)}
<div className="flex flex-col">
{searchResults.youtubeMusic.map(renderTrackRow)}
</div>
</section>
)}
@@ -384,8 +399,8 @@ export default function MainContent({
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
{searchResults.youtubeVideo.map(renderTrackCard)}
<div className="flex flex-col">
{searchResults.youtubeVideo.map(renderTrackRow)}
</div>
</section>
)}

View File

@@ -8,11 +8,14 @@ interface PlayerBarProps {
selectedServer: DiscordServer | null;
onToggleQueue?: () => void;
isQueueOpen?: boolean;
/** 넓은 화면에서 우측 대기열 컬럼을 접었다 펴는 토글 */
onToggleDock?: () => void;
isQueueDocked?: boolean;
/** 대기열 패널에도 "지금 재생 중"을 보여주기 위해 현재 곡을 위로 올려준다(중복 폴링 방지). */
onNowPlayingChange?: (track: Track | null) => void;
}
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onNowPlayingChange }: PlayerBarProps) {
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onToggleDock, isQueueDocked, onNowPlayingChange }: PlayerBarProps) {
const { data: session } = useSession();
// 재생 상태 관리
@@ -423,7 +426,7 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
<SkipForward size={20} aria-hidden />
</button>
{/* 모바일 전용: 대기열 토글 */}
{/* 좁은 화면에서 대기열 열기(넓은 화면에는 우측 하단에 같은 버튼을 따로 둔다) */}
{onToggleQueue && (
<button
onClick={onToggleQueue}
@@ -469,8 +472,19 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
</div>
</div>
{/* 3. 볼륨 (좁은 화면에서는 숨긴다 — 기기 자체 볼륨으로 조절 가능) */}
{/* 3. 우측 보조 조작: 대기열 + 볼륨 (스포티파이도 이 자리에 대기열 버튼을 둔다) */}
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
{onToggleDock && (
<button
onClick={onToggleDock}
aria-label={isQueueDocked ? "대기열 접기" : "대기열 펼치기"}
title="대기열"
className={`hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white lg:flex
${isQueueDocked ? "text-green-500" : ""}`}
>
<ListMusic size={20} aria-hidden />
</button>
)}
<button
onClick={handleToggleMute}
disabled={!canControl}