- 봇: 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>
267 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|