feat(page): 주요 음악 서비스 관행에 맞춰 조작 보완 + 화면·버튼 동작 문서화
화면별 사용 흐름과 버튼 동작을 전수 정리한 뒤, 스포티파이 웹 플레이어·유튜브 뮤직·애플 뮤직에서 공통으로 쓰이는 관행과 대조해 빠져 있던 것을 채웠다. 추가/변경: - 스피커 아이콘을 장식에서 음소거 토글 버튼으로(해제 시 직전 볼륨 복원). 세 서비스 공통 동작. - 키보드 단축키: Space 재생/일시정지, M 음소거, 좌우 5초 탐색, Ctrl/Cmd+오른쪽 다음 곡. 입력창에 포커스가 있으면 가로채지 않는다. - 대기열 맨 위에 "지금 재생 중" 블록 추가. 재생바가 이미 폴링하는 값을 위로 올려 공유하므로 같은 API 를 두 번 호출하지 않는다. - 대기열의 곡을 누르면 그 곡부터 재생. 봇에 점프 액션이 없어 queue/set 으로 대상 곡부터 남긴 뒤 skip 하는 방식이며, 건너뛴 곡이 빠지는 것까지 스포티파이 대기열과 동일하다. - 검색 결과 카드에 재생시간 표기(같은 제목의 짧은 클립과 원곡 구분에 필요). 문서(page/README.md 5-1~5-3): - 화면(ViewMode)별 진입 경로·보이는 것·할 수 있는 일 표 - 버튼/조작별 동작·호출 API·비활성 조건 전수 표 - 타 서비스 관행 대비 개선 항목과 미지원 항목(이전 곡·반복 재생: 봇 API 한계) 명시 검증: tsc 통과, lint 에러 0. 브라우저에서 음소거 토글(볼륨 0↔60 복원), Space 로 재생↔일시정지 전환, "지금 재생 중" 표시, 대기열 곡의 "…부터 재생" 동작, 재생시간 표기를 실제 렌더링으로 확인.
This commit is contained in:
@@ -84,6 +84,57 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
|||||||
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
|
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
|
||||||
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
|
- `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. 배포 메모
|
## 6. 배포 메모
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { ListMusic, X } from "lucide-react";
|
import { ListMusic, X } from "lucide-react";
|
||||||
import TopNav from "@/components/layout/TopNav";
|
import TopNav from "@/components/layout/TopNav";
|
||||||
@@ -8,7 +8,7 @@ import LeftSidebar from "@/components/layout/LeftSidebar";
|
|||||||
import MainContent from "@/components/player/MainContent";
|
import MainContent from "@/components/player/MainContent";
|
||||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
import QueueSidebar from "@/components/player/QueueSidebar";
|
||||||
import PlayerBar from "@/components/player/PlayerBar";
|
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";
|
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||||
@@ -20,6 +20,9 @@ export default function MusicPlayerLayout() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
// 모바일에서 대기열은 상시 컬럼이 아니라 오른쪽에서 밀려나오는 패널로 띄운다.
|
// 모바일에서 대기열은 상시 컬럼이 아니라 오른쪽에서 밀려나오는 패널로 띄운다.
|
||||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||||
|
// 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함).
|
||||||
|
const [nowPlaying, setNowPlaying] = useState<Track | null>(null);
|
||||||
|
const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []);
|
||||||
|
|
||||||
const isAuthed = status === "authenticated";
|
const isAuthed = status === "authenticated";
|
||||||
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
|
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
|
||||||
@@ -97,6 +100,7 @@ export default function MusicPlayerLayout() {
|
|||||||
>
|
>
|
||||||
<QueueSidebar
|
<QueueSidebar
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
|
nowPlaying={nowPlaying}
|
||||||
onClose={() => setIsQueueOpen(false)}
|
onClose={() => setIsQueueOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +113,7 @@ export default function MusicPlayerLayout() {
|
|||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
onToggleQueue={() => setIsQueueOpen((v) => !v)}
|
onToggleQueue={() => setIsQueueOpen((v) => !v)}
|
||||||
isQueueOpen={isQueueOpen}
|
isQueueOpen={isQueueOpen}
|
||||||
|
onNowPlayingChange={handleNowPlayingChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { ViewMode } from "@/app/page";
|
|||||||
import { useToast } from "@/components/ToastProvider";
|
import { useToast } from "@/components/ToastProvider";
|
||||||
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
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 {
|
interface MainContentProps {
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: (mode: ViewMode) => void;
|
setViewMode: (mode: ViewMode) => void;
|
||||||
@@ -211,7 +218,13 @@ export default function MainContent({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
|
<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 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>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface PlayerBarProps {
|
|||||||
selectedServer: DiscordServer | null;
|
selectedServer: DiscordServer | null;
|
||||||
onToggleQueue?: () => void;
|
onToggleQueue?: () => void;
|
||||||
isQueueOpen?: boolean;
|
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();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
// 재생 상태 관리
|
// 재생 상태 관리
|
||||||
@@ -29,6 +31,7 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen }
|
|||||||
// eslint-disable-next-line react-hooks/purity
|
// eslint-disable-next-line react-hooks/purity
|
||||||
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
|
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
|
||||||
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
||||||
|
const mutedVolumeRef = useRef<number>(50); // 음소거 해제 시 복원할 볼륨
|
||||||
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
||||||
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
|
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(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)
|
// 시간 포맷 변환 함수 (ms -> mm:ss)
|
||||||
const formatTime = (ms: number) => {
|
const formatTime = (ms: number) => {
|
||||||
if (!ms || isNaN(ms)) return "00:00";
|
if (!ms || isNaN(ms)) return "00:00";
|
||||||
@@ -255,6 +292,51 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen }
|
|||||||
|
|
||||||
const canControl = !!botPlayer && !!isPlaying && !!track;
|
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 (
|
return (
|
||||||
<footer className="z-50 flex-shrink-0 border-t border-neutral-800 bg-neutral-950">
|
<footer className="z-50 flex-shrink-0 border-t border-neutral-800 bg-neutral-950">
|
||||||
{/* 모바일: 진행바를 맨 위에 가로로 꽉 채워 터치 영역을 넓게 확보한다. */}
|
{/* 모바일: 진행바를 맨 위에 가로로 꽉 채워 터치 영역을 넓게 확보한다. */}
|
||||||
@@ -389,7 +471,15 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen }
|
|||||||
|
|
||||||
{/* 3. 볼륨 (좁은 화면에서는 숨긴다 — 기기 자체 볼륨으로 조절 가능) */}
|
{/* 3. 볼륨 (좁은 화면에서는 숨긴다 — 기기 자체 볼륨으로 조절 가능) */}
|
||||||
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
|
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
|
||||||
<span aria-hidden>{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}</span>
|
<button
|
||||||
|
onClick={handleToggleMute}
|
||||||
|
disabled={!canControl}
|
||||||
|
aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
||||||
|
title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{volume === 0 ? <VolumeX size={20} aria-hidden /> : <Volume2 size={20} aria-hidden />}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="group relative flex h-8 w-24 items-center">
|
<div className="group relative flex h-8 w-24 items-center">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import type { DiscordServer, Track } from "@/types/music";
|
|||||||
|
|
||||||
interface QueueSidebarProps {
|
interface QueueSidebarProps {
|
||||||
selectedServer: DiscordServer | null;
|
selectedServer: DiscordServer | null;
|
||||||
|
nowPlaying?: Track | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSidebarProps) {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
@@ -141,6 +142,46 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
await applyReorder(newQueue);
|
await applyReorder(newQueue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대기열의 곡을 누르면 그 곡부터 재생한다(스포티파이·유튜브뮤직 대기열과 같은 동작).
|
||||||
|
* 봇 API 에 "특정 곡으로 점프"가 없어서, 대기열을 대상 곡부터로 바꾼 뒤 다음 곡으로 넘긴다.
|
||||||
|
* 결과적으로 건너뛴 곡들은 대기열에서 빠진다 — 이것도 위 서비스들과 동일하다.
|
||||||
|
*/
|
||||||
|
const playFrom = async (index: number) => {
|
||||||
|
if (!selectedServer || index <= 0) return;
|
||||||
|
const rest = queue.slice(index);
|
||||||
|
const previous = queue;
|
||||||
|
setQueue(rest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setRes = await fetch('/api/queue/set', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
userId: session?.user?.id,
|
||||||
|
newQueue: rest,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const setData = await setRes.json();
|
||||||
|
if (!setRes.ok || !setData.success) {
|
||||||
|
showToast(setData.message || setData.error || "곡을 건너뛰지 못했습니다.", "error");
|
||||||
|
setQueue(previous);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetch('/api/player/skip', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverId: selectedServer.id }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("곡 점프 실패:", error);
|
||||||
|
showToast("곡을 건너뛰지 못했습니다.", "error");
|
||||||
|
setQueue(previous);
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (indexToRemove: number) => {
|
const handleDelete = async (indexToRemove: number) => {
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
|
|
||||||
@@ -173,7 +214,32 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full flex-col bg-neutral-950 p-4 lg:border-l lg:border-neutral-800">
|
<aside className="flex h-full flex-col bg-neutral-950 p-4 lg:border-l lg:border-neutral-800">
|
||||||
<div className="mb-3 flex items-center justify-between gap-2 pr-12 lg:pr-0">
|
{/* "지금 재생 중"을 대기열 맨 위에 두는 건 스포티파이·유튜브뮤직 대기열의 공통 구조다. */}
|
||||||
|
{nowPlaying && (
|
||||||
|
<div className="mb-4 pr-12 lg:pr-0">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-green-500">
|
||||||
|
지금 재생 중
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-green-500/30 bg-green-500/10 p-2">
|
||||||
|
<span className="h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
|
||||||
|
{nowPlaying.info?.artworkUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={nowPlaying.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : null}
|
||||||
|
</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-xs text-neutral-400">
|
||||||
|
{nowPlaying.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`mb-3 flex items-center justify-between gap-2 ${nowPlaying ? "" : "pr-12 lg:pr-0"}`}>
|
||||||
<h2 className="text-base font-bold">다음 재생 목록</h2>
|
<h2 className="text-base font-bold">다음 재생 목록</h2>
|
||||||
{queue.length > 0 && (
|
{queue.length > 0 && (
|
||||||
<span className="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
|
<span className="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
|
||||||
@@ -219,23 +285,32 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="w-4 flex-shrink-0 text-center text-xs tabular-nums text-neutral-500">
|
{/* 곡을 누르면 그 곡부터 재생 — 다른 음악 서비스의 대기열과 같은 조작감 */}
|
||||||
{index + 1}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
|
onClick={() => playFrom(index)}
|
||||||
<span className="h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
|
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left"
|
||||||
{item.info?.artworkUrl ? (
|
aria-label={`${title} 부터 재생`}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
title="이 곡부터 재생"
|
||||||
<img src={item.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
>
|
||||||
) : null}
|
<span className="w-4 flex-shrink-0 text-center text-xs tabular-nums text-neutral-500">
|
||||||
</span>
|
{index + 1}
|
||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-medium text-white">{title}</span>
|
|
||||||
<span className="block truncate text-xs text-neutral-400">
|
|
||||||
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
|
<span className="relative h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
|
||||||
|
{item.info?.artworkUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={item.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : null}
|
||||||
|
</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-xs text-neutral-400">
|
||||||
|
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 조작 버튼: hover 로 숨기지 않는다(터치 기기에서는 hover 가 없어 아예 못 쓴다).
|
{/* 조작 버튼: hover 로 숨기지 않는다(터치 기기에서는 hover 가 없어 아예 못 쓴다).
|
||||||
순서 이동 버튼은 드래그가 안 되는 좁은 화면에서만 띄워 제목 폭을 확보한다. */}
|
순서 이동 버튼은 드래그가 안 되는 좁은 화면에서만 띄워 제목 폭을 확보한다. */}
|
||||||
|
|||||||
Reference in New Issue
Block a user