Files
music_bot_v2/page/src/components/player/QueueList.tsx
tkrmagid 63456d8101 feat: 스포티파이 재생목록 최대 50,000곡까지 불러오기
- 봇: partner API(fetchPlaylist/getAlbum, 토크너 익명 토큰)로 첫 100곡을 받아 바로 재생,
  나머지는 1000곡씩(동시 3페이지) 백그라운드로 이어 붙임. 곡마다 앨범 커버 포함.
  실패 시 임베드 페이지(100곡) → Lavalink 순으로 폴백
- 정지·끝·모두 지우기 시 queueEpoch 증가 → 이어 받기 중단. '-s'는 페이지마다 재생목록 곡끼리 재섞기
- 토크너 토큰 캐시를 정식 API 토큰 캐시와 분리
- queue_list: 앞 200곡 + total/totalMs만 전송, queue_set: windowSize 부분만 교체·clearAll 지원
- 대시보드: 곡 수·총 길이는 전체 기준, '외 N곡' 표시, 모두 지우기는 clearAll

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:11:31 +09:00

267 lines
12 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp, GripVertical, ListX, Play, Shuffle, Trash2 } from "lucide-react";
import { Equalizer, Thumb } from "@/components/ui";
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { QueueState } from "@/hooks/useQueue";
interface QueueListProps {
player: PlayerState;
queue: QueueState;
/** page = 서버 페이지의 트랙 목록, panel = 재생 화면의 "다음 트랙" 탭 */
variant: "page" | "panel";
}
/**
* 유튜브 뮤직 "다음 트랙" 목록.
* 맨 위에 지금 재생 중인 곡(이퀄라이저 표시), 그 아래 대기열.
* - 곡 누르기: 그 곡부터 재생(앞 곡들은 대기열에서 빠진다)
* - 마우스: 왼쪽 손잡이로 드래그해 순서 변경, hover 시 삭제 버튼
* - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다
*/
export default function QueueList({ player, queue, variant }: QueueListProps) {
const { track: nowPlaying, isPaused, autoplay, toggleAutoplay } = player;
// items 는 대기열 앞부분(최대 200곡)만, total·totalMs 는 대기열 전체 기준
const { queue: items, total, totalMs, moveTo, moveBy, playFrom, remove, shuffle, clear } = queue;
// "모두 지우기"는 되돌릴 수 없어서 한 번 더 눌러야 실행한다(3초 안에).
const [confirmClear, setConfirmClear] = useState(false);
useEffect(() => {
if (!confirmClear) return;
const t = setTimeout(() => setConfirmClear(false), 3000);
return () => clearTimeout(t);
}, [confirmClear]);
const totalMin = Math.round(totalMs / 60000);
const hiddenCount = Math.max(total - items.length, 0);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const handleSort = async () => {
const fromIndex = dragItem.current;
const toIndex = dragOverItem.current;
setDraggingIndex(null);
setDragOverIndex(null);
dragItem.current = null;
dragOverItem.current = null;
if (fromIndex === null || toIndex === null) return;
await moveTo(fromIndex, toIndex);
};
const isPage = variant === "page";
const thumbSize = "h-10 w-10";
return (
<div className={isPage ? "" : "pb-6"}>
{/* 머리말: 곡 수·총 길이 + 섞기 / 모두 지우기 */}
<div className={`mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 ${isPage ? "border-b border-yt-line pb-3" : "px-2"}`}>
<div className="min-w-0">
{isPage && <h2 className="text-2xl font-bold">재생 목록</h2>}
<p className="text-sm text-white/60">
{total > 0
? `다음 트랙 ${total.toLocaleString()}곡${totalMin > 0 ? ` · 약 ${totalMin >= 60 ? `${Math.floor(totalMin / 60).toLocaleString()}시간 ${totalMin % 60}분` : `${totalMin}분`}` : ""}`
: "다음 트랙 없음"}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={shuffle}
disabled={total < 2}
title="다음 트랙 섞기"
className="flex h-8 items-center gap-1.5 rounded-full bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover disabled:cursor-not-allowed disabled:opacity-40"
>
<Shuffle size={16} aria-hidden />
섞기
</button>
<button
type="button"
onClick={() => {
if (confirmClear) {
setConfirmClear(false);
clear();
} else setConfirmClear(true);
}}
disabled={total === 0}
title="다음 트랙 모두 지우기(지금 곡은 계속 재생)"
className={`flex h-8 items-center gap-1.5 rounded-full px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40
${confirmClear ? "bg-yt-red text-white" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
>
<ListX size={16} aria-hidden />
{confirmClear ? "한 번 더 누르면 지워요" : "모두 지우기"}
</button>
</div>
</div>
{/* 자동재생(유튜브 뮤직 "다음 트랙" 위의 스위치와 같은 자리) */}
<div className="mb-2 flex items-center justify-between gap-4 rounded-md px-2 py-2">
<span className="min-w-0">
<span className="block text-sm font-medium text-white">자동재생</span>
<span className="block text-xs text-white/60">대기열이 끝나면 비슷한 곡을 이어서 틀어요</span>
</span>
<button
type="button"
role="switch"
aria-checked={autoplay}
aria-label="자동재생"
onClick={toggleAutoplay}
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${autoplay ? "bg-[#3ea6ff]" : "bg-white/30"}`}
>
<span
className={`absolute left-0 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${autoplay ? "translate-x-[22px]" : "translate-x-0.5"}`}
/>
</button>
</div>
{/* 지금 재생 중 */}
{nowPlaying && (
<div className="flex items-center gap-4 rounded-md bg-white/10 px-2 py-2">
<span className="relative">
<Thumb src={nowPlaying.info?.artworkUrl} className={thumbSize} />
<span className="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
<Equalizer paused={isPaused} />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white">
{nowPlaying.info?.title || "제목 없음"}
</span>
<span className="block truncate text-sm text-white/70">
{cleanArtist(nowPlaying.info?.author) || "알 수 없는 아티스트"}
</span>
</span>
<span className="flex-shrink-0 pr-2 text-sm tabular-nums text-white/70">
{formatTime(nowPlaying.info?.length)}
</span>
</div>
)}
{items.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p className="text-base text-white">대기 중인 노래가 없습니다.</p>
<p className="text-sm text-white/50">검색해서 나온 곡을 누르면 여기에 쌓입니다.</p>
</div>
) : (
<>
{nowPlaying && (
<p className="px-2 pb-1 pt-4 text-xs font-medium text-white/50">다음 트랙</p>
)}
<ul>
{items.map((item, index) => {
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
const title = item.info?.title || "제목 없음";
return (
<li
key={`${index}-${(item.encoded as string) || title}`}
draggable
onDragStart={() => {
dragItem.current = index;
setTimeout(() => setDraggingIndex(index), 0);
}}
onDragEnter={() => {
dragOverItem.current = index;
setDragOverIndex(index);
}}
onDragEnd={handleSort}
onDragOver={(e) => e.preventDefault()}
className={`group relative flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-white/10
${draggingIndex === index ? "opacity-40" : ""}
${isDropTarget ? "shadow-[inset_0_2px_0_0_#fff]" : ""}`}
>
{/* 드래그 손잡이(마우스 전용) */}
<span
className="flex w-5 flex-shrink-0 cursor-grab justify-center text-white/50 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing pointer-coarse:hidden"
aria-hidden
>
<GripVertical size={16} />
</span>
<button
type="button"
onClick={() => playFrom(index)}
className="flex min-w-0 flex-1 items-center gap-4 rounded py-1 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={`${title} 부터 재생`}
title="이 곡부터 재생"
>
<span className="relative">
<Thumb src={item.info?.artworkUrl} className={thumbSize} />
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Play fill="white" stroke="white" size={16} aria-hidden />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white">{title}</span>
<span className="block truncate text-sm text-white/70">
{cleanArtist(item.info?.author) || "알 수 없는 아티스트"}
</span>
</span>
{/* 길이: 마우스 hover 때는 삭제 버튼에 자리를 내준다 */}
<span className="flex-shrink-0 text-sm tabular-nums text-white/70 group-hover:hidden pointer-coarse:hidden">
{formatTime(item.info?.length)}
</span>
</button>
<span className="hidden flex-shrink-0 items-center group-focus-within:flex group-hover:flex pointer-coarse:flex">
<button
type="button"
onClick={() => moveBy(index, -1)}
disabled={index === 0}
className="hidden h-10 w-9 items-center justify-center rounded-full text-white/70 hover:text-white disabled:opacity-30 pointer-coarse:flex"
aria-label={`${title} 위로 옮기기`}
>
<ChevronUp size={18} aria-hidden />
</button>
<button
type="button"
onClick={() => moveBy(index, 1)}
disabled={index === items.length - 1}
className="hidden h-10 w-9 items-center justify-center rounded-full text-white/70 hover:text-white disabled:opacity-30 pointer-coarse:flex"
aria-label={`${title} 아래로 옮기기`}
>
<ChevronDown size={18} aria-hidden />
</button>
<button
type="button"
onClick={() => remove(index)}
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={`${title} 대기열에서 삭제`}
title="대기열에서 삭제"
>
<Trash2 size={18} aria-hidden />
</button>
</span>
</li>
);
})}
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */}
{draggingIndex !== null && (
<li
onDragEnter={() => {
dragOverItem.current = items.length;
setDragOverIndex(items.length);
}}
onDragOver={(e) => e.preventDefault()}
className="flex h-10 items-start"
>
{dragOverIndex === items.length && <span className="h-0.5 w-full bg-white" />}
</li>
)}
</ul>
{hiddenCount > 0 && (
<p className="px-2 pt-3 text-center text-xs text-white/50">
외 {hiddenCount.toLocaleString()}곡 · 앞의 곡이 재생되면 차례대로 보여요
</p>
)}
</>
)}
</div>
);
}