diff --git a/page/README.md b/page/README.md index abd6767..eff285b 100644 --- a/page/README.md +++ b/page/README.md @@ -84,6 +84,57 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브 - `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입. - `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등). +### 5-1. 화면(모드)별 사용 흐름 + +화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지). + +| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 | +|------|-----------|------------------|----------------------| +| 로그인 전 | 첫 방문 | 중앙 로그인 카드 | 디스코드 로그인. 사이드바/대기열/재생바는 렌더링하지 않는다 | +| `SERVER_LIST` | 로그인 직후, 홈 버튼, "다른 서버 선택" | 봇이 있는 내 서버 카드 목록 | 서버 선택. 서버를 고르기 전에는 검색·재생이 잠겨 있다 | +| `SERVER_DETAIL` | 서버 카드 클릭 | 서버 이름/아이콘, 사용 안내 배너, 서버 ID·내 권한 | 검색 시작점. 좌측에 조작 중인 서버 표시 | +| `SEARCH_RESULT` | 검색창에 입력 후 Enter | Spotify / YouTube Music / YouTube 영상 3개 구역의 곡 카드 | 곡을 눌러 대기열에 추가 | +| 대기열 패널 | (넓은 화면) 항상 우측 고정 · (좁은 화면) 우하단 초록 버튼·재생바 목록 버튼 | "지금 재생 중" + "다음 재생 목록" | 순서 변경, 삭제, 특정 곡부터 재생 | + +### 5-2. 버튼·조작 동작 정리 + +| 위치 | 요소 | 동작 | 비활성 조건 | +|------|------|------|-------------| +| 상단 | 홈 | 서버를 고른 상태면 서버 상세로, 아니면 서버 목록으로. 검색어 초기화 | 로그아웃 상태에선 숨김 | +| 상단 | 검색창 | Enter 로 검색 실행 → `SEARCH_RESULT` | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) | +| 상단 | 프로필/로그아웃 | 세션 종료 | — | +| 본문 | 서버 카드 | 서버 선택 → 서버 상세 | — | +| 본문 | 곡 카드(카드 전체가 버튼) | `POST /api/player/play` → 대기열 추가. 성공/실패를 토스트로 알림 | 서버 미선택·미로그인 | +| 재생바 | 재생/일시정지 | `POST /api/player/pause` (낙관적 UI, 실패 시 롤백) | 봇 세션 없음/재생 중 아님 | +| 재생바 | 다음 곡 | `POST /api/player/skip` | 〃 | +| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 | +| 재생바 | 진행바 | 드래그/클릭으로 탐색 → `POST /api/player/seek`. 좁은 화면에서는 재생바 상단에 전체 폭으로 배치 | 〃 | +| 재생바 | 스피커 아이콘 | 음소거/해제 토글(해제 시 직전 볼륨 복원) | 〃 | +| 재생바 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 | +| 재생바 | 목록 아이콘 | 대기열 패널 열기/닫기(좁은 화면 전용) | — | +| 대기열 | 곡(제목 영역) | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다 | 첫 곡은 이미 다음 곡이라 동작 없음 | +| 대기열 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (좁은 화면 전용, 드래그 대체) | 맨 위/맨 아래 | +| 대기열 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지) | — | +| 대기열 | 드래그 핸들 | 드래그로 순서 변경(넓은 화면 전용) | — | +| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 | + +### 5-3. 다른 음악 서비스와 비교해 맞춘 점 + +스포티파이 웹 플레이어 / 유튜브 뮤직 / 애플 뮤직에서 공통으로 쓰이는 관행을 기준으로 삼았다. + +| 관행 | 예전 | 현재 | +|------|------|------| +| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 | +| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 | +| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) | +| 스피커 아이콘 클릭 = 음소거 | 장식 아이콘 | 토글 버튼 | +| 키보드 단축키(Space/M/화살표) | 없음 | 지원 | +| 재생시간 표기 | 없음 | 검색 결과 카드에 표시 | +| 터치 조작 크기(44px 내외) | 재생 32px·삭제 24px·진행바 1px | 44~48px, 진행바 히트영역 확대 | +| hover 없이도 조작 가능 | 삭제·재생 버튼이 hover 시에만 노출 | 상시 노출 | + +미지원(봇 API 한계): 이전 곡, 반복 재생, 특정 위치로의 셔플 재생. 필요하면 봇 쪽 액션 추가가 선행돼야 한다. + --- ## 6. 배포 메모 diff --git a/page/src/app/page.tsx b/page/src/app/page.tsx index 5efd49f..e548e0e 100644 --- a/page/src/app/page.tsx +++ b/page/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useSession } from "next-auth/react"; import { ListMusic, X } from "lucide-react"; import TopNav from "@/components/layout/TopNav"; @@ -8,7 +8,7 @@ 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 } from "@/types/music"; +import type { DiscordServer, Track } from "@/types/music"; // 화면 모드 타입 정의 export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; @@ -20,6 +20,9 @@ export default function MusicPlayerLayout() { const [searchQuery, setSearchQuery] = useState(""); // 모바일에서 대기열은 상시 컬럼이 아니라 오른쪽에서 밀려나오는 패널로 띄운다. const [isQueueOpen, setIsQueueOpen] = useState(false); + // 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함). + const [nowPlaying, setNowPlaying] = useState(null); + const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []); const isAuthed = status === "authenticated"; // 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다. @@ -97,6 +100,7 @@ export default function MusicPlayerLayout() { > setIsQueueOpen(false)} /> @@ -109,6 +113,7 @@ export default function MusicPlayerLayout() { selectedServer={selectedServer} onToggleQueue={() => setIsQueueOpen((v) => !v)} isQueueOpen={isQueueOpen} + onNowPlayingChange={handleNowPlayingChange} /> )} diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index 7f7efac..3b056a5 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -7,6 +7,13 @@ import { ViewMode } from "@/app/page"; import { useToast } from "@/components/ToastProvider"; import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music"; +/** ms -> m:ss (검색 결과 재생시간 표기용) */ +function formatDuration(ms?: number) { + if (!ms || Number.isNaN(ms)) return ""; + const total = Math.floor(ms / 1000); + return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`; +} + interface MainContentProps { viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; @@ -211,7 +218,13 @@ export default function MainContent({ {track.title} - {track.artist} + + {track.artist} + {/* 재생시간 표기는 어느 음악 서비스에나 있는 정보다(같은 제목의 짧은 클립/전체 곡 구분에 필요). */} + {track.duration ? ( + {formatDuration(track.duration)} + ) : null} + ); diff --git a/page/src/components/player/PlayerBar.tsx b/page/src/components/player/PlayerBar.tsx index 53e0053..2f3014a 100644 --- a/page/src/components/player/PlayerBar.tsx +++ b/page/src/components/player/PlayerBar.tsx @@ -8,9 +8,11 @@ interface PlayerBarProps { selectedServer: DiscordServer | null; onToggleQueue?: () => void; isQueueOpen?: boolean; + /** 대기열 패널에도 "지금 재생 중"을 보여주기 위해 현재 곡을 위로 올려준다(중복 폴링 방지). */ + onNowPlayingChange?: (track: Track | null) => void; } -export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen }: PlayerBarProps) { +export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onNowPlayingChange }: PlayerBarProps) { const { data: session } = useSession(); // 재생 상태 관리 @@ -29,6 +31,7 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen } // eslint-disable-next-line react-hooks/purity const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() }); const prevVolumeRef = useRef(50); // 드래그 시작 시점 볼륨(실패 시 롤백용) + const mutedVolumeRef = useRef(50); // 음소거 해제 시 복원할 볼륨 // 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적 const [isVolumeDragging, setIsVolumeDragging] = useState(false); @@ -241,6 +244,40 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen } } }; + /** 볼륨 값을 봇에 전송(실패하면 이전 값으로 되돌린다). */ + const pushVolume = async (next: number, rollbackTo: number) => { + if (!selectedServer) return; + try { + const res = await fetch('/api/player/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverId: selectedServer.id, volume: next }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.success) setVolume(rollbackTo); + } catch (error) { + console.error("볼륨 조절 에러:", error); + setVolume(rollbackTo); + } + }; + + /** + * 스피커 아이콘 클릭 = 음소거/해제. + * 스포티파이·유튜브뮤직·애플뮤직 모두 같은 동작이라, 아이콘을 장식으로 두지 않고 버튼으로 만든다. + */ + const handleToggleMute = async () => { + if (!canControl) return; + if (volume > 0) { + mutedVolumeRef.current = volume; // 해제할 때 돌아갈 값 기억 + setVolume(0); + await pushVolume(0, mutedVolumeRef.current); + } else { + const restore = mutedVolumeRef.current || 50; + setVolume(restore); + await pushVolume(restore, 0); + } + }; + // 시간 포맷 변환 함수 (ms -> mm:ss) const formatTime = (ms: number) => { if (!ms || isNaN(ms)) return "00:00"; @@ -255,6 +292,51 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen } const canControl = !!botPlayer && !!isPlaying && !!track; + // 현재 곡이 바뀌면 상위로 알려 대기열 패널의 "지금 재생 중"과 동기화한다. + useEffect(() => { + onNowPlayingChange?.(track); + }, [track, onNowPlayingChange]); + + /** + * 웹 음악 플레이어 표준 단축키(스포티파이/유튜브뮤직 공통): + * Space 재생·일시정지 / M 음소거 / ← → 5초 탐색 / Ctrl+→ 다음 곡 + * 검색창처럼 글자를 입력하는 곳에 포커스가 있으면 가로채지 않는다. + */ + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + const typing = + el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable; + if (typing || !canControl) return; + + if (e.code === "Space") { + e.preventDefault(); + handleTogglePause(); + } else if (e.key === "m" || e.key === "M" || e.key === "ㅡ") { + e.preventDefault(); + handleToggleMute(); + } else if (e.key === "ArrowRight" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSkip(); + } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + const delta = e.key === "ArrowRight" ? 5000 : -5000; + const next = Math.min(Math.max(position + delta, 0), duration || 0); + setPosition(next); + anchorRef.current = { pos: next, at: Date.now() }; + if (selectedServer) { + fetch('/api/player/seek', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverId: selectedServer.id, seek: next }), + }).catch((err) => console.error("시간 이동 에러:", err)); + } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + return (