feat: 로그인·서버 선택 페이지 분리 + 플레이어 기능 추가(섞기·자동재생·주소 불러오기 등)
page - /login(디스코드 로그인 전용), /servers(서버 선택 전용), /servers/[id](유튜브 뮤직 플레이어)로 분리 · 서버 컴포넌트에서 로그인 검사, callbackUrl 은 같은 사이트 경로만 허용 · NextAuth 로그인/오류 화면도 /login 으로 - 서버 선택: 최근 사용 서버 맨 앞 "최근" 표시, 새로고침, 8개 초과 시 이름 검색, 오류 표시 - 플레이어: 검색어를 ?q= 로 남겨 뒤로가기 지원, 탭 제목에 지금 곡, 내 서버가 아니면 안내 - 검색창: 최근 검색어 목록, 유튜브·스포티파이 주소를 붙여넣으면 곡/재생목록 바로 불러오기 - 다음 트랙: 섞기, 모두 지우기(두 번 눌러 확인), 자동재생 스위치, 곡 수·총 길이 - 재생바: 처음부터 다시(⏮), 섞기 버튼 / 서버 홈: 음성채널 표시, 사용 안내, 최근 검색 칩 - 곡 추가 중 스피너 + 중복 추가 방지, 검색 실패 시 다시 시도, play/playlist 응답 대기 15초 - 계정 전환형 프로필 메뉴와 사이드바 서버 목록 제거(프로필은 서버 변경·로그아웃만) bot - Redis 액션 queue_shuffle, player_recommend 추가 - player_now 응답에 recommend(자동재생), voiceChannelName 추가 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, GripVertical, Play, Trash2 } from "lucide-react";
|
||||
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";
|
||||
@@ -20,8 +20,19 @@ interface QueueListProps {
|
||||
* - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다
|
||||
*/
|
||||
export default function QueueList({ player, queue, variant }: QueueListProps) {
|
||||
const { track: nowPlaying, isPaused } = player;
|
||||
const { queue: items, moveTo, moveBy, playFrom, remove } = queue;
|
||||
const { track: nowPlaying, isPaused, autoplay, toggleAutoplay } = player;
|
||||
const { queue: items, 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 totalMs = items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0);
|
||||
const totalMin = Math.round(totalMs / 60000);
|
||||
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
@@ -46,14 +57,65 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
|
||||
|
||||
return (
|
||||
<div className={isPage ? "" : "pb-6"}>
|
||||
{isPage && (
|
||||
<div className="mb-2 flex items-baseline justify-between gap-2 border-b border-yt-line pb-3">
|
||||
<h2 className="text-2xl font-bold">재생 목록</h2>
|
||||
<span className="text-sm text-white/50">
|
||||
{items.length > 0 ? `다음 트랙 ${items.length}곡` : ""}
|
||||
</span>
|
||||
{/* 머리말: 곡 수·총 길이 + 섞기 / 모두 지우기 */}
|
||||
<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">
|
||||
{items.length > 0
|
||||
? `다음 트랙 ${items.length}곡${totalMin > 0 ? ` · 약 ${totalMin}분` : ""}`
|
||||
: "다음 트랙 없음"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={shuffle}
|
||||
disabled={items.length < 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={items.length === 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 && (
|
||||
|
||||
Reference in New Issue
Block a user