Compare commits

...

6 Commits

Author SHA1 Message Date
8087e2e98b feat: 스포티파이 곡을 Soloist(스포티파이 공식 클라이언트)로 먼저 재생, 안 되면 유튜브로
- soloist/: soloist-bridge 중계 서버(docker). 곡마다 soloist --single-track을 띄우고
  PulseAudio 빈 싱크를 캡처해 MP3 스트림으로 Lavalink(http 소스)에 넘긴다.
  첫 소리가 나와야 성공으로 응답, 15초 안에 소리가 없거나 오류면 실패.
  한 계정 한 곡 제한 → 다른 길드가 쓰는 중이면 busy. 빌드 만료(90일) 자동 갱신, 페어링 대기 자동 진입.
- bot: SOLOIST_URL 설정 시 스포티파이 곡은 재생 직전에 Soloist를 먼저 시도, 실패하면 기존 유튜브 경로.
  곡 도중 끊김·위치 이동·음성 재접속은 같은 곡의 유튜브 음원으로 그 위치부터 이어 재생.
  오래 걸려 실패하면 5분 동안 유튜브로만 재생.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:33:46 +09:00
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
c75e42e10e feat(bot): 스포티파이 재생목록을 곡 정보만 즉시 대기열에 넣고 재생 직전에 유튜브로 변환
- 재생목록/앨범: 임베드 페이지 곡 목록(~0.4초)으로 변환 전 항목(spotify-lazy:<id>)을 바로 추가
  (기존: 곡마다 유튜브 매칭 후 5곡씩 이어 붙여 100곡에 ~12초)
- GuildPlayer.resolvePlayable 에서 스포티파이 곡을 재생 직전 유튜브 원곡으로 매칭(다음 곡은 프리페치)
- 매칭 결과 캐시(matchSpotifyTrack)로 예열·재생이 검색을 한 번만 하도록
- 원곡을 못 찾은 곡은 건너뜀, 재생목록 커버는 변환 시 곡 이미지로 교체
- '-s' 섞기를 resolveQuery로 옮겨 섞은 뒤 첫 곡을 예열
- queue_set: JSON으로 깨진 _resolved({}) 제거

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:02:28 +09:00
b55dde52ca style(page): 줄 끝 공백 제거
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:17:02 +09:00
7021f2b776 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>
2026-10-03 14:08:29 +09:00
a491a1a98e feat(page): 대시보드 디자인을 유튜브 뮤직(웹) 다크 테마로 전면 교체
- 상단 바: ☰ + 로고 + 반투명 검색창 + 프로필 메뉴(좁은 화면은 돋보기 → 전체 폭 검색)
- 왼쪽 메뉴: 홈/서버 목록 + 봇이 있는 내 서버 목록, 넓은 화면 펼침(240)/접힘(72), 좁은 화면 서랍
- 홈: 서버를 원형 카드로, 서버 페이지: 앨범 페이지 형태(왼쪽 아이콘·정보, 오른쪽 재생 목록)
- 검색: 필터 칩(전체/노래/동영상/Spotify) + 상위 검색결과 카드 + 구역별 목록(모두 표시)
- 하단 재생바: 위쪽 빨간 진행선, 조작·시간 / 곡 정보 / hover 볼륨·재생 화면(⌃)
- 재생 화면: 큰 앨범아트 + 다음 트랙 탭, 좁은 화면은 전체 화면 플레이어
- 재생/대기열/서버 로직을 hooks(usePlayer/useQueue/useServers)로 분리해 구독을 한 번만 연다
- 스낵바 알림, 파비콘, README 화면·버튼 문서 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:04:49 +09:00
47 changed files with 4137 additions and 1659 deletions

View File

@@ -10,6 +10,7 @@
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) | | [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) |
| [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) | | [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) |
| [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 | | [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 |
| [`soloist/`](soloist) | `soloist-bridge` — 스포티파이 곡을 스포티파이 공식 헤드리스 클라이언트(Soloist)로 재생해 MP3 스트림으로 Lavalink에 넘기는 중계 서버(docker). 안 되면 봇이 유튜브로 재생. | [soloist/README.md](soloist/README.md) |
| [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 | | [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
## 전체 아키텍처 ## 전체 아키텍처

View File

@@ -34,6 +34,10 @@ YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) --- # --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
YTRESOLVER_URL=http://192.168.10.5:8779 YTRESOLVER_URL=http://192.168.10.5:8779
# --- 스포티파이 Soloist 중계 서버 (선택, 리포 루트 soloist/) ---
# 스포티파이 곡을 Soloist로 먼저 재생하고, 안 되면 유튜브로 재생한다. 비우면 유튜브로만 재생.
SOLOIST_URL=http://192.168.10.5:8780
# --- Redis (대시보드 상태 연동, 선택) --- # --- Redis (대시보드 상태 연동, 선택) ---
REDIS=true REDIS=true
REDIS_HOST=192.168.10.7 REDIS_HOST=192.168.10.7

View File

@@ -100,6 +100,7 @@ sqlite DB(`music1.db` 등)와 쿠키(`ytcookie.txt`)가 컨테이너에서 `/db/
| `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 | | `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 |
| `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 | | `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 |
| `YTRESOLVER_URL` | 리졸버 주소(기본 `http://192.168.10.5:8779`) | | `YTRESOLVER_URL` | 리졸버 주소(기본 `http://192.168.10.5:8779`) |
| `SOLOIST_URL` | (선택) 스포티파이 Soloist 중계 서버 주소(예 `http://192.168.10.5:8780`). 비우면 스포티파이 곡도 유튜브로만 재생. [`../soloist/README.md`](../soloist/README.md) |
| `REDIS/REDIS_HOST/REDIS_PORT` | 대시보드 제어/상태 연동(선택) | | `REDIS/REDIS_HOST/REDIS_PORT` | 대시보드 제어/상태 연동(선택) |
> `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다. > `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다.
@@ -142,6 +143,16 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
- 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가. - 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가.
- 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글. - 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글.
- 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트. - 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트.
- 스포티파이 재생목록/앨범(최대 **50,000곡**, `SpotifyCollectionMax`): 웹 플레이어 partner API(익명 토큰은 토크너 `SPOTIFY_TOKENER_URL`)로
첫 100곡만 받아 **곡 정보만** 곧바로 대기열에 넣고 재생을 시작한다. 나머지는 1000곡씩(동시 3페이지) 백그라운드로 이어 붙인다
(`LavalinkManager.appendSpotifyPages`, 1만 곡 기준 ~6초). 정지·모두 지우기·퇴장하면 `queueEpoch`가 바뀌어 이어 받기를 멈춘다.
`-s`면 새 페이지가 올 때마다 그 재생목록 곡끼리 다시 섞는다. partner API가 실패하면 공개 임베드 페이지(100곡), 그것도 실패하면 Lavalink(LavaSrc).
partner API의 persisted query 해시(`Spotify.ts`의 `PARTNER_QUERY`)는 스포티파이가 바꾸면 갱신해야 한다.
유튜브 원곡 매칭은 곡마다 **재생 직전**에 한다(`GuildPlayer.resolvePlayable` → `LavalinkManager.matchSpotifyTrack`).
다음 곡은 현재 곡 재생 중에 미리 변환하고, 원곡을 못 찾은 곡은 건너뛴다.
- 대시보드 `queue_list`는 대기열 앞 200곡과 전체 곡 수(`total`)·총 길이(`totalMs`)만 보낸다. `queue_set`은 그 앞부분(`windowSize`)만 바꾸고
뒤는 그대로 두며, `clearAll`이면 전부 비운다.
스포티파이 음원 자체를 직접 재생하지는 않는다(LavaSrc도 항상 다른 음원으로 미러링하며, 직접 재생은 Premium 계정 + librespot이 필요).
- 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백). - 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백).
### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임) ### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임)
@@ -159,7 +170,9 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`). 봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`).
사이트는 그 키를 짧게 폴링해 결과를 받는다. 사이트는 그 키를 짧게 폴링해 결과를 받는다.
- action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`, - action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`. `player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`,
`queue_shuffle`(다음 트랙 섞기), `player_recommend`(자동재생 토글 — 재생 중이 아니어도 바꿀 수 있음).
- `player_now` 응답에는 대시보드 표시용 `recommend`(자동재생 상태)와 `voiceChannelName`(봇이 있는 음성채널)도 들어 있다.
- **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish - **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish
(`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다. (`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다.
- 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`. - 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`.
@@ -190,6 +203,7 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
- **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`, - **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`,
`curl http://192.168.10.5:8779/health`, Lavalink `:2333`. `curl http://192.168.10.5:8779/health`, Lavalink `:2333`.
- **유튜브뮤직 검색 품질**: YTM API가 이 계정/엔드포인트에선 음악 카탈로그 '노래'를 거의 안 주고 일반 유튜브 영상(토크쇼·재생목록·MV 등)을 반환한다. 그래서 애매한 결과는 버리고 `ytsearch:… Topic`(공식 오디오)로 폴백하도록 해둠. 더 깨끗한 공식 오디오가 필요하면 검색에 `-p`(스포티파이 우선)를 쓴다. - **유튜브뮤직 검색 품질**: YTM API가 이 계정/엔드포인트에선 음악 카탈로그 '노래'를 거의 안 주고 일반 유튜브 영상(토크쇼·재생목록·MV 등)을 반환한다. 그래서 애매한 결과는 버리고 `ytsearch:… Topic`(공식 오디오)로 폴백하도록 해둠. 더 깨끗한 공식 오디오가 필요하면 검색에 `-p`(스포티파이 우선)를 쓴다.
- **스포티파이 곡이 유튜브로만 재생됨**: 봇 로그의 `[Soloist] 재생 준비 실패(...)` 사유 확인. `not_paired`=페어링 필요, `busy`=다른 길드가 사용 중(정상), `disabled`=API 키 없음. `curl http://192.168.10.5:8780/health`.
- **yt-dlp EJS(시그니처) 오류**: 리졸버는 연령제한 영상에 `--js-runtimes node`가 필요하다(node 설치 필수). - **yt-dlp EJS(시그니처) 오류**: 리졸버는 연령제한 영상에 `--js-runtimes node`가 필요하다(node 설치 필수).
--- ---

View File

@@ -9,6 +9,8 @@ import { shuffle } from "../utils/Shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel"; import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { Config } from "../utils/Config"; import { Config } from "../utils/Config";
import { Spotify } from "../utils/api/Spotify";
import { Soloist } from "../utils/api/Soloist";
const DelayAfterErrMs = 1000 * 5; const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10; const idleEndTime = 1000 * 60 * 10;
@@ -20,7 +22,8 @@ const LoadFailRetryDelayMs = 2500;
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시. // _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다. // 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; }; // _soloist: 지금 Soloist(스포티파이 공식 클라이언트) 스트림으로 재생 중인지. 이 스트림은 위치 이동이 안 된다.
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; _soloist?: boolean; };
export class GuildPlayer { export class GuildPlayer {
private isDead = false; private isDead = false;
@@ -28,6 +31,8 @@ export class GuildPlayer {
private _GDB: GuildType | undefined; private _GDB: GuildType | undefined;
private lastPlayedTrack: QueueTrack | undefined; private lastPlayedTrack: QueueTrack | undefined;
public queue: QueueTrack[] = []; public queue: QueueTrack[] = [];
/** 대기열을 통째로 비울 때(정지·끝·모두 지우기)마다 1 증가. 백그라운드로 재생목록을 이어 받는 작업이 중단 여부를 판단한다. */
public queueEpoch = 0;
private errorTimer: NodeJS.Timeout | undefined; private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined; private endTimer: NodeJS.Timeout | undefined;
private closedTimer: NodeJS.Timeout | undefined; private closedTimer: NodeJS.Timeout | undefined;
@@ -35,6 +40,8 @@ export class GuildPlayer {
private lastPosition = 0; private lastPosition = 0;
private lastProgressAt = 0; private lastProgressAt = 0;
private loadFailCount = 0; private loadFailCount = 0;
/** Soloist 스트림이 곡 도중에 끊긴 재생 위치(ms). end 이벤트에서 유튜브 음원으로 그 위치부터 이어 재생한다. */
private soloistCutAt: number | undefined;
constructor( constructor(
public guild: Guild, public guild: Guild,
@@ -66,6 +73,21 @@ export class GuildPlayer {
try { try {
if (this.isDead) return; if (this.isDead) return;
if (data.reason === "replaced") return; if (data.reason === "replaced") return;
// Soloist 스트림이 곡 도중에 끊기면(다른 기기에서 같은 계정으로 재생 등) Lavalink는 exception 뒤
// finished(또는 loadFailed)로 끝낸다 → 다음 곡으로 넘기지 않고 같은 곡의 유튜브 음원으로 그 위치부터 이어 재생
const cutAt = this.soloistCutAt;
this.soloistCutAt = undefined;
const cut = this.queue[0];
if (cutAt !== undefined && cut?._soloist && data.reason !== "stopped" && data.reason !== "cleanup") {
cut._soloist = false;
const encoded = await this.getResolved(cut);
if (this.isDead || this.queue[0] !== cut) return;
if (encoded) {
Logger.warn(`[GuildPlayer] Soloist 재생이 ${cutAt}ms에서 끊겨 유튜브로 이어서 재생합니다: "${cut.info.title}"`);
await this.player.playTrack({ track: { encoded }, position: cutAt });
return;
}
}
// 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면 // 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대 // 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다. // MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
@@ -77,9 +99,13 @@ export class GuildPlayer {
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs)); await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
if (this.isDead) return; if (this.isDead) return;
if (this.queue[0]?.encoded === cur.encoded) { if (this.queue[0]?.encoded === cur.encoded) {
// Soloist 스트림이 끊긴 경우도 여기로 온다 → 유튜브 음원으로 다시 재생
cur._soloist = false;
const reEncoded = await this.resolvePlayable(cur); const reEncoded = await this.resolvePlayable(cur);
await this.player.playTrack({ track: { encoded: reEncoded } }); if (reEncoded) {
return; await this.player.playTrack({ track: { encoded: reEncoded } });
return;
}
} }
} else { } else {
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`); Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
@@ -131,6 +157,7 @@ export class GuildPlayer {
this.player.on("exception", async (data) => { this.player.on("exception", async (data) => {
try { try {
if (this.queue[0]?._soloist) this.soloistCutAt = this.player.position;
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`); Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을 // Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다. // 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
@@ -193,9 +220,10 @@ export class GuildPlayer {
this.attachPlayerListeners(); this.attachPlayerListeners();
await this.player.setGlobalVolume(savedVolume || 50); await this.player.setGlobalVolume(savedVolume || 50);
// 곡이 있었으면 이어서 재생 // 곡이 있었으면 이어서 재생 (Soloist 스트림은 이어 받을 수 없으므로 유튜브 음원으로 그 위치부터)
if (currentTrack) { if (currentTrack) currentTrack._soloist = false;
const reEncoded = await this.resolvePlayable(currentTrack); const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
if (currentTrack && reEncoded) {
await this.player.playTrack({ await this.player.playTrack({
track: { encoded: reEncoded }, track: { encoded: reEncoded },
position: currentPosition, position: currentPosition,
@@ -243,9 +271,11 @@ export class GuildPlayer {
} }
} }
public async addTracks(tracks: Track[], userId: string) { /** 여러 곡을 대기열에 넣는다. 넣은 대기열 항목을 돌려준다. */
public async addTracks(tracks: Track[], userId: string): Promise<QueueTrack[]> {
const first = this.queue.length === 0; const first = this.queue.length === 0;
for (const track of tracks) this.queue.push({ ...track, userId }); const added = tracks.map((track) => ({ ...track, userId }));
for (const item of added) this.queue.push(item);
if (first) { if (first) {
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브) // 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
this.prefetch(this.queue[1]); this.prefetch(this.queue[1]);
@@ -255,29 +285,67 @@ export class GuildPlayer {
this.prefetch(this.queue[1]); this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true }); this.setMsg({ player: false, queue: true });
} }
return added;
} }
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생. /**
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */ * 재생목록의 나머지 곡을 대기열 뒤에 붙인다(백그라운드로 이어 받는 용도). 디스코드 메시지는 고치지 않고
* 대시보드에만 알린다(페이지마다 메시지를 고치면 디스코드 요청 제한에 걸린다).
* group이 있으면('-s'로 넣은 재생목록) 그 곡들이 있던 자리끼리 다시 섞는다. 지금 곡과 미리 변환 중인 다음 곡은 그대로 둔다.
*/
public appendTracks(tracks: Track[], userId: string, group?: Set<object>) {
const added = tracks.map((track) => ({ ...track, userId }));
for (const item of added) this.queue.push(item);
if (group) {
for (const item of added) group.add(item);
const slots: number[] = [];
for (let i = 2; i < this.queue.length; i++) if (group.has(this.queue[i])) slots.push(i);
const mixed = shuffle(slots.map((i) => this.queue[i]));
slots.forEach((slot, k) => { this.queue[slot] = mixed[k]; });
}
this.prefetch(this.queue[1]);
Redis?.publishState("queue_update", { guildId: this.guild.id });
}
/** 유튜브(및 스포티파이) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백.
* 스포티파이 곡은 여기서(재생 직전에) 유튜브 원곡을 찾아 그 오디오를 쓴다. 재생목록 항목은 대기열에
* 곡 정보만 들어 있어서, 원곡을 못 찾으면 재생할 수 없음을 뜻하는 빈 문자열을 돌려준다. */
private async resolvePlayable(track: QueueTrack): Promise<string> { private async resolvePlayable(track: QueueTrack): Promise<string> {
let source: Track = track;
let fallback = Spotify.isLazyTrack(track) ? "" : track.encoded;
try { try {
const info: any = (track as any).info ?? {}; if (track.info.sourceName === "spotify") {
const matched = await lavalinkManager.matchSpotifyTrack(track);
if (matched) {
source = matched;
fallback = matched.encoded;
// 재생목록 커버를 임시로 쓰던 곡은 찾은 곡의 이미지로 바꾼다(앨범은 앨범 커버가 맞으므로 유지)
if (!track.info.artworkUrl || (track.pluginInfo as any)?.coverIsPlaylist) {
track.info.artworkUrl = matched.info.artworkUrl ?? track.info.artworkUrl;
}
} else if (!fallback) {
Logger.warn(`[GuildPlayer] 유튜브에서 같은 곡을 찾지 못해 건너뜁니다: "${track.info.author} - ${track.info.title}"`);
return "";
}
}
const info: any = (source as any).info ?? {};
const src = info.sourceName; const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded; if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return fallback;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim(); const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return track.encoded; if (!target) return fallback;
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }); const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
if (!r.ok) return track.encoded; if (!r.ok) return fallback;
const data = (await r.json()) as { url?: string }; const data = (await r.json()) as { url?: string };
if (!data.url) return track.encoded; if (!data.url) return fallback;
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes); const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
if (!node) return track.encoded; if (!node) return fallback;
const res = await node.rest.resolve(data.url); const res = await node.rest.resolve(data.url);
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string; if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
return track.encoded; return fallback;
} catch (e) { } catch (e) {
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`); Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
return track.encoded; return fallback;
} }
} }
@@ -287,7 +355,7 @@ export class GuildPlayer {
track._resolved = this.resolvePlayable(track).catch((e) => { track._resolved = this.resolvePlayable(track).catch((e) => {
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`); Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도 track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
return track.encoded; return Spotify.isLazyTrack(track) ? "" : track.encoded;
}); });
} }
@@ -297,12 +365,46 @@ export class GuildPlayer {
return track._resolved!; return track._resolved!;
} }
public async playNext() { /**
* 스포티파이 곡을 Soloist(스포티파이 공식 헤드리스 클라이언트)로 재생할 encoded를 만든다.
* 설정이 없거나, 페어링 전이거나, 다른 길드가 쓰는 중이거나, 재생이 안 되면 "" → 호출한 쪽이 유튜브로 재생한다.
* 계정 하나로 한 번에 한 곡만 재생되므로 다음 곡 미리 준비(prefetch)는 하지 않고 재생 직전에만 부른다.
*/
private async resolveSoloist(track: QueueTrack): Promise<string> {
if (!Soloist.enabled || track.info.sourceName !== "spotify") return "";
const url = await Soloist.prepare(track.info.identifier, `${Config.appId}:${this.guild.id}`, track.info.length);
if (!url) return "";
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
if (!node) return "";
try {
const res = await node.rest.resolve(url);
if (res?.loadType === LoadType.TRACK) return res.data.encoded;
Logger.warn(`[GuildPlayer] Soloist 스트림을 Lavalink가 읽지 못함(${res?.loadType}), 유튜브로 재생합니다.`);
} catch (e) {
Logger.warn(`[GuildPlayer] Soloist 스트림 확인 실패, 유튜브로 재생합니다: ${String(e)}`);
}
return "";
}
public async playNext(): Promise<void> {
const track = this.queue[0]; const track = this.queue[0];
if (!track) return this.end(); if (!track) return this.end();
const tResolve = Date.now(); const tResolve = Date.now();
const encoded = await this.getResolved(track); // 스포티파이 곡은 Soloist를 먼저 시도하고, 안 되면 유튜브 음원(미리 준비해 둔 것)으로 재생한다.
this.soloistCutAt = undefined;
let encoded = await this.resolveSoloist(track);
if (this.isDead || this.queue[0] !== track) return; // 준비하는 동안 건너뛰기/정지됨
track._soloist = !!encoded;
if (encoded) this.prefetch(track); // 도중에 끊기거나 위치를 옮길 때 쓸 유튜브 음원도 준비
else encoded = await this.getResolved(track);
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`); Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
if (!encoded) {
// 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로
if (this.isDead || this.queue[0] !== track) return;
this.queue.shift();
if (this.queue.length === 0 && this.isRecommend && this.canRecommend && this.lastPlayedTrack) return this.autoPlay();
return this.playNext();
}
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거 // 다음 곡을 미리 리졸브해 곡 전환 지연 제거
this.prefetch(this.queue[1]); this.prefetch(this.queue[1]);
await this.player.playTrack({ track: { encoded } }); await this.player.playTrack({ track: { encoded } });
@@ -321,6 +423,7 @@ export class GuildPlayer {
} }
public stop() { public stop() {
this.queueEpoch++;
this.queue = []; this.queue = [];
this.canRecommend = false; this.canRecommend = false;
this.player.stopTrack(); this.player.stopTrack();
@@ -355,6 +458,18 @@ export class GuildPlayer {
public seek(num: number) { public seek(num: number) {
if (!this.isPlaying) return; if (!this.isPlaying) return;
if (!this.nowTrack) return; if (!this.nowTrack) return;
const track = this.nowTrack;
if (track._soloist) {
// Soloist 스트림은 위치 이동이 안 되므로 같은 곡의 유튜브 음원으로 바꿔 그 위치부터 재생한다.
const paused = this.isPaused;
this.getResolved(track).then(async (encoded) => {
if (!encoded || this.isDead || this.nowTrack !== track) return;
track._soloist = false;
await this.player.playTrack({ track: { encoded }, position: num, paused });
Redis?.publishState("player_update", { guildId: this.guild.id });
}).catch((e) => Logger.warn(`[GuildPlayer] 위치 이동(유튜브 전환) 실패: ${String(e)}`));
return;
}
this.player.seekTo(num); this.player.seekTo(num);
Redis?.publishState("player_update", { guildId: this.guild.id }); Redis?.publishState("player_update", { guildId: this.guild.id });
} }
@@ -532,6 +647,7 @@ export class GuildPlayer {
this.delete(true); this.delete(true);
}, idleEndTime); }, idleEndTime);
this.lastPlayedTrack = undefined; this.lastPlayedTrack = undefined;
this.queueEpoch++;
this.queue = []; this.queue = [];
this.canRecommend = true; this.canRecommend = true;
this.setMsg(); this.setMsg();

View File

@@ -7,16 +7,27 @@ import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { parseLink } from "../utils/music/Url"; import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle"; import { shuffle } from "../utils/Shuffle";
import { Spotify } from "../utils/api/Spotify"; import { Spotify, SpotifyCollectionMax, type SpotifyCollectionPage } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic"; import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch"; import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
/** 해석 결과. pendingSpotify: 스포티파이 재생목록에서 아직 유튜브로 매칭하지 않은 나머지 곡(큐 추가 후 백그라운드 매칭). */ /** 스포티파이 재생목록에서 아직 받지 않은 나머지(offset부터 total까지). 대기열 추가 후 백그라운드로 이어 받는다. */
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; pendingSpotify?: SongMeta[] }; type SpotifyRemaining = { kind: "playlist" | "album"; id: string; offset: number; total: number; cover?: string };
/** 해석 결과. */
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; spotifyRemaining?: SpotifyRemaining };
/** 스포티파이 재생목록 첫 페이지 곡 수(이만큼 받으면 바로 재생 시작) / 이후 백그라운드 페이지 크기·동시 요청 수. */
const SpotifyFirstPage = 100;
const SpotifyPageSize = 1000;
const SpotifyPageConcurrency = 3;
/** 스포티파이 곡 → 유튜브 원곡 매칭 결과 캐시 크기(곡 수). 예열과 실제 재생이 같은 결과를 쓰게 한다. */
const MatchCacheSize = 500;
export class LavalinkManager { export class LavalinkManager {
public shoukaku: Shoukaku; public shoukaku: Shoukaku;
private players: Map<string, GuildPlayer> = new Map(); private players: Map<string, GuildPlayer> = new Map();
private matchCache: Map<string, Promise<Track | null>> = new Map();
constructor(readonly client: Client) { constructor(readonly client: Client) {
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{ this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
@@ -117,18 +128,28 @@ export class LavalinkManager {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const { isUrl, text, flags } = parseLink(query.trim()); const { isUrl, text, flags } = parseLink(query.trim());
// 스포티파이 플레이리스트/앨범: Lavalink(LavaSrc)는 Spotify API 403으로 실패하므로 봇이 곡 목록을 직접 받아 // 스포티파이 플레이리스트/앨범: 곡 목록(제목·아티스트·길이)만 받아 곧바로 대기열에 넣는다.
// 곡마다 유튜브 원곡을 매칭한다. 첫 곡만 지금 매칭해 바로 재생하고, 나머지는 큐 추가 후 백그라운드에서 이어 붙인다. // 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 첫 100곡(~0.5초)만 받고 바로
// 재생을 시작하며, 나머지(최대 SpotifyCollectionMax곡)는 addResolved 뒤에 백그라운드로 이어 받는다.
// partner API가 실패하면 임베드 페이지(100곡), 그것도 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
const collection = isUrl ? Spotify.parseCollection(text) : null; const collection = isUrl ? Spotify.parseCollection(text) : null;
if (collection) { if (collection) {
const entries = await Spotify.getCollectionTracks(collection.kind, collection.id); let page: SpotifyCollectionPage | null = await Spotify.getCollectionPage(collection.kind, collection.id, 0, SpotifyFirstPage);
if (flags.has('s')) shuffle(entries); if (!page || page.tracks.length === 0) {
let first: Track | null = null; const embed = await Spotify.getCollectionTracks(collection.kind, collection.id);
let i = 0; page = { tracks: embed.tracks, total: 0, cover: embed.cover }; // total 0 → 이어 받지 않음
while (!first && i < entries.length && i < 5) first = await this.findYoutubeMatch(entries[i++]); }
if (!first) return null; if (page.tracks.length > 0) {
this.prewarmTrack(first); const isPlaylist = collection.kind === "playlist";
return { tracks: [first], playlist: true, flags, pendingSpotify: entries.slice(i) }; const tracks = page.tracks.map((e) => Spotify.toLazyTrack(e, page.cover, isPlaylist));
if (flags.has('s')) shuffle(tracks);
this.prewarmTrack(tracks[0]);
const total = Math.min(page.total, SpotifyCollectionMax);
const spotifyRemaining = total > SpotifyFirstPage
? { kind: collection.kind, id: collection.id, offset: SpotifyFirstPage, total, cover: page.cover }
: undefined;
return { tracks, playlist: true, flags, spotifyRemaining };
}
} }
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
@@ -177,6 +198,7 @@ export class LavalinkManager {
if (result.loadType === LoadType.PLAYLIST) { if (result.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks; tracks = result.data.tracks;
playlist = true; playlist = true;
if (flags.has('s')) shuffle(tracks);
} else if (result.loadType === LoadType.TRACK) { } else if (result.loadType === LoadType.TRACK) {
tracks = [result.data]; tracks = [result.data];
} else if (result.loadType === LoadType.SEARCH) { } else if (result.loadType === LoadType.SEARCH) {
@@ -197,10 +219,12 @@ export class LavalinkManager {
userId: string, userId: string,
) { ) {
if (resolved.playlist) { if (resolved.playlist) {
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId); // '-s'(섞기)는 resolveQuery에서 이미 적용했다(섞은 뒤 첫 곡을 예열해야 하므로).
if (resolved.pendingSpotify?.length) { const added = await player.addTracks(resolved.tracks, userId);
this.appendSpotifyEntries(player, resolved.pendingSpotify, userId) if (resolved.spotifyRemaining) {
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 추가 실패: ${String(e)}`)); const group = resolved.flags.has("s") ? new Set(added) : undefined;
this.appendSpotifyPages(player, resolved.spotifyRemaining, userId, group)
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 불러오기 실패: ${String(e)}`));
} }
return; return;
} }
@@ -226,22 +250,55 @@ export class LavalinkManager {
} }
} }
/** 스포티파이 재생목록의 나머지 곡을 순서대로 매칭해 큐에 이어 붙인다. 정지/퇴장하면 중단. */ /**
private async appendSpotifyEntries(player: GuildPlayer, entries: SongMeta[], userId: string) { * 스포티파이 재생목록의 나머지 곡을 페이지 단위로 받아 대기열 뒤에 순서대로 붙인다(곡 정보만, 변환은 재생 직전).
const chunkSize = 5; * 정지·모두 지우기·퇴장으로 대기열이 바뀌면(queueEpoch 변경) 중단한다.
* group: '-s'로 넣은 재생목록이면 그 곡들 — 새 페이지가 올 때마다 그 곡들끼리 다시 섞는다.
*/
private async appendSpotifyPages(player: GuildPlayer, rest: SpotifyRemaining, userId: string, group?: Set<object>) {
const epoch = player.queueEpoch;
const stopped = () => this.getPlayer(player.guild.id) !== player || player.queueEpoch !== epoch;
const isPlaylist = rest.kind === "playlist";
let added = 0; let added = 0;
for (let i = 0; i < entries.length; i += chunkSize) { let offset = rest.offset;
const found = await Promise.all(entries.slice(i, i + chunkSize).map((e) => this.findYoutubeMatch(e))); while (offset < rest.total) {
// 그 사이 정지(큐 비움)했거나 봇이 나갔으면 더 붙이지 않는다 const offsets: number[] = [];
if (this.getPlayer(player.guild.id) !== player || player.queue.length === 0) { for (let k = 0; k < SpotifyPageConcurrency && offset < rest.total; k++, offset += SpotifyPageSize) offsets.push(offset);
Logger.info(`[LavalinkManager] 스포티파이 재생목록 추가 중단 (정지/퇴장), ${added}곡 추가됨`); const pages = await Promise.all(offsets.map((o) =>
Spotify.getCollectionPage(rest.kind, rest.id, o, Math.min(SpotifyPageSize, rest.total - o))));
if (stopped()) {
Logger.info(`[LavalinkManager] 스포티파이 재생목록 불러오기 중단 (정지/지우기/퇴장), ${added}곡 추가됨`);
return; return;
} }
const tracks = found.filter((t): t is Track => !!t); pages.forEach((page, k) => {
if (tracks.length > 0) await player.addTracks(tracks, userId); if (!page) {
added += tracks.length; Logger.warn(`[LavalinkManager] 스포티파이 재생목록 ${offsets[k]}번째부터 한 페이지를 받지 못해 건너뜁니다.`);
return;
}
const tracks = page.tracks.map((e) => Spotify.toLazyTrack(e, rest.cover, isPlaylist));
if (tracks.length === 0) return;
player.appendTracks(tracks, userId, group);
added += tracks.length;
});
} }
Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}/${entries.length}곡 추가 완료`); Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}곡 추가 완료 (전체 ${rest.total}곡 중 ${rest.offset}곡은 먼저 추가)`);
player.setMsg({ player: false, queue: true });
}
/**
* 스포티파이 곡(재생목록 항목·자동재생 추천곡)을 재생할 유튜브 원곡 트랙으로 바꾼다. 못 찾으면 null.
* 같은 곡은 결과를 캐시해, 대기열에 넣을 때의 예열과 실제 재생이 검색을 한 번만 하도록 한다.
*/
public matchSpotifyTrack(track: Track): Promise<Track | null> {
const key = track.info.identifier;
const cached = this.matchCache.get(key);
if (cached) return cached;
const pending = this.findYoutubeMatch({ title: track.info.title, artist: track.info.author, durationMs: track.info.length });
this.matchCache.set(key, pending);
// 못 찾은 결과는 남기지 않는다(다음에 다시 시도)
pending.then((t) => { if (!t && this.matchCache.get(key) === pending) this.matchCache.delete(key); }, () => this.matchCache.delete(key));
if (this.matchCache.size > MatchCacheSize) this.matchCache.delete(this.matchCache.keys().next().value!);
return pending;
} }
/** /**
@@ -252,7 +309,12 @@ export class LavalinkManager {
try { try {
const info: any = (track as any).info ?? {}; const info: any = (track as any).info ?? {};
const src = info.sourceName; const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return; // 스포티파이 곡은 유튜브 원곡 매칭부터 예열하고, 찾으면 그 유튜브 주소를 리졸브해 둔다.
if (src === "spotify") {
this.matchSpotifyTrack(track).then((m) => { if (m) this.prewarmTrack(m); }).catch(() => {});
return;
}
if (src !== "youtube" && src !== "youtubemusic") return;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim(); const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return; if (!target) return;
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {}); fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});

View File

@@ -9,6 +9,14 @@ import { channelJoin } from "../commands/join";
import { GuildPlayer } from "./GuildPlayer"; import { GuildPlayer } from "./GuildPlayer";
import { Guild, VoiceChannel } from "discord.js"; import { Guild, VoiceChannel } from "discord.js";
import { SongItem } from "../types/Track"; import { SongItem } from "../types/Track";
import { DB } from "../utils/Database";
import { buttonRecommend } from "../utils/music/Button";
/**
* 대시보드에 보내는 대기열 앞부분 곡 수. 스포티파이 재생목록은 최대 5만 곡이라 전부 보내면 응답이 수십 MB가 되고
* 브라우저가 멈춘다. 곡 수·총 길이는 따로 보내고, 순서 변경(queue_set)은 이 앞부분만 바꾸고 뒤는 그대로 둔다.
*/
const QueueListLimit = 200;
type SubAction = type SubAction =
"search" | "search" |
@@ -21,7 +29,9 @@ type SubAction =
"player_volume" | "player_volume" |
"queue_list" | "queue_list" |
"queue_set" | "queue_set" |
"queue_remove"; "queue_remove" |
"queue_shuffle" |
"player_recommend";
export const RedisClient = () => { export const RedisClient = () => {
if (Config.redis.state) return new RedisClientClass(); if (Config.redis.state) return new RedisClientClass();
@@ -141,7 +151,10 @@ class RedisClientClass {
isPaused: player?.isPaused, isPaused: player?.isPaused,
position: player?.position, position: player?.position,
volume: player?.volume, volume: player?.volume,
track: player?.nowTrack ?? null track: player?.nowTrack ?? null,
// 웹 대시보드 표시용: 자동재생 상태, 봇이 들어가 있는 음성채널 이름
recommend: player ? player.isRecommend : (DB.guild.get(data.serverId)?.options.recommend ?? false),
voiceChannelName: player ? (nowGuild.channels.cache.get(player.voiceChannelId)?.name ?? null) : null,
})); }));
} }
if (data.action === "queue_list") { if (data.action === "queue_list") {
@@ -152,7 +165,15 @@ class RedisClientClass {
if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId); const player = lavalinkManager.getPlayer(data.serverId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] })); const upcoming = player?.queue ?? [];
let totalMs = 0;
for (let i = 1; i < upcoming.length; i++) totalMs += Number(upcoming[i].info?.length) || 0;
await this.reply(resultKey, 60, JSON.stringify({
success: true,
queue: upcoming.slice(1, 1 + QueueListLimit),
total: Math.max(upcoming.length - 1, 0),
totalMs,
}));
} }
if (data.action === "queue_set") { if (data.action === "queue_set") {
const resultKey = `queue:set:${data.requestId}`; const resultKey = `queue:set:${data.requestId}`;
@@ -162,11 +183,20 @@ class RedisClientClass {
const context = await this.getContext(data.serverId, resultKey, data.userId); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; if (!context.ok) return;
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0]; const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
context.player.queue.length = 0; // 대시보드는 대기열 앞부분(windowSize곡, 기본 QueueListLimit)만 갖고 있으므로 그 부분만 바꾸고 뒤는 남긴다.
if (nowTrack) context.player.queue.push(nowTrack); // clearAll(모두 지우기)이면 뒤까지 비우고, 백그라운드로 이어 받던 재생목록도 멈춘다(queueEpoch).
const windowSize = typeof data.windowSize === "number" && data.windowSize >= 0 ? data.windowSize : QueueListLimit;
const tail = data.clearAll === true ? [] : context.player.queue.slice(1 + windowSize);
if (data.clearAll === true) context.player.queueEpoch++;
const head: typeof context.player.queue = [];
if (nowTrack) head.push(nowTrack);
for (const rawTrack of data.newQueue) { for (const rawTrack of data.newQueue) {
if (rawTrack.encoded) context.player.queue.push(rawTrack); if (!rawTrack?.encoded) continue;
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
const { _resolved, ...track } = rawTrack;
head.push(track);
} }
context.player.queue = head.concat(tail);
await this.reply(resultKey, 60, JSON.stringify({ success: true })); await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg(); context.player.setMsg();
} }
@@ -194,6 +224,39 @@ class RedisClientClass {
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack })); await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg(); context.player.setMsg();
} }
if (data.action === "queue_shuffle") {
const resultKey = `queue:shuffle:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (context.player.queue.length < 3) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "섞을 곡이 2곡 이상 있어야 합니다." }));
context.player.setShuffle();
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_recommend") {
// 자동재생 토글. 재생 중이 아니어도 바꿀 수 있어야 하므로 음성채널 접속(getContext)을 거치지 않는다.
const resultKey = `player:recommend:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const recGuild = await getGuildById(data.serverId);
if (!recGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(recGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(recGuild.id);
if (player) {
// 재생 중이면 봇과 같은 음성채널에 있는 사람만 바꿀 수 있다(다른 조작과 같은 규칙).
const voiceChannel = await getVoiceChannelById(recGuild, data.userId);
if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
}
player.setRecommend();
} else {
await buttonRecommend(recGuild);
}
const recommend = DB.guild.get(recGuild.id)?.options.recommend ?? false;
await this.reply(resultKey, 60, JSON.stringify({ success: true, recommend }));
this.publishState("player_update", { guildId: recGuild.id });
}
if (data.action === "player_paused") { if (data.action === "player_paused") {
const resultKey = `player:paused:${data.requestId}`; const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));

View File

@@ -106,6 +106,10 @@ export const Config = {
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located. // yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""), ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
// 스포티파이 Soloist 중계 서버(soloist-bridge) 주소. 비우면 스포티파이 곡도 유튜브로만 재생한다.
// Lavalink(.6)도 이 주소로 스트림을 받으므로 LAN 주소여야 한다. 예: http://192.168.10.5:8780
soloistUrl: (process.env.SOLOIST_URL?.trim() || "").replace(/\/+$/, ""),
proxyUrl: process.env.PROXY_URL?.trim() || "", proxyUrl: process.env.PROXY_URL?.trim() || "",
_redis: { _redis: {

View File

@@ -0,0 +1,53 @@
import { Config } from "../Config";
import { Logger } from "../Logger";
/** soloist-bridge가 첫 소리를 확인할 때까지 기다리는 최대 시간(브리지 쪽 15초 + 여유). */
const PrepareTimeoutMs = 20_000;
/** 실패하는 데 이보다 오래 걸렸으면(소리가 안 나와 시간 초과 등) 잠시 Soloist를 쉬어, 곡마다 오래 기다리지 않게 한다. */
const SlowFailMs = 5_000;
const CooldownMs = 5 * 60_000;
let cooldownUntil = 0;
/**
* 스포티파이 공식 헤드리스 클라이언트(Soloist) 중계 서버(soloist-bridge, 리포 루트 soloist/) 클라이언트.
* 스포티파이 곡을 Soloist로 재생해 받은 MP3 스트림 주소를 돌려준다. 실패하면 null → 호출한 쪽이 유튜브로 재생한다.
*/
export const Soloist = {
get enabled(): boolean {
return !!Config.soloistUrl;
},
/**
* 곡 재생을 준비한다(브리지가 Soloist를 띄우고 실제 소리가 나올 때까지 기다린다).
* 성공하면 Lavalink에 넘길 스트림 주소, 실패(미설정·미페어링·다른 길드가 사용 중·재생 불가)하면 null.
* owner: 같은 owner의 새 요청은 이전 곡을 끊고 시작하고, 다른 owner가 쓰는 중이면 거절된다.
*/
async prepare(trackId: string, owner: string, durationMs: number): Promise<string | null> {
if (!this.enabled || Date.now() < cooldownUntil) return null;
const t0 = Date.now();
const url = `${Config.soloistUrl}/prepare?track=${encodeURIComponent(trackId)}`
+ `&owner=${encodeURIComponent(owner)}&duration=${Math.max(0, Math.floor(durationMs || 0))}`;
try {
const r = await fetch(url, { signal: AbortSignal.timeout(PrepareTimeoutMs) });
const data = await r.json().catch(() => ({})) as { path?: string; error?: string; readyMs?: number };
if (!r.ok || !data.path) {
Logger.warn(`[Soloist] 재생 준비 실패(${r.status} ${data.error ?? ""}), 유튜브로 재생합니다: ${trackId}`);
// busy(다른 길드가 사용 중)·preempted(이 길드가 곧바로 다음 곡을 요청함)는 정상 상황이라 쉬지 않는다
if (data.error !== "busy" && data.error !== "preempted") this.coolDownIfSlow(t0);
return null;
}
Logger.log(`[Soloist] 재생 준비 완료 ${data.readyMs ?? "?"}ms: ${trackId}`);
return `${Config.soloistUrl}${data.path}`;
} catch (e) {
Logger.warn(`[Soloist] 브리지 요청 실패, 유튜브로 재생합니다: ${String(e)}`);
this.coolDownIfSlow(t0);
return null;
}
},
coolDownIfSlow(t0: number) {
if (Date.now() - t0 < SlowFailMs) return;
cooldownUntil = Date.now() + CooldownMs;
Logger.warn(`[Soloist] 실패에 시간이 오래 걸려 ${CooldownMs / 60_000}분 동안 유튜브로만 재생합니다.`);
},
};

View File

@@ -2,8 +2,40 @@
import "dotenv/config"; import "dotenv/config";
import { Logger } from "../Logger"; import { Logger } from "../Logger";
import { SongItem } from "../../types/Track"; import { SongItem } from "../../types/Track";
import type { Track } from "shoukaku";
import type { SongMeta } from "../music/TrackMatch"; import type { SongMeta } from "../music/TrackMatch";
/** 스포티파이 재생목록/앨범의 곡 하나. artworkUrl: 곡의 앨범 커버(임베드 페이지에서 받으면 없음). */
export type SpotifyEntry = SongMeta & { id: string; artworkUrl?: string };
/** 재생목록/앨범 한 페이지. total: 재생목록 전체 항목 수(다음 페이지를 받을지 판단). */
export type SpotifyCollectionPage = { tracks: SpotifyEntry[]; total: number; cover?: string };
/** 재생목록/앨범에서 불러올 최대 곡 수. */
export const SpotifyCollectionMax = 50000;
const LAZY_PREFIX = "spotify-lazy:";
// 스포티파이 웹 플레이어가 쓰는 GraphQL(partner API). 익명 웹 토큰(토크너)으로 재생목록을 한 번에 최대 수천 곡씩
// 받을 수 있다(1000곡 ~1초). persisted query 해시는 Lavalink LavaSrc 4.8.3과 같은 값이며, 스포티파이가 바꾸면
// "PersistedQueryNotFound" 오류가 나므로 그때 갱신해야 한다(실패 시 임베드 페이지 100곡으로 폴백).
const PARTNER_API_URL = "https://api-partner.spotify.com/pathfinder/v1/query";
const PARTNER_QUERY = {
playlist: { operationName: "fetchPlaylist", sha256Hash: "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77" },
album: { operationName: "getAlbum", sha256Hash: "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10" },
} as const;
/** 크기별 이미지 목록에서 300px 정도(대기열 썸네일·재생 화면에 충분)를, 없으면 가장 큰 것을 고른다. */
const pickImage = (sources: any): string | undefined => {
if (!Array.isArray(sources) || sources.length === 0) return undefined;
const mid = sources.find((x: any) => x?.width === 300);
const best = mid ?? sources.reduce((a: any, b: any) => ((b?.width ?? 0) > (a?.width ?? 0) ? b : a));
return typeof best?.url === "string" ? best.url : undefined;
};
const artistNames = (artists: any): string =>
(Array.isArray(artists?.items) ? artists.items : []).map((a: any) => a?.profile?.name).filter(Boolean).join(", ");
const trackIdOf = (uri: unknown): string | undefined => String(uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1];
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? ""; const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? ""; const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
@@ -26,15 +58,20 @@ export const Spotify = {
cached: "", cached: "",
expiresAt: 0, expiresAt: 0,
}, },
// 토크너(웹 플레이어) 토큰은 정식 API 토큰(_token)과 쓰임새가 달라 캐시를 따로 둔다.
_webToken: {
cached: "",
expiresAt: 0,
},
async getTokenByTokener(): Promise<string | null> { async getTokenByTokener(): Promise<string | null> {
if (this._token.cached && Date.now() < this._token.expiresAt) return this._token.cached; if (this._webToken.cached && Date.now() < this._webToken.expiresAt - 60000) return this._webToken.cached;
try { try {
const response = await fetch(TOKENR_URL); const response = await fetch(TOKENR_URL, { signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`); if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`);
const jsonData = await response.json() as TOKENER_RESPONSE; const jsonData = await response.json() as TOKENER_RESPONSE;
this._token.cached = jsonData.accessToken; this._webToken.cached = jsonData.accessToken;
this._token.expiresAt = jsonData.accessTokenExpirationTimestampMs; this._webToken.expiresAt = jsonData.accessTokenExpirationTimestampMs;
return jsonData.accessToken; return jsonData.accessToken;
} catch (err) { } catch (err) {
Logger.error(`토크너 접근 실패: ${err}`); Logger.error(`토크너 접근 실패: ${err}`);
@@ -142,11 +179,90 @@ export const Spotify = {
}, },
/** /**
* 플레이리스트/앨범의 곡 목록(제목·아티스트·길이)을 가져온다. * 플레이리스트/앨범의 곡 목록 한 페이지(offset부터 limit개)를 partner API로 가져온다.
* 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이라 * 곡마다 id·제목·아티스트·길이·앨범 커버가 들어 있다. 로컬 파일·삭제된 곡은 빠진다. 실패하면 null.
* (Lavalink LavaSrc도 같은 이유로 실패), 공개 임베드 페이지의 데이터를 쓴다. 최대 100곡까지 제공된다.
*/ */
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<SongMeta[]> { async getCollectionPage(kind: "playlist" | "album", id: string, offset: number, limit: number): Promise<SpotifyCollectionPage | null> {
const query = PARTNER_QUERY[kind];
const variables = kind === "playlist"
? { uri: `spotify:playlist:${id}`, offset, limit, enableWatchFeedEntrypoint: false }
: { uri: `spotify:album:${id}`, locale: "", offset, limit };
for (let attempt = 0; attempt < 2; attempt++) {
const token = await this.getTokenByTokener();
if (!token) return null;
try {
const response = await fetch(PARTNER_API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "App-Platform": "WebPlayer" },
body: JSON.stringify({ operationName: query.operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash: query.sha256Hash } } }),
signal: AbortSignal.timeout(30000),
});
if (response.status === 401 && attempt === 0) {
this._webToken.cached = ""; // 토큰 만료 → 새로 받아 한 번 더
continue;
}
if (!response.ok) {
Logger.error(`스포티파이 ${kind} 곡 목록(partner API) 에러 (${response.status}): ${(await response.text()).slice(0, 200)}`);
return null;
}
const json: any = await response.json();
if (kind === "playlist") {
const pl = json?.data?.playlistV2;
if (pl?.__typename !== "Playlist") {
Logger.error(`스포티파이 재생목록을 찾지 못했습니다: ${id} (${pl?.__typename ?? JSON.stringify(json?.errors ?? "").slice(0, 200)})`);
return null;
}
const items: any[] = Array.isArray(pl.content?.items) ? pl.content.items : [];
const tracks: SpotifyEntry[] = [];
for (const it of items) {
const t = it?.itemV2?.data;
const trackId = t?.__typename === "Track" ? trackIdOf(t.uri) : undefined;
if (!trackId || !t.name) continue;
tracks.push({
id: trackId,
title: String(t.name),
artist: artistNames(t.artists),
durationMs: Number(t.trackDuration?.totalMilliseconds) || 0,
artworkUrl: pickImage(t.albumOfTrack?.coverArt?.sources),
});
}
return { tracks, total: Number(pl.content?.totalCount) || 0, cover: pickImage(pl.images?.items?.[0]?.sources) };
}
const album = json?.data?.albumUnion;
if (album?.__typename !== "Album") {
Logger.error(`스포티파이 앨범을 찾지 못했습니다: ${id} (${album?.__typename ?? JSON.stringify(json?.errors ?? "").slice(0, 200)})`);
return null;
}
const cover = pickImage(album.coverArt?.sources);
const items: any[] = Array.isArray(album.tracksV2?.items) ? album.tracksV2.items : [];
const tracks: SpotifyEntry[] = [];
for (const it of items) {
const t = it?.track;
const trackId = trackIdOf(t?.uri);
if (!trackId || !t.name) continue;
tracks.push({
id: trackId,
title: String(t.name),
artist: artistNames(t.artists),
durationMs: Number(t.duration?.totalMilliseconds) || 0,
artworkUrl: cover,
});
}
return { tracks, total: Number(album.tracksV2?.totalCount) || 0, cover };
} catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록(partner API) 조회 실패: ${err}`);
return null;
}
}
return null;
},
/**
* 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 공개 임베드 페이지에서 가져온다.
* partner API(getCollectionPage)가 실패할 때 쓰는 폴백이며, 최대 100곡까지만 제공된다. 실패하면 tracks가 빈 배열.
* (2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이다)
*/
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<{ tracks: SpotifyEntry[]; cover?: string }> {
try { try {
const response = await fetch(`https://open.spotify.com/embed/${kind}/${encodeURIComponent(id)}`, { const response = await fetch(`https://open.spotify.com/embed/${kind}/${encodeURIComponent(id)}`, {
headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" }, headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" },
@@ -154,28 +270,67 @@ export const Spotify = {
}); });
if (!response.ok) { if (!response.ok) {
Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`); Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`);
return []; return { tracks: [] };
} }
const html = await response.text(); const html = await response.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/); const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
const list = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity?.trackList : null; const entity = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity : null;
const list = entity?.trackList;
if (!Array.isArray(list)) { if (!Array.isArray(list)) {
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`); Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
return []; return { tracks: [] };
} }
return list const tracks = list
.filter((t: any) => t?.title && (t.entityType ?? "track") === "track") .map((t: any) => ({ t, id: String(t?.uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1] }))
.map((t: any) => ({ .filter(({ t, id }: any) => id && t?.title)
.map(({ t, id }: any) => ({
id,
title: String(t.title), title: String(t.title),
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(), artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
durationMs: Number(t.duration) || 0, durationMs: Number(t.duration) || 0,
})); }));
// 앨범은 visualIdentity.image(크기별 목록), 플레이리스트는 coverArt.sources 에 커버가 있다
const images: any[] = Array.isArray(entity?.visualIdentity?.image) ? entity.visualIdentity.image : [];
const largest = images.reduce((best, img) => (!best || (img?.maxWidth ?? 0) > (best?.maxWidth ?? 0) ? img : best), null);
const cover = largest?.url ?? entity?.coverArt?.sources?.[0]?.url;
return { tracks, cover: typeof cover === "string" ? cover : undefined };
} catch (err) { } catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`); Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`);
return []; return { tracks: [] };
} }
}, },
/**
* 재생목록 곡을 '아직 유튜브로 변환하지 않은' 대기열 항목으로 만든다.
* encoded는 Lavalink 트랙이 아니라 표식(`spotify-lazy:<id>`)이고, 재생 직전에
* GuildPlayer.resolvePlayable 이 유튜브 원곡을 찾아 실제 재생용 트랙으로 바꾼다.
* 곡 자체의 앨범 커버(entry.artworkUrl)가 없으면 cover(재생목록/앨범 커버)를 쓴다.
* coverIsPlaylist: cover가 곡 앨범아트가 아니라 재생목록 커버라서, 변환할 때 곡 이미지로 바꿔도 되는지.
*/
toLazyTrack(entry: SpotifyEntry, cover: string | undefined, coverIsPlaylist: boolean): Track {
return {
encoded: `${LAZY_PREFIX}${entry.id}`,
info: {
identifier: entry.id,
isSeekable: true,
author: entry.artist,
length: entry.durationMs,
isStream: false,
position: 0,
title: entry.title,
uri: `https://open.spotify.com/track/${entry.id}`,
artworkUrl: entry.artworkUrl ?? cover,
sourceName: "spotify",
},
pluginInfo: { lazy: true, coverIsPlaylist: !entry.artworkUrl && coverIsPlaylist },
};
},
/** toLazyTrack 으로 만든(아직 변환 전인) 항목인지. 이런 항목의 encoded는 Lavalink에 넘기면 안 된다. */
isLazyTrack(track: Track): boolean {
return typeof track.encoded === "string" && track.encoded.startsWith(LAZY_PREFIX);
},
/** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */ /** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
async getTrackById(id: string): Promise<SongMeta | null> { async getTrackById(id: string): Promise<SongMeta | null> {
try { try {

View File

@@ -22,7 +22,7 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
}); });
} }
const buttonRecommend = async (guild: Guild) => { export const buttonRecommend = async (guild: Guild) => {
const gdb = DB.guild.get(guild.id); const gdb = DB.guild.get(guild.id);
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false; const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
if (!change) return; if (!change) return;

View File

@@ -50,7 +50,7 @@ site: 그 키를 짧게 폴링(100ms→최대400ms 백오프, 기본 3초 타임
``` ```
- `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험. - `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험.
- action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`, - action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`. `player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`, `queue_shuffle`, `player_recommend`.
### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream` ### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream`
``` ```
@@ -66,8 +66,9 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다. 모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다.
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `events`(SSE) - **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `autoplay`(자동재생 토글), `events`(SSE)
- **queue**: `list`, `set`, `remove`, `events`(SSE) - **queue**: `list`, `set`, `remove`, `shuffle`(다음 트랙 섞기), `events`(SSE)
- `play`/`playlist`는 음성채널 접속 + 곡·재생목록 해석까지 기다려야 해서 응답 대기 시간을 15초로 둔다.
- **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링) - **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링)
- **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과) - **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과)
- **auth**: `[...nextauth]`(Discord OAuth) - **auth**: `[...nextauth]`(Discord OAuth)
@@ -78,65 +79,67 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
## 5. UI 구성 (`src/`) ## 5. UI 구성 (`src/`)
- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지. `components/Providers.tsx`(세션 등), `ToastProvider.tsx`. 디자인은 **유튜브 뮤직(웹) 다크 테마**를 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`,
- `components/layout/` — `TopNav`(상단/서버선택·로그인), `LeftSidebar`(탐색). 보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`).
- `components/player/` — `PlayerBar`(재생바/볼륨/탐색), `MainContent`(검색·결과), `QueueSidebar`(대기열).
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
### 5-1. 화면(모드)별 사용 흐름 ### 5-1. 페이지(주소)
화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지). | 주소 | 내용 | 접근 규칙 |
|------|------|-----------|
| `/` | 바로 이동만 한다 | 로그인 안 했으면 `/login`, 했으면 `/servers` |
| `/login` | 로그인 전용 페이지(Discord 로그인 버튼 + 사용 3단계 안내, 로그인 오류 표시) | 이미 로그인했으면 `callbackUrl`(없으면 `/servers`)로 이동 |
| `/servers` | 서버 선택 전용 페이지(봇이 있는 내 서버, 최근 사용 서버를 맨 앞에 "최근" 표시, 새로고침, 8개 넘으면 이름 검색) | 로그인 필수 |
| `/servers/[serverId]` | 유튜브 뮤직 형태의 봇 플레이어. 검색하면 `?q=검색어`가 붙어 뒤로가기로 홈↔검색을 오간다 | 로그인 필수. 내 서버 목록에 없는 서버면 "열 수 없어요" 안내 후 서버 선택으로 |
| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 | - 로그인 검사는 서버 컴포넌트에서 한다(`lib/auth.ts`의 `requirePageSession`). 돌아올 주소(`callbackUrl`)는 같은 사이트 경로만 허용한다.
|------|-----------|------------------|----------------------| - NextAuth 로그인/오류 화면도 `/login`으로 돌린다(`authOptions.pages`).
| 로그인 전 | 첫 방문 | 중앙 로그인 카드 | 디스코드 로그인. 사이드바/대기열/재생바는 렌더링하지 않는다 | - 쓰다가 세션이 끊기면 클라이언트에서 `/login`으로 보낸다.
| `SERVER_LIST` | 로그인 직후, 홈 버튼, "다른 서버 선택" | 봇이 있는 내 서버 카드 목록 | 서버 선택. 서버를 고르기 전에는 검색·재생이 잠겨 있다 |
| `SERVER_DETAIL` | 서버 카드 클릭 | 서버 이름/아이콘, 사용 안내 배너, 서버 ID·내 권한 | 검색 시작점. 좌측에 조작 중인 서버 표시 |
| `SEARCH_RESULT` | 검색창에 입력 후 Enter | Spotify / YouTube Music / YouTube 영상 3개 구역의 곡 리스트 행(번호·썸네일·제목/아티스트·재생시간) | 행을 눌러 대기열에 추가 |
| 대기열 패널 | (넓은 화면) 기본 펼침, 재생바 우측 대기열 버튼으로 접기/펴기 · (좁은 화면) 기본 접힘, 우하단 초록 버튼·재생바 목록 버튼으로 열기 | "지금 재생 중" + "다음 재생 목록" | 순서 변경, 삭제, 특정 곡부터 재생 |
### 5-2. 버튼·조작 동작 정리 ### 5-2. 파일 구성
| 위치 | 요소 | 동작 | 비활성 조건 | - `app/login`, `app/servers`, `app/servers/[serverId]` — 위 페이지(서버 컴포넌트, 로그인 검사 후 화면 컴포넌트 렌더).
|------|------|------|-------------| - `components/pages/` — `LoginView`, `ServerPicker`, `PlayerApp`(플레이어 전체 배치·검색 주소 관리·탭 제목).
| 상단 | 홈 | 서버를 고른 상태면 서버 상세로, 아니면 서버 목록으로. 검색어 초기화 | 로그아웃 상태에선 숨김 | - `components/layout/` — `TopNav`(☰·로고·검색·최근 검색어), `LeftSidebar`(홈/검색/서버 변경 + 조작 중인 서버·음성채널),
| 상단 | 검색창 | Enter 로 검색 실행 → `SEARCH_RESULT` | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) | `AccountMenu`(프로필 → 서버 변경·로그아웃만).
| 상단 | 프로필/로그아웃 | 세션 종료 | — | - `components/player/` — `MainContent`(서버 홈·검색 결과·주소 불러오기), `PlayerBar`(하단 재생바), `PlayerPage`(펼친 재생 화면),
| 본문 | 서버 카드 | 서버 선택 → 서버 상세 | — | `QueueList`(다음 트랙 + 섞기/모두 지우기/자동재생).
| 본문 | 곡 행(행 전체가 버튼) | `POST /api/player/play` → 대기열 추가. 성공/실패를 토스트로 알림. 넓은 화면은 hover 시 번호 자리에 재생 아이콘이, 좁은 화면은 썸네일에 재생 아이콘이 항상 보인다 | 서버 미선택·미로그인 | - `components/ui.tsx` — 로고, 썸네일, 서버 아이콘, 로딩 표시, 재생 중 이퀄라이저. `ToastProvider.tsx` — 하단 스낵바.
| 재생바 | 재생/일시정지 | `POST /api/player/pause` (낙관적 UI, 실패 시 롤백) | 봇 세션 없음/재생 중 아님 | - `hooks/` — `usePlayer`(재생 상태·조작·단축키·자동재생), `useQueue`(대기열·섞기·지우기), `useServers`(서버 목록·최근 서버).
| 재생바 | 다음 곡 | `POST /api/player/skip` | 〃 | 재생 상태·대기열은 `PlayerApp`에서 한 번만 구독해 화면 여러 곳에 내려준다.
| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 | - `lib/` — `auth.ts`(페이지 로그인 검사), `url.ts`(음악 주소 판별), `recent.ts`(최근 검색어), `api.ts`(봇 RPC).
| 재생바 | 진행바 | 드래그/클릭으로 탐색 → `POST /api/player/seek`. 좁은 화면에서는 재생바 상단에 전체 폭으로 배치 | 〃 |
| 재생바 | 스피커 아이콘 | 음소거/해제 토글(해제 시 직전 볼륨 복원) | 〃 |
| 재생바 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 |
| 재생바 | 목록 아이콘(컨트롤 옆, 좁은 화면) | 대기열 슬라이드 패널 열기/닫기 | — |
| 재생바 | 목록 아이콘(우측 볼륨 옆, 넓은 화면) | 우측 대기열 컬럼 접기/펴기. 펼쳐져 있으면 초록색으로 표시 | — |
| 대기열 | 곡(제목 영역) | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다 | 첫 곡은 이미 다음 곡이라 동작 없음 |
| 대기열 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (좁은 화면 전용, 드래그 대체) | 맨 위/맨 아래 |
| 대기열 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지) | — |
| 대기열 | 드래그 핸들 | 드래그로 순서 변경(넓은 화면 전용) | — |
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 |
### 5-3. 다른 음악 서비스와 비교해 맞춘 점 ### 5-3. 플레이어 화면 구성
스포티파이 웹 플레이어 / 유튜브 뮤직 / 애플 뮤직에서 공통으로 쓰이는 관행을 기준으로 삼았다. | 화면 | 진입 | 보이는 것 |
|------|------|-----------|
| 서버 홈 | 서버 선택 직후, 로고·"홈" | 왼쪽: 서버 아이콘·이름·권한·봇이 있는 음성채널, 노래 검색 버튼, (재생 전) 사용 안내, 최근 검색 칩 / 오른쪽: 재생 목록(자동재생 스위치, 지금 곡, 다음 트랙) |
| 검색 결과 | 검색창 Enter, 최근 검색어 | 필터 칩(전체/노래/동영상/Spotify, 개수 표시), 상위 검색결과, 구역별 목록(5곡 + "모두 표시"), 실패 시 "다시 시도" |
| 주소 불러오기 | 유튜브·유튜브 뮤직·스포티파이 주소를 검색창에 붙여넣고 Enter | 곡/재생목록·앨범 구분, "대기열에 추가" → `POST /api/player/playlist` |
| 재생 화면 | 재생바 곡 정보·⌃, 서버 홈의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + 다음 트랙 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 |
| 관행 | 예전 | 현재 | - 재생바는 재생 중인 곡이 있을 때만 보인다. 브라우저 탭 제목에 지금 곡(▶/⏸)이 표시된다.
- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침/접힘, 좁은 화면은 ☰ 로 서랍.
### 5-4. 버튼·조작 동작 정리
| 위치 | 요소 | 동작 |
|------|------|------| |------|------|------|
| 곡 검색 결과 표시 | 그리드 카드 | 리스트 행(번호·썸네일·제목/아티스트·재생시간). 그리드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태이고, 개별 곡은 두 서비스 모두 행 형식이다 | | 상단 | 검색창 | Enter 로 검색(주소면 불러오기 화면). 누르면 최근 검색어 8개(✕로 개별 삭제) |
| 대기열 접기/펴기 | 넓은 화면에서 항상 고정 | 재생바 우측 대기열 버튼으로 접고 펼 수 있음(스포티파이와 같은 위치) | | 상단 | 프로필 | 서버 변경 · 로그아웃 |
| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 | | 왼쪽 메뉴 | 홈 / 검색 / 서버 변경 | 서버 홈 / 검색창 포커스 / `/servers` |
| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 | | 검색 | 곡 행·상위 검색결과 "재생" | `POST /api/player/play`. 요청 중에는 스피너를 띄우고 다른 곡 추가를 막아 중복 추가를 방지 |
| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) | | 재생바 | ⏮ | 지금 곡을 처음부터 다시(유튜브 뮤직 동작. 봇에 이전 곡 기록이 없다) |
| 스피커 아이콘 클릭 = 음소거 | 장식 아이콘 | 토글 버튼 | | 재생바 | ⏯ / ⏭ / 진행바 | 일시정지·재개 / 다음 곡 / 탐색 |
| 키보드 단축키(Space/M/화살표) | 없음 | 지원 | | 재생바 | 스피커 | 클릭 = 음소거/해제, hover 시 볼륨 슬라이더 |
| 재생시간 표기 | 없음 | 검색 결과 행 오른쪽에 표시 | | 재생바 | 섞기 | 다음 트랙 섞기(2곡 이상일 때) |
| 터치 조작 크기(44px 내외) | 재생 32px·삭제 24px·진행바 1px | 44~48px, 진행바 히트영역 확대 | | 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) |
| hover 없이도 조작 가능 | 삭제·재생 버튼이 hover 시에만 노출 | 상시 노출 | | 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) |
| 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 |
| 다음 트랙 | (목록 길이) | 앞 200곡만 표시하고 곡 수·총 길이는 전체 기준. 나머지는 "외 N곡"으로 표시(스포티파이 재생목록은 최대 5만 곡) |
| 재생 화면 | ⌄ / Esc | 닫기 |
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 |
미지원(봇 API 한계): 이전 곡, 반복 재생, 특정 위치로의 셔플 재생. 필요하면 봇 쪽 액션 추가가 선행돼야 한다. 미지원(봇 기능 없음): 이전 곡으로 돌아가기, 반복 재생, 좋아요/가사. 동작하지 않는 버튼이 되므로 넣지 않았다.
--- ---

View File

@@ -32,6 +32,11 @@ export const authOptions: NextAuthOptions = {
session: { session: {
strategy: "jwt", strategy: "jwt",
}, },
// 로그인/로그인 오류는 NextAuth 기본 화면 대신 우리 로그인 페이지로 보낸다.
pages: {
signIn: "/login",
error: "/login",
},
callbacks: { callbacks: {
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직 // 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface AutoplayBody {
serverId?: unknown;
}
/** 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) 켜기/끄기 토글 */
export async function POST(request: Request) {
try {
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
const bodyResult = await readJsonBody<AutoplayBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
const { status, body } = await botRpc({
channel: "player:recommend",
payload: { action: "player_recommend", serverId: serverIdResult.value, userId },
timeoutMs: 5000,
});
return NextResponse.json(body, { status });
} catch (error) {
Logger.error(`player/autoplay API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -30,6 +30,8 @@ export async function POST(request: Request) {
userId, userId,
track, track,
}, },
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
timeoutMs: 15000,
}); });
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} catch (error) { } catch (error) {

View File

@@ -30,6 +30,8 @@ export async function POST(request: Request) {
userId, userId,
playlistUrl: urlResult.value, playlistUrl: urlResult.value,
}, },
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
timeoutMs: 15000,
}); });
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} catch (error) { } catch (error) {

View File

@@ -5,6 +5,10 @@ import { botRpc, errorResponse, readJsonBody, requireSession, requireString } fr
interface QueueSetBody { interface QueueSetBody {
serverId?: unknown; serverId?: unknown;
newQueue?: unknown; newQueue?: unknown;
/** 화면에 있던 대기열 앞부분 곡 수(봇은 이 부분만 newQueue로 바꾸고 뒤는 남긴다) */
windowSize?: unknown;
/** 모두 지우기(뒤에 남은 곡과 불러오는 중인 재생목록까지 비움) */
clearAll?: unknown;
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -29,6 +33,8 @@ export async function POST(request: Request) {
serverId: serverIdResult.value, serverId: serverIdResult.value,
userId, userId,
newQueue, newQueue,
windowSize: typeof bodyResult.data.windowSize === "number" ? bodyResult.data.windowSize : undefined,
clearAll: bodyResult.data.clearAll === true,
}, },
timeoutMs: 5000, timeoutMs: 5000,
}); });

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface ShuffleBody {
serverId?: unknown;
}
/** 대기열 섞기(현재 곡은 그대로, 다음 트랙만 섞는다) */
export async function POST(request: Request) {
try {
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
const bodyResult = await readJsonBody<ShuffleBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
const { status, body } = await botRpc({
channel: "queue:shuffle",
payload: { action: "queue_shuffle", serverId: serverIdResult.value, userId },
timeoutMs: 5000,
});
return NextResponse.json(body, { status });
} catch (error) {
Logger.error(`queue/shuffle API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,10 +1,25 @@
/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */
@import "tailwindcss"; @import "tailwindcss";
/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */ /*
* 유튜브 뮤직(웹) 다크 테마 색상.
* - 배경 #030303, 재생바/메뉴 #212121, 강조 빨강 #ff0000
* - 보조 글자는 흰색 70%/50% 투명도(유튜브 뮤직과 같은 방식)
*/
@theme { @theme {
--color-background: #171717; /* neutral-900 */ --color-background: #030303;
--color-foreground: #ffffff; --color-foreground: #ffffff;
--color-yt-bg: #030303;
--color-yt-raised: #212121;
--color-yt-menu: #282828;
--color-yt-red: #ff0000;
--color-yt-chip: rgb(255 255 255 / 0.1);
--color-yt-chip-hover: rgb(255 255 255 / 0.2);
--color-yt-line: rgb(255 255 255 / 0.1);
--font-sans: Roboto, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", Arial, sans-serif;
}
html {
color-scheme: dark;
} }
body { body {
@@ -12,19 +27,54 @@ body {
color: var(--color-foreground); color: var(--color-foreground);
margin: 0; margin: 0;
padding: 0; padding: 0;
-webkit-font-smoothing: antialiased;
} }
/* 스크롤바 예쁘게 숨기기 (선택 사항) */ /* 유튜브 뮤직처럼 얇고 어두운 스크롤바 */
* {
scrollbar-width: thin;
scrollbar-color: rgb(255 255 255 / 0.2) transparent;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #3f3f46; background: rgb(255 255 255 / 0.2);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #52525b; background: rgb(255 255 255 / 0.3);
} }
/* 가로 스크롤 칩 줄은 스크롤바를 숨긴다 */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* 재생 중 표시(이퀄라이저 막대) */
@keyframes yt-eq {
0%, 100% { transform: scaleY(0.3); }
50% { transform: scaleY(1); }
}
.yt-eq > span {
display: block;
width: 3px;
height: 14px;
background: currentColor;
transform-origin: bottom;
animation: yt-eq 0.9s ease-in-out infinite;
}
.yt-eq > span:nth-child(2) { animation-delay: -0.3s; }
.yt-eq > span:nth-child(3) { animation-delay: -0.6s; }
.yt-eq.paused > span { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.yt-eq > span { animation: none; transform: scaleY(0.6); }
}

1
page/src/app/icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#f00"/><circle cx="12" cy="12" r="6.6" fill="none" stroke="#fff" stroke-width="1.1"/><path d="M10 9.2v5.6l4.9-2.8z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import LoginView from "@/components/pages/LoginView";
import { getPageSession, safeCallbackUrl } from "@/lib/auth";
export const metadata = { title: "로그인 | MusicBot" };
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}) {
const { callbackUrl, error } = await searchParams;
const target = safeCallbackUrl(callbackUrl);
const session = await getPageSession();
if (session?.user?.id) redirect(target);
return <LoginView callbackUrl={target} error={error} />;
}

View File

@@ -1,152 +1,8 @@
"use client"; import { redirect } from "next/navigation";
import { getPageSession } from "@/lib/auth";
import { useCallback, useState } from "react"; /** 첫 화면: 로그인 안 했으면 로그인 페이지, 했으면 서버 선택 페이지로 보낸다. */
import { useSession } from "next-auth/react"; export default async function Home() {
import { ListMusic, X } from "lucide-react"; const session = await getPageSession();
import TopNav from "@/components/layout/TopNav"; redirect(session?.user?.id ? "/servers" : "/login");
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, Track } from "@/types/music";
// 화면 모드 타입 정의
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
export default function MusicPlayerLayout() {
const { status } = useSession();
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// 대기열 표시 상태는 화면 크기별로 기본값이 달라서 두 개로 나눠 둔다.
// (창 크기를 읽어 한 상태로 합치면 SSR·하이드레이션이 어긋난다)
// · 좁은 화면: 기본 접힘 → 버튼으로 슬라이드 패널을 연다
// · 넓은 화면: 기본 펼침 → 재생바의 대기열 버튼으로 접을 수 있다
const [isQueueOpen, setIsQueueOpen] = useState(false);
const [isQueueDocked, setIsQueueDocked] = useState(true);
// 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함).
const [nowPlaying, setNowPlaying] = useState<Track | null>(null);
const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []);
const isAuthed = status === "authenticated";
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
const showPlayback = isAuthed && !!selectedServer;
const handleHome = () => {
if (selectedServer) {
setViewMode("SERVER_DETAIL");
} else {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}
};
const handleSearch = (query: string) => {
setSearchQuery(query);
setViewMode("SEARCH_RESULT");
};
const handleSelectServer = (server: DiscordServer) => {
setSelectedServer(server);
setViewMode("SERVER_DETAIL");
};
const handleBackToServers = () => {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
setIsQueueOpen(false);
};
return (
<div className="flex h-dvh flex-col overflow-hidden bg-neutral-950 font-sans text-white">
<TopNav
onSearch={handleSearch}
onHome={handleHome}
selectedServer={selectedServer}
/>
<div className="relative flex flex-1 overflow-hidden">
{/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */}
{showPlayback && (
<LeftSidebar selectedServer={selectedServer} onBackToServers={handleBackToServers} />
)}
<MainContent
viewMode={viewMode}
setViewMode={setViewMode}
selectedServer={selectedServer}
setSelectedServer={setSelectedServer}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onSelectServer={handleSelectServer}
/>
{showPlayback && (
<>
{/* 모바일에서 대기열이 열렸을 때 뒤쪽을 덮는 배경(탭하면 닫힘) */}
{isQueueOpen && (
<button
type="button"
aria-label="대기열 닫기"
onClick={() => setIsQueueOpen(false)}
className="absolute inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
/>
)}
{/* 대기열: 데스크톱은 고정 컬럼, 모바일은 슬라이드 패널 (인스턴스는 하나만 유지) */}
<div
className={`absolute inset-y-0 right-0 z-40 w-[22rem] max-w-[88%] transform border-l border-neutral-800 bg-neutral-950 shadow-2xl transition-transform duration-300 ease-out
${isQueueOpen ? "translate-x-0" : "translate-x-full"}
${isQueueDocked
? "lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none"
: "lg:hidden"}`}
>
<QueueSidebar
selectedServer={selectedServer}
nowPlaying={nowPlaying}
onClose={() => setIsQueueOpen(false)}
/>
</div>
</>
)}
</div>
{showPlayback && (
<PlayerBar
selectedServer={selectedServer}
onToggleQueue={() => setIsQueueOpen((v) => !v)}
isQueueOpen={isQueueOpen}
onToggleDock={() => setIsQueueDocked((v) => !v)}
isQueueDocked={isQueueDocked}
onNowPlayingChange={handleNowPlayingChange}
/>
)}
{/* 모바일 전용 대기열 열기 버튼: 플레이어바에도 있지만, 재생 전에도 접근할 수 있게 둔다. */}
{showPlayback && !isQueueOpen && (
<button
type="button"
onClick={() => setIsQueueOpen(true)}
className="fixed bottom-24 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-black shadow-xl transition hover:bg-green-400 active:scale-95 lg:hidden"
aria-label="대기열 열기"
>
<ListMusic size={24} />
</button>
)}
{/* 모바일 패널 닫기 버튼(패널 위에 떠 있음) */}
{showPlayback && isQueueOpen && (
<button
type="button"
onClick={() => setIsQueueOpen(false)}
className="fixed right-4 top-20 z-50 flex h-11 w-11 items-center justify-center rounded-full bg-neutral-800 text-white shadow-lg transition hover:bg-neutral-700 lg:hidden"
aria-label="대기열 닫기"
>
<X size={20} />
</button>
)}
</div>
);
} }

View File

@@ -0,0 +1,16 @@
import PlayerApp from "@/components/pages/PlayerApp";
import { requirePageSession } from "@/lib/auth";
export default async function ServerPlayerPage({
params,
searchParams,
}: {
params: Promise<{ serverId: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { serverId } = await params;
const { q } = await searchParams;
await requirePageSession(`/servers/${serverId}`);
// key: 서버를 바꾸면 재생 상태·검색 결과를 깨끗하게 새로 시작한다.
return <PlayerApp key={serverId} serverId={serverId} initialQuery={typeof q === "string" ? q : ""} />;
}

View File

@@ -0,0 +1,9 @@
import ServerPicker from "@/components/pages/ServerPicker";
import { requirePageSession } from "@/lib/auth";
export const metadata = { title: "서버 선택 | MusicBot" };
export default async function ServersPage() {
await requirePageSession("/servers");
return <ServerPicker />;
}

View File

@@ -29,13 +29,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={{ showToast }}> <ToastContext.Provider value={{ showToast }}>
{children} {children}
{/* 화면 최상단에 떠있는 실제 토스트 UI */} {/* 유튜브 뮤직 스낵바: 왼쪽 아래(재생바 위)에 뜨는 어두운 알림 */}
{toast && ( {toast && (
<div <div
className={`fixed bottom-10 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-full shadow-2xl flex items-center gap-3 z-[9999] animate-in fade-in slide-in-from-bottom-5 duration-300 font-medium text-sm text-white ${ role={toast.type === "error" ? "alert" : "status"}
toast.type === 'success' ? 'bg-green-500' : 'bg-red-500' className="fixed bottom-24 left-4 right-4 z-[9999] flex items-center gap-3 rounded bg-yt-menu px-4 py-3 text-sm text-white shadow-2xl ring-1 ring-white/10 sm:left-1/2 sm:right-auto sm:w-max sm:max-w-md sm:-translate-x-1/2"
}`}
> >
{toast.type === "error" && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-yt-red" aria-hidden />}
{toast.message} {toast.message}
</div> </div>
)} )}

View File

@@ -0,0 +1,85 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { LogOut, Server } from "lucide-react";
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
export function Avatar({ user, className = "h-8 w-8 text-sm" }: { user: { name?: string | null; image?: string | null }; className?: string }) {
if (user.image) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.image} alt="" className={`${className} rounded-full object-cover`} />
);
}
return (
<span className={`${className} flex items-center justify-center rounded-full bg-[#7b1fa2] font-medium text-white`} aria-hidden>
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
</span>
);
}
/**
* 프로필 버튼 → 작은 메뉴(서버 변경 · 로그아웃).
* 계정 전환·설정 같은 유튜브 계정 메뉴 항목은 이 봇에 필요 없어 넣지 않는다.
*/
export default function AccountMenu({ showServerSwitch = true }: { showServerSwitch?: boolean }) {
const { data: session, status } = useSession();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 바깥을 누르거나 Esc 로 닫는다.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (status === "loading" || !session?.user) {
return <span className="h-8 w-8 animate-pulse rounded-full bg-yt-chip" aria-label="로그인 상태 확인 중" />;
}
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={`${session.user.name ?? "내"} 계정 메뉴`}
aria-expanded={open}
className="flex h-10 w-10 items-center justify-center rounded-full"
>
<Avatar user={session.user} />
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-56 overflow-hidden rounded-lg bg-yt-menu py-2 shadow-2xl ring-1 ring-white/10">
<p className="truncate px-4 pb-2 pt-1 text-sm text-white/70">{session.user.name}</p>
{showServerSwitch && (
<Link
href="/servers"
className="flex h-10 items-center gap-4 px-4 text-sm text-white hover:bg-white/10"
>
<Server size={20} className="text-white/70" aria-hidden />
서버 변경
</Link>
)}
<button
type="button"
onClick={() => signOut({ callbackUrl: "/login" })}
className="flex h-10 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
>
<LogOut size={20} className="text-white/70" aria-hidden />
로그아웃
</button>
</div>
)}
</div>
);
}

View File

@@ -1,61 +1,148 @@
"use client"; "use client";
import { ChevronLeft, Info, Server } from "lucide-react"; import Link from "next/link";
import { ArrowLeftRight, Home, Menu, Search, Volume2 } from "lucide-react";
import type { DiscordServer } from "@/types/music"; import type { DiscordServer } from "@/types/music";
import type { ViewMode } from "@/components/pages/PlayerApp";
import { Logo, ServerAvatar } from "@/components/ui";
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
interface LeftSidebarProps { interface LeftSidebarProps {
selectedServer: DiscordServer | null; /** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */
onBackToServers: () => void; expanded: boolean;
/** 좁은 화면에서 서랍처럼 열린 상태 */
drawerOpen: boolean;
onCloseDrawer: () => void;
viewMode: ViewMode;
server: DiscordServer | null;
/** 봇이 들어가 있는 음성채널 이름(재생 중일 때) */
voiceChannelName: string | null;
onHome: () => void;
} }
/** /**
* 좌측 보조 패널. 예전에는 안내 문구만 있는 죽은 공간이었어서, * 유튜브 뮤직 왼쪽 메뉴(가이드): 홈 / 검색 / 서버 변경 + 아래에 지금 조작 중인 서버.
* "지금 어느 서버를 조작 중인지"와 서버 전환 동선을 넣어 실제로 쓰이게 했다. * 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다.
* 좁은 화면에서는 숨긴다(같은 정보가 본문/플레이어에 이미 있다).
*/ */
export default function LeftSidebar({ selectedServer, onBackToServers }: LeftSidebarProps) { export default function LeftSidebar({
return ( expanded,
<aside className="hidden w-60 flex-shrink-0 flex-col gap-4 border-r border-neutral-800 bg-neutral-950 p-4 lg:flex"> drawerOpen,
<p className="px-1 text-xs font-semibold uppercase tracking-wider text-neutral-500"> onCloseDrawer,
조작 중인 서버 viewMode,
</p> server,
voiceChannelName,
onHome,
}: LeftSidebarProps) {
const focusSearch = () => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT));
<div className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3"> type Item = { key: string; label: string; icon: typeof Home; active?: boolean } & (
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-800"> | { href: string; onClick?: never }
{selectedServer?.icon ? ( | { onClick: () => void; href?: never }
// eslint-disable-next-line @next/next/no-img-element );
<img const nav: Item[] = [
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} { key: "home", label: "홈", icon: Home, active: viewMode === "HOME", onClick: onHome },
alt="" { key: "search", label: "검색", icon: Search, active: viewMode === "SEARCH", onClick: focusSearch },
className="h-full w-full object-cover" { key: "switch", label: "서버 변경", icon: ArrowLeftRight, href: "/servers" },
/> ];
) : (
<Server size={20} className="text-neutral-500" aria-hidden />
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">
{selectedServer?.name ?? "선택 안 됨"}
</span>
<span className="block text-xs text-neutral-500">음악 봇 연결됨</span>
</span>
</div>
const itemClass = (active?: boolean) =>
`flex h-12 w-full items-center gap-5 rounded-lg px-4 text-left text-base text-white transition-colors ${active ? "bg-white/10 font-medium" : "hover:bg-white/10"}`;
const miniClass = (active?: boolean) =>
`flex h-[74px] w-full flex-col items-center justify-center gap-1.5 rounded-lg text-[10px] text-white transition-colors ${active ? "bg-white/10" : "hover:bg-white/10"}`;
const renderItem = (item: Item, cls: string, afterClick?: () => void) => {
const Icon = item.icon;
const content = (
<>
<Icon size={24} strokeWidth={item.active ? 2.2 : 1.6} aria-hidden />
{item.label}
</>
);
return item.href ? (
<Link href={item.href} className={cls}>
{content}
</Link>
) : (
<button <button
onClick={onBackToServers} type="button"
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm text-neutral-300 transition-colors hover:bg-neutral-900 hover:text-white" onClick={() => {
afterClick?.();
item.onClick?.();
}}
aria-current={item.active ? "page" : undefined}
className={cls}
> >
<ChevronLeft size={18} aria-hidden /> {content}
다른 서버 선택
</button> </button>
);
};
<div className="mt-auto rounded-xl border border-neutral-800 bg-neutral-900/60 p-3"> /** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-neutral-300"> const full = (afterClick?: () => void) => (
<Info size={14} aria-hidden /> 사용 방법 <div className="flex h-full flex-col">
</p> <ul className="flex flex-col gap-1 px-2 pt-2">
<p className="text-xs leading-relaxed text-neutral-500"> {nav.map((item) => (
디스코드 음성 채널에 먼저 들어간 뒤, 위 검색창에서 노래를 찾아 재생 버튼을 누르세요. <li key={item.key}>{renderItem(item, itemClass(item.active), afterClick)}</li>
</p> ))}
</div> </ul>
</aside>
{server && (
<div className="mx-3 mb-4 mt-auto rounded-xl bg-white/[0.06] p-3">
<div className="flex items-center gap-3">
<ServerAvatar server={server} className="h-10 w-10" iconSize={16} size={64} />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-white">{server.name}</span>
<span className="block truncate text-xs text-white/60">조작 중인 서버</span>
</span>
</div>
<p className="mt-3 flex items-center gap-1.5 text-xs text-white/60">
<Volume2 size={14} className="flex-shrink-0" aria-hidden />
<span className="truncate">
{voiceChannelName ? `${voiceChannelName}에서 재생 중` : "봇이 음성 채널에 없어요"}
</span>
</p>
</div>
)}
</div>
);
return (
<>
{/* 넓은 화면: 고정 메뉴(펼침 240px / 접힘 72px) */}
<aside
className={`hidden flex-shrink-0 border-r border-yt-line bg-yt-bg lg:block ${expanded ? "w-60" : "w-[72px]"}`}
aria-label="메뉴"
>
{expanded ? (
full()
) : (
<ul className="flex flex-col gap-1 px-1 pt-1">
{nav.map((item) => (
<li key={item.key}>{renderItem(item, miniClass(item.active))}</li>
))}
</ul>
)}
</aside>
{/* 좁은 화면: 서랍 메뉴 */}
{drawerOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<button type="button" aria-label="메뉴 닫기" onClick={onCloseDrawer} className="absolute inset-0 bg-black/60" />
<aside className="absolute inset-y-0 left-0 flex w-64 flex-col bg-yt-raised shadow-2xl" aria-label="메뉴">
<div className="flex h-16 flex-shrink-0 items-center gap-1 px-2">
<button
type="button"
onClick={onCloseDrawer}
aria-label="메뉴 닫기"
className="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-white/10"
>
<Menu size={22} aria-hidden />
</button>
<Logo className="px-1" />
</div>
<div className="min-h-0 flex-1">{full(onCloseDrawer)}</div>
</aside>
</div>
)}
</>
); );
} }

View File

@@ -1,137 +1,222 @@
"use client"; "use client";
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react"; import { ArrowLeft, Clock, Link2, Menu, Search, X } from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react"; import { useEffect, useRef, useState } from "react";
import { useState } from "react"; import { Logo } from "@/components/ui";
import type { DiscordServer } from "@/types/music"; import AccountMenu from "@/components/layout/AccountMenu";
import { isMusicUrl } from "@/lib/url";
import { RECENT_EVENT, RECENT_MAX, readRecent, writeRecent } from "@/lib/recent";
/** 다른 화면(서버 홈의 "노래 검색" 버튼 등)에서 검색창을 열 때 쓰는 이벤트 이름 */
export const FOCUS_SEARCH_EVENT = "musicbot:focus-search";
interface TopNavProps { interface TopNavProps {
/** 지금 화면에 걸린 검색어(뒤로가기 등으로 바뀌면 입력칸도 맞춘다) */
query: string;
onSearch: (query: string) => void; onSearch: (query: string) => void;
onHome: () => void; onHome: () => void;
selectedServer: DiscordServer | null; onToggleGuide: () => void;
} }
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */ /**
function Avatar({ * 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
user, * - 검색창을 누르면 최근 검색어가 펼쳐진다(유튜브 뮤직과 같은 방식).
size, * - 좁은 화면은 돋보기 버튼 → 바 전체가 검색창으로 바뀐다.
}: { */
user: { name?: string | null; image?: string | null }; export default function TopNav({ query, onSearch, onHome, onToggleGuide }: TopNavProps) {
size: "sm" | "md"; const [term, setTerm] = useState(query);
}) { const [mobileSearch, setMobileSearch] = useState(false);
const box = size === "sm" ? "h-7 w-7 text-xs" : "h-9 w-9 text-sm"; const [focused, setFocused] = useState(false);
if (user.image) { const [recent, setRecent] = useState<string[]>([]);
return ( const inputRef = useRef<HTMLInputElement>(null);
// eslint-disable-next-line @next/next/no-img-element const boxRef = useRef<HTMLDivElement>(null);
<img src={user.image} alt="" className={`${box} rounded-full object-cover`} />
);
}
return (
<span
className={`${box} flex items-center justify-center rounded-full bg-neutral-700 font-semibold text-white`}
aria-hidden
>
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
</span>
);
}
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) { // 화면의 검색어가 바뀌면(뒤로가기·홈) 입력칸도 맞춘다.
const { data: session, status } = useSession(); useEffect(() => {
const [term, setTerm] = useState("");
const isAuthed = status === "authenticated"; setTerm(query);
const canSearch = isAuthed && !!selectedServer; }, [query]);
const submitSearch = () => { useEffect(() => {
if (canSearch && term.trim()) onSearch(term.trim()); const sync = () => setRecent(readRecent());
sync();
window.addEventListener(RECENT_EVENT, sync);
return () => window.removeEventListener(RECENT_EVENT, sync);
}, []);
// 서버 홈의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
useEffect(() => {
const focus = () => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
};
window.addEventListener(FOCUS_SEARCH_EVENT, focus);
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
}, []);
// 검색창 바깥을 누르면 최근 검색어 목록을 닫는다.
useEffect(() => {
if (!focused) return;
const onDown = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setFocused(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [focused]);
const run = (value: string) => {
const q = value.trim();
if (!q) return;
setTerm(q);
// 주소는 최근 검색어에 남기지 않는다(길고 다시 쓸 일이 적다).
if (!isMusicUrl(q)) {
const next = [q, ...recent.filter((r) => r !== q)].slice(0, RECENT_MAX);
setRecent(next);
writeRecent(next);
}
onSearch(q);
setFocused(false);
inputRef.current?.blur();
setMobileSearch(false);
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const removeRecent = (r: string) => {
if (e.key === "Enter") submitSearch(); const next = recent.filter((x) => x !== r);
setRecent(next);
writeRecent(next);
}; };
const handleHomeClick = () => { const showDropdown = focused && (recent.length > 0 || isMusicUrl(term));
setTerm("");
onHome();
};
// 상황별로 검색창 안내를 다르게 준다(로그인 전인데 "서버를 고르라"고 하면 혼란스럽다).
const searchPlaceholder = !isAuthed
? "로그인하면 검색할 수 있어요"
: !selectedServer
? "먼저 서버를 선택해주세요"
: "노래 제목이나 유튜브·스포티파이 주소";
return ( return (
<header className="z-20 flex-shrink-0 border-b border-neutral-800 bg-neutral-950"> <header className="z-40 flex-shrink-0 border-b border-yt-line bg-yt-bg">
<nav className="flex h-16 items-center gap-2 px-3 sm:gap-4 sm:px-6"> <nav className="relative flex h-16 items-center gap-2 px-2 sm:px-4">
{/* 로고: 좁은 화면에서는 아이콘만 남겨 검색창 폭을 확보한다. */} {/* 좁은 화면 검색 모드: 뒤로 + 검색창이 바 전체를 차지한다 */}
<div className="flex flex-shrink-0 items-center gap-2 text-lg font-bold"> {mobileSearch && (
<ListMusic className="text-green-500" aria-hidden /> <div className="absolute inset-0 z-10 flex items-center gap-2 bg-yt-bg px-2 md:hidden">
<span className="hidden sm:inline">MusicBot</span> <button
</div> type="button"
onClick={() => {
{isAuthed && ( setMobileSearch(false);
<button setFocused(false);
onClick={handleHomeClick} }}
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white" aria-label="검색 닫기"
aria-label="처음 화면으로" className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white hover:bg-yt-chip"
> >
<Home size={20} aria-hidden /> <ArrowLeft size={22} aria-hidden />
</button> </button>
</div>
)} )}
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */} {/* 왼쪽: 메뉴 + 로고 (넓은 화면에서는 왼쪽 메뉴 폭에 맞춘다) */}
<div className="relative min-w-0 flex-1"> <div className="flex flex-shrink-0 items-center gap-1 lg:w-[232px]">
<Search <button
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" type="button"
size={18} onClick={onToggleGuide}
aria-hidden aria-label="메뉴"
/> className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-yt-chip"
<input >
type="search" <Menu size={22} aria-hidden />
value={term} </button>
onChange={(e) => setTerm(e.target.value)} <button type="button" onClick={onHome} aria-label="홈으로" className="rounded px-1 py-1">
onKeyDown={handleKeyDown} <Logo />
disabled={!canSearch} </button>
placeholder={searchPlaceholder}
aria-label="노래 검색"
className="h-11 w-full rounded-full border border-neutral-700 bg-neutral-900 pl-10 pr-4 text-sm text-white placeholder:text-neutral-500 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div> </div>
{/* 계정 영역 */} {/* 검색창 */}
<div className="flex flex-shrink-0 items-center"> <div
{status === "loading" ? ( ref={boxRef}
<div className="h-11 w-11 animate-pulse rounded-full bg-neutral-800" aria-label="로그인 상태 확인 중" /> className={`min-w-0 flex-1 md:block ${mobileSearch ? "absolute inset-y-0 left-14 right-2 z-20 flex items-center md:static" : "hidden"}`}
) : isAuthed && session?.user ? ( >
<div className="flex items-center gap-1 sm:gap-2"> <form
<span className="hidden items-center gap-2 rounded-full border border-neutral-700 bg-neutral-900 py-1 pl-1 pr-3 md:flex"> role="search"
<Avatar user={session.user} size="sm" /> onSubmit={(e) => {
<span className="max-w-[10rem] truncate text-sm font-medium">{session.user.name}</span> e.preventDefault();
</span> run(term);
<span className="md:hidden"> }}
<Avatar user={session.user} size="md" /> className="relative w-full max-w-[480px]"
</span> >
<Search className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/70" size={20} aria-hidden />
<input
ref={inputRef}
type="search"
value={term}
onChange={(e) => setTerm(e.target.value)}
onFocus={() => setFocused(true)}
onKeyDown={(e) => e.key === "Escape" && setFocused(false)}
placeholder="노래·아티스트 검색 또는 유튜브·스포티파이 주소 붙여넣기"
aria-label="노래 검색"
autoComplete="off"
className={`h-10 w-full border border-white/20 bg-white/15 pl-12 pr-10 text-base text-white placeholder:text-white/50 focus:border-white/30 focus:bg-yt-raised focus:outline-none [&::-webkit-search-cancel-button]:hidden
${showDropdown ? "rounded-t-lg" : "rounded-lg"}`}
/>
{term && (
<button <button
onClick={() => signOut()} type="button"
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-red-400" onClick={() => {
aria-label="로그아웃" setTerm("");
title="로그아웃" inputRef.current?.focus();
}}
aria-label="검색어 지우기"
className="absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white/70 hover:text-white"
> >
<LogOut size={18} aria-hidden /> <X size={18} aria-hidden />
</button> </button>
</div> )}
) : (
<button {/* 최근 검색어 / 주소 안내 */}
onClick={() => signIn("discord")} {showDropdown && (
className="flex h-11 items-center gap-2 rounded-full bg-[#5865F2] px-4 text-sm font-medium text-white shadow-md transition-colors hover:bg-[#4752C4] sm:px-5" <div className="absolute inset-x-0 top-10 z-50 overflow-hidden rounded-b-lg border border-t-0 border-white/20 bg-yt-raised py-2 shadow-2xl">
> {isMusicUrl(term) ? (
<LogIn size={16} aria-hidden /> <button
<span className="hidden sm:inline">Discord 로그인</span> type="submit"
<span className="sm:hidden">로그인</span> className="flex h-11 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
</button> >
)} <Link2 size={18} className="flex-shrink-0 text-white/70" aria-hidden />
<span className="truncate">이 주소의 곡·재생목록 불러오기</span>
</button>
) : (
recent.map((r) => (
<div key={r} className="group flex h-11 items-center hover:bg-white/10">
<button
type="button"
onClick={() => run(r)}
className="flex h-full min-w-0 flex-1 items-center gap-4 pl-4 text-left text-sm text-white"
>
<Clock size={18} className="flex-shrink-0 text-white/70" aria-hidden />
<span className="truncate">{r}</span>
</button>
<button
type="button"
onClick={() => removeRecent(r)}
aria-label={`최근 검색어 ${r} 삭제`}
className="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-white/50 hover:text-white"
>
<X size={16} aria-hidden />
</button>
</div>
))
)}
</div>
)}
</form>
</div>
<div className="flex-1 md:hidden" />
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
aria-label="검색"
className="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-yt-chip md:hidden"
>
<Search size={22} aria-hidden />
</button>
<AccountMenu />
</div> </div>
</nav> </nav>
</header> </header>

View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { Headphones, ListMusic, Server } from "lucide-react";
import { Logo, Spinner } from "@/components/ui";
/** 디스코드 로고(공식 색상 버튼 안에 넣는 흰색 마크) */
function DiscordMark() {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden fill="currentColor">
<path d="M20.32 4.37A19.8 19.8 0 0 0 15.4 2.85a13.8 13.8 0 0 0-.63 1.29 18.4 18.4 0 0 0-5.53 0 13.6 13.6 0 0 0-.64-1.29 19.7 19.7 0 0 0-4.92 1.52C.53 9.05-.32 13.6.1 18.1a19.9 19.9 0 0 0 6.03 3.05c.49-.66.92-1.36 1.29-2.1a12.9 12.9 0 0 1-2.03-.98c.17-.12.34-.25.5-.38a14.2 14.2 0 0 0 12.22 0l.5.38c-.65.38-1.33.71-2.04.98.37.74.8 1.44 1.29 2.1a19.8 19.8 0 0 0 6.03-3.05c.5-5.2-.84-9.71-3.57-13.73ZM8.02 15.33c-1.18 0-2.16-1.09-2.16-2.42s.95-2.42 2.16-2.42c1.2 0 2.18 1.09 2.16 2.42 0 1.33-.96 2.42-2.16 2.42Zm7.97 0c-1.19 0-2.16-1.09-2.16-2.42s.95-2.42 2.16-2.42c1.2 0 2.18 1.09 2.16 2.42 0 1.33-.95 2.42-2.16 2.42Z" />
</svg>
);
}
const ERROR_TEXT: Record<string, string> = {
OAuthCallback: "디스코드 로그인 중 문제가 생겼습니다. 다시 시도해 주세요.",
AccessDenied: "디스코드에서 로그인 권한을 허용하지 않았습니다.",
};
const STEPS = [
{ icon: ListMusic, title: "디스코드로 로그인", desc: "계정 비밀번호는 이 사이트에 저장되지 않아요." },
{ icon: Server, title: "서버 선택", desc: "음악 봇이 들어가 있는 내 서버만 보여요." },
{ icon: Headphones, title: "음성 채널에서 감상", desc: "음성 채널에 들어간 뒤 노래를 검색해 누르면 봇이 재생해요." },
];
/** 로그인 전용 페이지. 로그인해야만 서버 선택·플레이어로 들어갈 수 있다. */
export default function LoginView({ callbackUrl, error }: { callbackUrl: string; error?: string }) {
const [pending, setPending] = useState(false);
const errorText = error ? (ERROR_TEXT[error] ?? "로그인하지 못했습니다. 다시 시도해 주세요.") : null;
return (
<main className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-yt-bg px-5 py-10 text-white">
{/* 은은한 빨간 빛 배경 */}
<div
className="pointer-events-none absolute -top-40 left-1/2 h-[520px] w-[820px] -translate-x-1/2 rounded-full bg-yt-red/20 blur-[120px]"
aria-hidden
/>
<div className="relative w-full max-w-md">
<div className="flex justify-center">
<Logo className="scale-[1.35]" />
</div>
<div className="mt-10 rounded-2xl bg-yt-raised/80 p-6 shadow-2xl ring-1 ring-white/10 backdrop-blur sm:p-8">
<h1 className="text-center text-2xl font-bold">로그인</h1>
<p className="mt-2 text-center text-sm leading-relaxed text-white/70">
디스코드 계정으로 로그인하면 브라우저에서 음악 봇을 조작할 수 있어요.
</p>
{errorText && (
<p role="alert" className="mt-5 rounded-lg bg-yt-red/15 px-4 py-3 text-sm text-red-200 ring-1 ring-yt-red/40">
{errorText}
</p>
)}
<button
type="button"
disabled={pending}
onClick={() => {
setPending(true);
signIn("discord", { callbackUrl });
}}
className="mt-6 flex h-12 w-full items-center justify-center gap-3 rounded-full bg-[#5865F2] text-base font-medium text-white transition-colors hover:bg-[#4752C4] disabled:opacity-70"
>
{pending ? <Spinner size={20} label="디스코드로 이동 중" /> : <DiscordMark />}
{pending ? "디스코드로 이동 중..." : "Discord로 로그인"}
</button>
<ol className="mt-8 flex flex-col gap-4 border-t border-yt-line pt-6">
{STEPS.map(({ icon: Icon, title, desc }, i) => (
<li key={title} className="flex gap-4">
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-white">
<Icon size={18} aria-hidden />
</span>
<span>
<span className="block text-sm font-medium">
{i + 1}. {title}
</span>
<span className="block text-sm text-white/60">{desc}</span>
</span>
</li>
))}
</ol>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,164 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import TopNav from "@/components/layout/TopNav";
import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent";
import PlayerBar from "@/components/player/PlayerBar";
import PlayerPage from "@/components/player/PlayerPage";
import { Spinner } from "@/components/ui";
import { cleanArtist, usePlayer } from "@/hooks/usePlayer";
import { useQueue } from "@/hooks/useQueue";
import { setLastServerId, useServers } from "@/hooks/useServers";
import type { DiscordServer } from "@/types/music";
/** 플레이어 안의 화면: 서버 홈 / 검색 결과 */
export type ViewMode = "HOME" | "SEARCH";
/** 주소창의 ?q= 를 읽는다(뒤로가기로 검색 화면을 오갈 수 있게 검색어를 주소에 남긴다) */
function readQuery() {
return new URLSearchParams(window.location.search).get("q") ?? "";
}
/**
* 유튜브 뮤직 구조의 봇 플레이어(서버를 고른 뒤 들어오는 화면):
* 상단 바(☰ 로고 · 검색 · 프로필)
* [왼쪽 메뉴] [본문: 서버 홈 / 검색 결과] ← 재생 화면(⌃)은 본문 위를 덮는다
* 하단 재생바
*/
export default function PlayerApp({ serverId, initialQuery }: { serverId: string; initialQuery: string }) {
const router = useRouter();
const { status } = useSession();
const [searchQuery, setSearchQuery] = useState(initialQuery);
const viewMode: ViewMode = searchQuery ? "SEARCH" : "HOME";
// 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍
const [guideExpanded, setGuideExpanded] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { servers, loaded, isFetching, error } = useServers();
const found = servers.find((s) => s.id === serverId) ?? null;
// 목록을 받기 전에도 재생 상태 구독은 바로 시작한다(봇이 서버 멤버 여부를 다시 확인한다).
const server: DiscordServer = useMemo(
() => found ?? { id: serverId, name: "", icon: null, owner: false, permissions: "0" },
[found, serverId],
);
// 재생 상태·대기열은 재생바, 재생 화면, 서버 홈이 같이 쓰므로 여기서 한 번만 구독한다.
const player = usePlayer(server);
const queue = useQueue(server);
// 세션이 만료되면 로그인 페이지로
useEffect(() => {
if (status === "unauthenticated") router.replace(`/login?callbackUrl=${encodeURIComponent(`/servers/${serverId}`)}`);
}, [status, router, serverId]);
useEffect(() => {
if (found) setLastServerId(found.id);
}, [found]);
// 뒤로/앞으로 가기로 검색 화면을 오간다.
useEffect(() => {
const onPop = () => setSearchQuery(readQuery());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
// 탭 제목에 지금 곡을 보여준다(다른 탭에 있어도 무슨 곡인지 알 수 있게).
useEffect(() => {
const t = player.track?.info;
const base = found?.name ? `${found.name} | MusicBot` : "MusicBot";
document.title = t?.title ? `${player.isPaused ? "⏸" : "▶"} ${t.title} · ${cleanArtist(t.author)} | MusicBot` : base;
}, [player.track, player.isPaused, found]);
const goHome = () => {
setIsPlayerOpen(false);
if (searchQuery) {
setSearchQuery("");
window.history.pushState(null, "", `/servers/${serverId}`);
}
};
const handleSearch = (query: string) => {
setIsPlayerOpen(false);
setSearchQuery(query);
window.history.pushState(null, "", `/servers/${serverId}?q=${encodeURIComponent(query)}`);
};
const handleToggleGuide = () => {
// 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기
if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v);
else setDrawerOpen(true);
};
const closePlayer = useCallback(() => setIsPlayerOpen(false), []);
// 목록을 받았는데 이 서버가 없으면(봇이 없거나 내가 멤버가 아님) 서버 선택으로 안내
if (loaded && !isFetching && !found) {
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-yt-bg px-6 text-center text-white">
<p className="text-xl font-bold">이 서버를 열 수 없어요</p>
<p className="max-w-sm text-sm leading-relaxed text-white/60">
{error ?? "음악 봇이 없는 서버이거나, 내가 들어가 있지 않은 서버예요."}
</p>
<Link href="/servers" className="mt-2 h-10 rounded-full bg-white px-5 text-sm font-medium leading-10 text-black hover:bg-white/85">
서버 선택으로
</Link>
</main>
);
}
if (status !== "authenticated") {
return (
<main className="flex min-h-dvh items-center justify-center bg-yt-bg">
<Spinner />
</main>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-yt-bg font-sans text-white">
<TopNav query={searchQuery} onSearch={handleSearch} onHome={goHome} onToggleGuide={handleToggleGuide} />
<div className="relative flex min-h-0 flex-1">
<LeftSidebar
expanded={guideExpanded}
drawerOpen={drawerOpen}
onCloseDrawer={() => setDrawerOpen(false)}
viewMode={viewMode}
server={found}
voiceChannelName={player.voiceChannelName}
onHome={goHome}
/>
<div className="relative flex min-w-0 flex-1">
<MainContent
viewMode={viewMode}
server={server}
searchQuery={searchQuery}
onSearch={handleSearch}
onOpenPlayer={() => setIsPlayerOpen(true)}
showQueue={!isPlayerOpen}
player={player}
queue={queue}
/>
{isPlayerOpen && <PlayerPage player={player} queue={queue} onClose={closePlayer} />}
</div>
</div>
{/* 유튜브 뮤직처럼 재생 중인 곡이 있을 때만 재생바를 띄운다 */}
{player.track && (
<PlayerBar
player={player}
onShuffle={queue.shuffle}
canShuffle={queue.total >= 2}
isPlayerOpen={isPlayerOpen}
onTogglePlayer={() => setIsPlayerOpen((v) => !v)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { RefreshCw, Search } from "lucide-react";
import AccountMenu from "@/components/layout/AccountMenu";
import { Logo, ServerAvatar, Spinner } from "@/components/ui";
import { getLastServerId, permissionLabel, setLastServerId, useServers } from "@/hooks/useServers";
/** 서버가 이만큼 넘으면 이름 검색 칸을 보여준다 */
const FILTER_THRESHOLD = 8;
/** 서버 선택 전용 페이지. 여기서 서버를 골라야 플레이어로 들어간다. */
export default function ServerPicker() {
const router = useRouter();
const { data: session, status } = useSession();
const { servers, isFetching, error, loaded, refresh } = useServers();
const [filter, setFilter] = useState("");
const [lastId, setLastId] = useState<string | null>(null);
// 세션이 만료됐으면 로그인 페이지로
useEffect(() => {
if (status === "unauthenticated") router.replace("/login?callbackUrl=/servers");
}, [status, router]);
// localStorage 는 브라우저에서만 읽을 수 있다(서버 렌더와 어긋나지 않게 마운트 뒤에 읽음).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastId(getLastServerId());
}, []);
// 최근 사용한 서버를 맨 앞으로
const ordered = useMemo(() => {
const list = [...servers];
const i = list.findIndex((s) => s.id === lastId);
if (i > 0) list.unshift(list.splice(i, 1)[0]);
const q = filter.trim().toLowerCase();
return q ? list.filter((s) => s.name.toLowerCase().includes(q)) : list;
}, [servers, lastId, filter]);
return (
<div className="min-h-dvh bg-yt-bg text-white">
<header className="sticky top-0 z-40 border-b border-yt-line bg-yt-bg/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between px-4 sm:px-8">
<Logo />
<AccountMenu showServerSwitch={false} />
</div>
</header>
<main className="mx-auto max-w-[1200px] px-4 pb-16 pt-8 sm:px-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm text-white/70">{session?.user?.name ? `${session.user.name}님, 안녕하세요` : " "}</p>
<h1 className="mt-1 text-[28px] font-bold leading-tight sm:text-[32px]">어느 서버에서 들을까요?</h1>
<p className="mt-2 text-sm text-white/60">음악 봇이 들어가 있는 내 서버만 보여요.</p>
</div>
<button
type="button"
onClick={refresh}
disabled={isFetching}
className="flex h-9 items-center gap-2 rounded-full border border-white/20 px-4 text-sm font-medium text-white transition-colors hover:bg-white/10 disabled:opacity-50"
>
<RefreshCw size={16} className={isFetching ? "animate-spin" : ""} aria-hidden />
새로고침
</button>
</div>
{servers.length > FILTER_THRESHOLD && (
<div className="relative mt-6 max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/60" size={18} aria-hidden />
<input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="서버 이름으로 찾기"
aria-label="서버 이름으로 찾기"
className="h-10 w-full rounded-lg border border-white/20 bg-white/10 pl-10 pr-3 text-sm text-white placeholder:text-white/50 focus:border-white/30 focus:bg-yt-raised focus:outline-none"
/>
</div>
)}
{error && (
<p role="alert" className="mt-6 rounded-lg bg-yt-red/15 px-4 py-3 text-sm text-red-200 ring-1 ring-yt-red/40">
{error} 잠시 뒤 새로고침을 눌러 주세요.
</p>
)}
<div className="mt-8">
{!loaded ? (
<div className="flex justify-center py-24">
<Spinner label="서버 목록을 불러오는 중" />
</div>
) : servers.length === 0 ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-20 text-center">
<p className="text-lg font-medium">표시할 서버가 없어요</p>
<p className="text-sm leading-relaxed text-white/60">
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있어요.
봇을 서버에 초대한 뒤 새로고침을 눌러 주세요.
</p>
</div>
) : ordered.length === 0 ? (
<p className="py-16 text-center text-sm text-white/60">&ldquo;{filter}&rdquo;과(와) 일치하는 서버가 없어요.</p>
) : (
<ul className="grid grid-cols-3 gap-x-3 gap-y-6 sm:gap-x-4 sm:gap-y-8 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{ordered.map((server) => (
<li key={server.id}>
<Link
href={`/servers/${server.id}`}
onClick={() => setLastServerId(server.id)}
className="group block text-center focus:outline-none"
>
<span className="relative mb-3 block">
<ServerAvatar server={server} className="aspect-square w-full" iconSize={40} size={256} />
<span className="absolute inset-0 rounded-full transition-colors group-hover:bg-black/30 group-focus-visible:ring-2 group-focus-visible:ring-white" />
{server.id === lastId && (
<span className="absolute left-1/2 top-2 -translate-x-1/2 rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-black shadow">
최근
</span>
)}
</span>
<span className="block truncate text-base font-medium">{server.name}</span>
<span className="block truncate text-sm text-white/60">{permissionLabel(server)}</span>
</Link>
</li>
))}
</ul>
)}
</div>
</main>
</div>
);
}

View File

@@ -1,414 +1,416 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { signIn, useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc, ListMusic, LogIn } from "lucide-react"; import { Clock, Headphones, Link2, ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react";
import { ViewMode } from "@/app/page"; import type { ViewMode } from "@/components/pages/PlayerApp";
// 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider"; import { useToast } from "@/components/ToastProvider";
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
import QueueList from "@/components/player/QueueList";
import { ServerAvatar, Spinner, Thumb } from "@/components/ui";
import { formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { QueueState } from "@/hooks/useQueue";
import { permissionLabel } from "@/hooks/useServers";
import { RECENT_EVENT, readRecent } from "@/lib/recent";
import { isCollectionUrl, isMusicUrl } from "@/lib/url";
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music"; import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
/** ms -> m:ss (검색 결과 재생시간 표기용) */ type SourceKey = keyof SearchResults;
function formatDuration(ms?: number) { type Filter = "all" | SourceKey;
if (!ms || Number.isNaN(ms)) return "";
const total = Math.floor(ms / 1000); /** 검색 결과 구역(유튜브 뮤직 검색 화면의 "노래 / 동영상" 구성 + 스포티파이) */
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`; const SOURCES: { key: SourceKey; title: string; kind: string; wide: boolean }[] = [
} { key: "youtubeMusic", title: "노래", kind: "노래", wide: false },
{ key: "youtubeVideo", title: "동영상", kind: "동영상", wide: true },
{ key: "spotify", title: "Spotify", kind: "Spotify", wide: false },
];
/** "전체" 탭에서 구역마다 먼저 보여줄 곡 수(나머지는 "모두 표시") */
const PREVIEW_COUNT = 5;
const EMPTY_RESULTS: SearchResults = { spotify: [], youtubeMusic: [], youtubeVideo: [] };
interface MainContentProps { interface MainContentProps {
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void; server: DiscordServer;
selectedServer: DiscordServer | null;
setSelectedServer: (server: DiscordServer | null) => void;
searchQuery: string; searchQuery: string;
setSearchQuery: (query: string) => void; onSearch: (query: string) => void;
onSelectServer: (server: DiscordServer) => void; onOpenPlayer: () => void;
/** 재생 화면이 열려 있으면 서버 홈의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */
showQueue: boolean;
player: PlayerState;
queue: QueueState;
} }
export default function MainContent({ export default function MainContent({
viewMode, viewMode,
setViewMode, server,
selectedServer,
setSelectedServer,
searchQuery, searchQuery,
setSearchQuery, onSearch,
onSelectServer, onOpenPlayer,
showQueue,
player,
queue,
}: MainContentProps) { }: MainContentProps) {
const { data: session, status } = useSession(); const { data: session } = useSession();
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
const { showToast } = useToast(); const { showToast } = useToast();
const [servers, setServers] = useState<DiscordServer[]>([]); const [searchResults, setSearchResults] = useState<SearchResults>(EMPTY_RESULTS);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResults>({
spotify: [],
youtubeMusic: [],
youtubeVideo: []
});
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>("all");
// 같은 곡을 연달아 눌러 중복으로 쌓이지 않게, 요청 중인 항목을 표시한다.
const [pendingKey, setPendingKey] = useState<string | null>(null);
const [recent, setRecent] = useState<string[]>([]);
const [retry, setRetry] = useState(0);
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => { const isUrlQuery = isMusicUrl(searchQuery);
if (!selectedServer) {
// 🌟 alert -> showToast 교체
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
return;
}
const userId = session?.user?.id; useEffect(() => {
if (!userId) { const sync = () => setRecent(readRecent());
sync();
window.addEventListener(RECENT_EVENT, sync);
return () => window.removeEventListener(RECENT_EVENT, sync);
}, []);
/** 곡 하나(player_play) 또는 주소(player_playlist: 곡·재생목록·앨범 모두)를 대기열에 넣는다. */
const addToQueue = async (key: string, body: Record<string, unknown>, endpoint: string, okText: string) => {
if (!session?.user?.id) {
showToast("로그인이 필요합니다.", "error"); showToast("로그인이 필요합니다.", "error");
return; return;
} }
if (pendingKey) return;
let endpoint = ""; setPendingKey(key);
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
if (actionType === 'player_play') {
endpoint = "/api/player/play";
bodyData.track = track;
} else if (actionType === 'player_playlist') {
endpoint = "/api/player/playlist";
bodyData.playlistUrl = playlistUrl;
}
try { try {
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData), body: JSON.stringify({ serverId: server.id, ...body }),
}); });
const data = await res.json().catch(() => ({}));
const data = await res.json();
if (res.ok && data.success) { if (res.ok && data.success) {
// 🌟 성공 시 초록색 알림 showToast(okText, "success");
showToast("재생목록에 곡을 추가했습니다!", "success");
} else { } else {
// 🌟 에러 시 빨간색 알림
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error"); showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
} }
} catch (error) { } catch (error) {
console.error("Music Action Error:", error); console.error("Music Action Error:", error);
// 🌟 네트워크 에러 시 알림
showToast('서버와 통신할 수 없습니다.', "error"); showToast('서버와 통신할 수 없습니다.', "error");
} finally {
setPendingKey(null);
} }
}; };
const getPermissionLabel = (server: DiscordServer | null) => { const addTrack = (track: SearchTrack, key: string) =>
if (!server) return "알 수 없음"; addToQueue(key, { track }, "/api/player/play", `"${track.title}"을(를) 대기열에 추가했어요.`);
if (server.owner) return "👑 서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화. const addUrl = (url: string) =>
const raw: unknown = server.permissions; addToQueue(
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버"; "url",
try { { playlistUrl: url },
const perms = BigInt(raw); "/api/player/playlist",
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자"; isCollectionUrl(url) ? "재생목록을 대기열에 추가했어요. 나머지 곡은 이어서 채워져요." : "대기열에 추가했어요.",
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저"; );
return "👤 일반 멤버";
} catch {
return "👤 일반 멤버";
}
};
useEffect(() => { useEffect(() => {
if (status === "authenticated") { if (viewMode !== "SEARCH" || !searchQuery || isMusicUrl(searchQuery)) return;
setIsFetching(true); let cancelled = false;
const cached = sessionStorage.getItem("filtered_servers"); // 새 검색마다 결과·탭을 초기화하는 의도적 패턴
if (cached) {
try { setIsSearching(true);
const parsed = JSON.parse(cached); setSearchError(null);
if (Array.isArray(parsed)) { setFilter("all");
setServers(parsed); setSearchResults(EMPTY_RESULTS);
setIsFetching(false);
} fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
} catch { .then(async (res) => {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다. const data = await res.json().catch(() => null);
sessionStorage.removeItem("filtered_servers"); if (cancelled) return;
if (res.ok && data) {
setSearchResults({
spotify: data.spotify || [],
youtubeMusic: data.youtubeMusic || [],
youtubeVideo: data.youtubeVideo || []
});
} else {
setSearchError((data && (data.error || data.message)) || "검색하지 못했습니다.");
} }
} })
.catch(() => !cancelled && setSearchError("서버와 통신할 수 없습니다."))
fetch("/api/servers") .finally(() => !cancelled && setIsSearching(false));
.then((res) => res.json()) // 결과가 늦게 와도 이미 다른 검색어로 넘어갔으면 버린다.
.then((data) => { return () => {
if (Array.isArray(data)) { cancelled = true;
setServers(data); };
sessionStorage.setItem("filtered_servers", JSON.stringify(data)); }, [viewMode, searchQuery, retry]);
}
})
.finally(() => setIsFetching(false));
}
}, [status]);
useEffect(() => {
if (viewMode === "SEARCH_RESULT" && searchQuery) {
setIsSearching(true);
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
.then(async (res) => {
const data = await res.json();
if (res.ok && data) {
setSearchResults({
spotify: data.spotify || [],
youtubeMusic: data.youtubeMusic || [],
youtubeVideo: data.youtubeVideo || []
});
} else {
console.error("Search Error:", data);
}
})
.catch(err => console.error("Fetch Error:", err))
.finally(() => setIsSearching(false));
}
}, [viewMode, searchQuery]);
if (status === "unauthenticated") {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-5 bg-neutral-900 px-6 text-center">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-neutral-800">
<ListMusic size={32} className="text-green-500" aria-hidden />
</span>
<div>
<h2 className="text-2xl font-bold">디스코드로 시작하기</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-neutral-400">
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
</p>
</div>
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
<button
onClick={() => signIn("discord")}
className="flex h-12 items-center gap-2 rounded-full bg-[#5865F2] px-6 font-medium text-white shadow-lg transition hover:bg-[#4752C4] active:scale-95"
>
<LogIn size={18} aria-hidden />
Discord 로그인
</button>
</main>
);
}
if (status === "loading") {
return (
<main className="flex flex-1 items-center justify-center bg-neutral-900">
<Loader2 className="animate-spin text-neutral-600" size={32} aria-label="불러오는 중" />
</main>
);
}
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
/** /**
* 곡 하나는 '리스트 행'으로 보여준다. * 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이".
* 그리드 카드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태고, * 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다.
* 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(제목·아티스트·재생시간) 형식이라
* 사용자에게 익숙한 쪽으로 맞춘다. 행 전체가 버튼이라 터치로도 바로 누를 수 있다.
*/ */
const renderTrackRow = (track: SearchTrack, index: number) => ( const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean, sourceKey: string, index: number) => {
<button const key = `${sourceKey}:${track.videoId || track.id || track.url || index}`;
key={track.videoId || track.id || track.url || track.title} const pending = pendingKey === key;
type="button" const meta = [kind, track.artist, track.duration ? formatTime(track.duration) : null].filter(Boolean).join(" • ");
onClick={() => handleMusicAction('player_play', track)} return (
aria-label={`${track.title} 재생목록에 추가`} <li key={key} className="border-b border-yt-line last:border-b-0">
className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:bg-neutral-700/60" <button
> type="button"
{/* 번호 자리에 hover 시 재생 아이콘이 겹치는 건 스포티파이 곡 목록과 같은 방식 */} onClick={() => addTrack(track, key)}
<span className="hidden w-5 flex-shrink-0 text-center text-sm tabular-nums text-neutral-500 group-hover:hidden sm:block"> disabled={!!pendingKey}
{index + 1} aria-label={`${track.title} 대기열에 추가`}
</span> className="group flex w-full items-center gap-4 py-2 text-left focus:outline-none focus-visible:bg-white/10 disabled:cursor-wait"
<span className="hidden w-5 flex-shrink-0 items-center justify-center text-white sm:group-hover:flex"> >
<Play fill="currentColor" size={14} aria-hidden /> <span className={`relative flex-shrink-0 ${wide ? "h-14 w-[100px]" : "h-14 w-14"}`}>
</span> <Thumb src={track.thumbnail} wide={wide} className="h-full w-full" />
{/* 썸네일 위 재생 아이콘: 마우스면 hover 때, 터치 화면이면 항상 */}
<span className={`absolute inset-0 flex items-center justify-center bg-black/50 transition-opacity ${pending ? "opacity-100" : "opacity-0 group-hover:opacity-100 pointer-coarse:opacity-100 pointer-coarse:bg-black/30"}`}>
{pending ? <Spinner size={20} label="추가 중" /> : <Play fill="white" stroke="white" size={20} aria-hidden />}
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-base text-white">{track.title}</span>
<span className="block truncate text-sm text-white/70">{meta}</span>
</span>
<span
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white/70 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:hidden"
aria-hidden
>
<ListPlus size={22} />
</span>
</button>
</li>
);
};
<span className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded bg-neutral-700"> const counts: Record<SourceKey, number> = {
{track.thumbnail && ( youtubeMusic: searchResults.youtubeMusic.length,
// eslint-disable-next-line @next/next/no-img-element youtubeVideo: searchResults.youtubeVideo.length,
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" /> spotify: searchResults.spotify.length,
)} };
{/* 좁은 화면은 hover 가 없으니 재생 아이콘을 항상 얹어 눌러야 할 곳을 알려준다. */} const hasAnyResults = counts.youtubeMusic + counts.youtubeVideo + counts.spotify > 0;
<span className="absolute inset-0 flex items-center justify-center bg-black/40 sm:hidden">
<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">{track.title}</span> const topKey = (["youtubeMusic", "spotify", "youtubeVideo"] as const).find((k) => counts[k] > 0);
<span className="block truncate text-xs text-neutral-400">{track.artist}</span> const topSource = SOURCES.find((s) => s.key === topKey);
</span> const topTrack = topSource ? searchResults[topSource.key][0] : undefined;
{track.duration ? (
<span className="flex-shrink-0 text-xs tabular-nums text-neutral-500">
{formatDuration(track.duration)}
</span>
) : null}
</button>
);
return ( return (
<main className="flex flex-1 flex-col overflow-y-auto bg-gradient-to-b from-neutral-800/50 to-neutral-900 p-4 pb-28 sm:p-6 lg:p-8"> <main className="relative flex-1 overflow-y-auto bg-yt-bg">
<div className="mx-auto w-full max-w-[1200px] px-4 pb-32 pt-6 sm:px-8 lg:px-12">
{/* 화면 1: 서버 목록 */} {/* 서버 홈 — 유튜브 뮤직 앨범 페이지 형태(왼쪽 서버 정보, 오른쪽 재생 목록) */}
{viewMode === "SERVER_LIST" && ( {viewMode === "HOME" && (
<> <div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:gap-12">
<h2 className="mb-1 text-xl font-bold sm:text-2xl">어느 서버에서 들을까요?</h2> <section className="flex flex-col items-center text-center lg:sticky lg:top-6 lg:w-[320px] lg:flex-shrink-0">
<p className="mb-5 text-sm text-neutral-400">음악 봇이 들어가 있는 서버만 보입니다.</p> <ServerAvatar
{isFetching && servers.length === 0 ? ( server={server}
<div className="flex flex-col items-center gap-3 py-20 text-neutral-400"> rounded="rounded-lg"
<Loader2 className="animate-spin text-green-500" size={36} aria-hidden /> className="h-32 w-32 shadow-2xl sm:h-60 sm:w-60"
<p>서버 목록을 불러오는 중...</p> iconSize={64}
</div> size={512}
) : servers.length === 0 ? ( />
<div className="flex flex-col items-center gap-2 py-20 text-center text-neutral-500"> <h1 className="mt-6 break-words text-[28px] font-bold leading-tight sm:text-[32px]">{server.name || "서버"}</h1>
<Server size={40} className="opacity-50" aria-hidden /> <p className="mt-2 text-sm text-white/70">디스코드 서버 • 내 권한: {permissionLabel(server)}</p>
<p className="text-sm">표시할 서버가 없습니다.</p> <p className="mt-1 flex items-center justify-center gap-1.5 text-sm text-white/60">
<p className="max-w-sm text-xs text-neutral-600"> <Headphones size={14} aria-hidden />
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다. {player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "봇이 아직 음성 채널에 없어요"}
</p> </p>
</div>
) : ( <div className="mt-6 flex items-center gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
{servers.map((server) => (
<button <button
key={server.id}
type="button" type="button"
onClick={() => onSelectServer(server)} onClick={() => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT))}
className="w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]" className="flex h-10 items-center gap-2 rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/85"
> >
<span className="mb-2 flex aspect-square items-center justify-center overflow-hidden rounded-full bg-neutral-700 shadow-lg"> <Search size={18} aria-hidden />
{server.icon ? ( 노래 검색
// eslint-disable-next-line @next/next/no-img-element </button>
<img <button
src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} type="button"
className="h-full w-full object-cover" onClick={onOpenPlayer}
alt="" aria-label="재생 화면 열기"
/> title="재생 화면"
) : ( className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 text-white transition-colors hover:bg-white/10"
<Server size={24} className="text-neutral-500" aria-hidden /> >
)} <ListMusic size={20} aria-hidden />
</span>
<span className="block truncate text-center text-sm font-medium">{server.name}</span>
</button> </button>
))}
</div>
)}
</>
)}
{/* 화면 2: 서버 상세 대시보드 */}
{viewMode === "SERVER_DETAIL" && selectedServer && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<button onClick={() => {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
서버 목록으로 돌아가기
</button>
<div className="mb-6 flex flex-col items-center gap-4 text-center sm:flex-row sm:items-end sm:gap-6 sm:text-left">
<span className="flex h-28 w-28 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800 shadow-2xl sm:h-40 sm:w-40">
{selectedServer.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
className="h-full w-full object-cover"
alt=""
/>
) : <Server size={56} className="text-neutral-500" aria-hidden />}
</span>
<div className="min-w-0">
<span className="text-xs font-bold uppercase tracking-wider text-neutral-400">서버 대시보드</span>
<h1 className="mt-1 break-words text-2xl font-black sm:text-4xl lg:text-5xl">{selectedServer.name}</h1>
<p className="mt-2 text-sm text-neutral-400">
위 검색창에서 노래를 찾아 바로 재생할 수 있습니다.
</p>
</div>
</div>
{/* 무엇을 해야 하는지 알려주는 안내 — 서버만 고르면 다음 행동이 막막했던 문제 보완 */}
<div className="mb-6 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
<p className="text-sm text-green-200">
먼저 디스코드에서 <strong>음성 채널에 입장</strong>한 뒤, 노래를 검색해 재생 버튼을 누르세요.
</p>
</div>
<div className="rounded-xl border border-neutral-800 bg-black/20 p-4 sm:p-6">
<h3 className="mb-4 flex items-center gap-2 text-base font-bold sm:text-lg">
<Music size={20} aria-hidden /> 상세 정보
</h3>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 sm:gap-4">
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
<p className="mb-1 text-neutral-500">서버 고유 ID</p>
<p className="break-all font-mono text-white">{selectedServer.id}</p>
</div> </div>
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
<p className="mb-1 text-neutral-500">나의 서버 권한</p> {/* 처음 쓰는 사람을 위한 안내: 아무것도 재생 중이 아닐 때만 */}
<p className="font-bold text-green-400">{getPermissionLabel(selectedServer)}</p> {!player.track && (
</div> <ol className="mt-6 w-full max-w-sm space-y-2 rounded-xl bg-white/[0.06] p-4 text-left text-sm text-white/80">
</div> <li>1. 디스코드에서 <strong className="text-white">음성 채널에 먼저 입장</strong>해 주세요.</li>
<li>2. 위 검색창에서 노래를 찾거나 유튜브·스포티파이 <strong className="text-white">주소를 붙여넣고</strong> Enter.</li>
<li>3. 곡을 누르면 봇이 들어와서 바로 재생해요.</li>
</ol>
)}
{/* 최근 검색: 다시 찾기 쉽게 칩으로 */}
{recent.length > 0 && (
<div className="mt-6 w-full max-w-sm text-left">
<p className="mb-2 flex items-center gap-1.5 text-xs font-medium text-white/60">
<Clock size={14} aria-hidden /> 최근 검색
</p>
<div className="flex flex-wrap gap-2">
{recent.map((r) => (
<button
key={r}
type="button"
onClick={() => onSearch(r)}
className="h-8 max-w-full truncate rounded-lg bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover"
>
{r}
</button>
))}
</div>
</div>
)}
</section>
<section className="min-w-0 flex-1">
{showQueue && <QueueList player={player} queue={queue} variant="page" />}
</section>
</div> </div>
</div> )}
)}
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */} {/* 주소를 붙여넣은 경우: 검색 대신 바로 불러오기 카드 */}
{viewMode === "SEARCH_RESULT" && ( {viewMode === "SEARCH" && isUrlQuery && (
<div> <section className="mx-auto max-w-2xl">
<h2 className="mb-1 break-words text-xl font-bold sm:text-2xl">&ldquo;{searchQuery}&rdquo; 검색 결과</h2> <h2 className="mb-4 text-2xl font-bold">주소에서 불러오기</h2>
<p className="mb-6 text-sm text-neutral-400">곡을 누르면 대기열에 추가됩니다.</p> <div className="rounded-lg bg-white/[0.08] p-5">
<div className="flex items-start gap-4">
{isSearching ? ( <span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
<div className="flex flex-col items-center py-20"> <Link2 size={22} aria-hidden />
<Loader2 className="animate-spin text-green-500 mb-4" size={40} /> </span>
<p>노래를 찾는 중입니다...</p> <div className="min-w-0">
<p className="font-medium">{isCollectionUrl(searchQuery) ? "재생목록 · 앨범" : "곡 주소"}</p>
<p className="mt-1 break-all text-sm text-white/60">{searchQuery}</p>
</div>
</div>
<button
type="button"
onClick={() => addUrl(searchQuery)}
disabled={!!pendingKey}
className="mt-5 flex h-10 items-center gap-2 rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/85 disabled:opacity-60"
>
{pendingKey === "url" ? <Spinner size={18} label="불러오는 중" /> : <ListPlus size={18} aria-hidden />}
{pendingKey === "url" ? "불러오는 중..." : "대기열에 추가"}
</button>
<p className="mt-3 text-xs text-white/50">유튜브·유튜브 뮤직·스포티파이의 곡, 재생목록, 앨범 주소를 지원해요.</p>
</div> </div>
) : !hasAnyResults ? ( </section>
<div className="flex flex-col items-center py-20 text-neutral-500"> )}
<SearchX size={48} className="mb-4" />
<p>검색 결과가 없습니다.</p> {/* 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */}
{viewMode === "SEARCH" && !isUrlQuery && (
<div>
<div className="no-scrollbar -mx-4 mb-6 flex gap-3 overflow-x-auto px-4 sm:mx-0 sm:px-0">
{([{ key: "all", title: "전체" } as const, ...SOURCES]).map((chip) => {
const active = filter === chip.key;
const disabled = chip.key !== "all" && counts[chip.key] === 0;
return (
<button
key={chip.key}
type="button"
onClick={() => setFilter(chip.key)}
disabled={disabled || isSearching}
aria-pressed={active}
className={`h-8 flex-shrink-0 rounded-lg px-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40
${active ? "bg-white text-black" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
>
{chip.title}
{chip.key !== "all" && counts[chip.key] > 0 && <span className="ml-1 opacity-60">{counts[chip.key]}</span>}
</button>
);
})}
</div> </div>
) : (
<div className="flex flex-col gap-10 pb-10">
{/* 카테고리 1: Spotify */} {isSearching ? (
{searchResults.spotify.length > 0 && ( <div className="flex flex-col items-center gap-4 py-24 text-white/70">
<section> <Spinner label="노래를 찾는 중" />
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400"> <p className="text-sm">&ldquo;{searchQuery}&rdquo; 검색 중...</p>
<Disc size={24} /> Spotify 결과 </div>
</h3> ) : searchError ? (
<div className="flex flex-col"> <div className="flex flex-col items-center gap-3 py-24 text-center">
{searchResults.spotify.map(renderTrackRow)} <SearchX size={48} className="text-white/50" aria-hidden />
</div> <p className="text-base text-white">{searchError}</p>
</section> <button
)} type="button"
onClick={() => setRetry((n) => n + 1)}
className="h-9 rounded-full border border-white/20 px-4 text-sm hover:bg-white/10"
>
다시 시도
</button>
</div>
) : !hasAnyResults ? (
<div className="flex flex-col items-center gap-3 py-24 text-center">
<SearchX size={48} className="text-white/50" aria-hidden />
<p className="text-base text-white">&ldquo;{searchQuery}&rdquo;에 대한 검색 결과가 없습니다.</p>
<p className="text-sm text-white/50">철자를 확인하거나 다른 검색어를 입력해 보세요.</p>
</div>
) : (
<div className="flex flex-col gap-10">
{filter === "all" && topTrack && topSource && (
<section>
<h2 className="mb-4 text-2xl font-bold">상위 검색결과</h2>
<div className="flex flex-col gap-4 rounded-lg bg-white/[0.08] p-4 sm:flex-row sm:items-center sm:gap-6 sm:p-5">
<div className="flex min-w-0 items-center gap-4 sm:gap-6">
<Thumb
src={topTrack.thumbnail}
wide={topSource.wide}
className={topSource.wide ? "h-[88px] w-[156px]" : "h-[88px] w-[88px]"}
iconSize={32}
/>
<div className="min-w-0">
<p className="line-clamp-2 text-xl font-bold leading-tight sm:text-2xl">{topTrack.title}</p>
<p className="mt-1 truncate text-sm text-white/70">
{[topSource.kind, topTrack.artist, topTrack.duration ? formatTime(topTrack.duration) : null]
.filter(Boolean)
.join(" • ")}
</p>
</div>
</div>
<div className="flex gap-2 sm:ml-auto">
<button
type="button"
onClick={() => addTrack(topTrack, "top")}
disabled={!!pendingKey}
className="flex h-9 items-center gap-2 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85 disabled:opacity-60"
>
{pendingKey === "top" ? <Spinner size={16} label="추가 중" /> : <Play size={18} fill="currentColor" aria-hidden />}
재생
</button>
</div>
</div>
</section>
)}
{/* 카테고리 2: YouTube Music */} {SOURCES.filter((s) => (filter === "all" || filter === s.key) && counts[s.key] > 0).map((s) => {
{searchResults.youtubeMusic.length > 0 && ( const list = searchResults[s.key];
<section> const shown = filter === "all" ? list.slice(0, PREVIEW_COUNT) : list;
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500"> return (
<Music size={24} /> YouTube Music <section key={s.key}>
</h3> <div className="mb-2 flex items-end justify-between gap-4">
<div className="flex flex-col"> <h2 className="text-2xl font-bold">{s.title}</h2>
{searchResults.youtubeMusic.map(renderTrackRow)} {filter === "all" && list.length > PREVIEW_COUNT && (
</div> <button
</section> type="button"
)} onClick={() => setFilter(s.key)}
className="h-8 flex-shrink-0 rounded-full border border-white/20 px-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
{/* 카테고리 3: YouTube Video */} >
{searchResults.youtubeVideo.length > 0 && ( 모두 표시
<section> </button>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white"> )}
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상 </div>
</h3> <ul>{shown.map((t, i) => renderTrackRow(t, s.kind, s.wide, s.key, i))}</ul>
<div className="flex flex-col"> </section>
{searchResults.youtubeVideo.map(renderTrackRow)} );
</div> })}
</section> </div>
)} )}
</div>
</div> )}
)} </div>
</div>
)}
</main> </main>
); );
} }

View File

@@ -1,535 +1,222 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { ChevronDown, ChevronUp, Pause, Play, Shuffle, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play, ListMusic } from "lucide-react"; import { Thumb } from "@/components/ui";
import { useSession } from "next-auth/react"; import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { DiscordServer, Track } from "@/types/music";
interface PlayerBarProps { interface PlayerBarProps {
selectedServer: DiscordServer | null; player: PlayerState;
onToggleQueue?: () => void; onShuffle: () => void;
isQueueOpen?: boolean; canShuffle: boolean;
/** 넓은 화면에서 우측 대기열 컬럼을 접었다 펴는 토글 */ isPlayerOpen: boolean;
onToggleDock?: () => void; onTogglePlayer: () => void;
isQueueDocked?: boolean;
/** 대기열 패널에도 "지금 재생 중"을 보여주기 위해 현재 곡을 위로 올려준다(중복 폴링 방지). */
onNowPlayingChange?: (track: Track | null) => void;
} }
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onToggleDock, isQueueDocked, onNowPlayingChange }: PlayerBarProps) { /** 진행바(유튜브 뮤직: 재생바 위쪽 가장자리의 얇은 빨간 선, 마우스를 올리면 굵어지고 손잡이가 보인다) */
const { data: session } = useSession(); export function SeekBar({ player, className = "", thick = false }: { player: PlayerState; className?: string; thick?: boolean }) {
const { position, duration, canControl, dragSeek, commitSeek } = player;
const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
return (
<div className={`group relative flex h-4 items-center ${className}`}>
<input
type="range"
min={0}
max={duration || 100}
value={position}
disabled={!canControl}
aria-label="재생 위치"
aria-valuetext={`${formatTime(position)} / ${formatTime(duration)}`}
onChange={(e) => dragSeek(Number(e.target.value))}
onMouseUp={(e) => commitSeek(Number(e.currentTarget.value))}
onTouchEnd={(e) => commitSeek(Number(e.currentTarget.value))}
onKeyUp={(e) => commitSeek(Number(e.currentTarget.value))}
className="absolute inset-0 z-20 h-full w-full cursor-pointer opacity-0 disabled:cursor-default"
/>
<div
className={`pointer-events-none absolute inset-x-0 z-10 bg-white/20 transition-[height] ${thick ? "h-1 rounded-full" : "h-0.5 group-hover:h-1"}`}
>
<div className={`h-full bg-yt-red ${thick ? "rounded-full" : ""}`} style={{ width: `${percent}%` }} />
</div>
{canControl && (
<div
className={`pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-yt-red transition-opacity ${thick ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
style={{ left: `${percent}%` }}
/>
)}
</div>
);
}
// 재생 상태 관리 /**
const [track, setTrack] = useState<Track | null>(null); * 유튜브 뮤직 하단 재생바.
const [botPlayer, setBotPlayer] = useState<boolean>(false); * 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃]
const [isPlaying, setIsPlaying] = useState<boolean>(false); * 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다.
const [isPaused, setIsPaused] = useState<boolean>(false); */
const [volume, setVolume] = useState<number>(50); export default function PlayerBar({ player, onShuffle, canShuffle, isPlayerOpen, onTogglePlayer }: PlayerBarProps) {
const {
track, isPaused, volume, position, duration, canControl, isVolumeDragging,
togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
} = player;
// 시간 및 진행도 관리 const title = track?.info?.title || "재생 중인 곡 없음";
const [position, setPosition] = useState<number>(0); const artist = cleanArtist(track?.info?.author);
const [duration, setDuration] = useState<number>(0); const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
// eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
const mutedVolumeRef = useRef<number>(50); // 음소거 해제 시 복원할 볼륨
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
// 1. 현재 재생 상태 불러오기 (player_now) const playButton = (size: number, className: string) => (
const fetchNowPlaying = useCallback(async () => { <button
if (!selectedServer) return; type="button"
onClick={togglePause}
disabled={!canControl}
aria-label={isPaused ? "재생" : "일시정지"}
title={isPaused ? "재생 (Space)" : "일시정지 (Space)"}
className={`flex items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30 ${className}`}
>
{isPaused ? <Play size={size} fill="currentColor" aria-hidden /> : <Pause size={size} fill="currentColor" aria-hidden />}
</button>
);
const userId = session?.user?.id; const nextButton = (className: string) => (
if (!userId) return; <button
type="button"
try { onClick={skip}
const res = await fetch('/api/player/now', { disabled={!canControl}
method: 'POST', aria-label="다음 곡"
headers: { 'Content-Type': 'application/json' }, title="다음 곡 (Ctrl+→)"
body: JSON.stringify({ className={`flex items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30 ${className}`}
serverId: selectedServer.id, >
}) <SkipForward size={24} fill="currentColor" aria-hidden />
}); </button>
const data = await res.json(); );
if (res.ok && data.success && data.track) {
setBotPlayer(Boolean(data.botPlayer));
setIsPlaying(Boolean(data.isPlaying));
setIsPaused(Boolean(data.isPaused));
setTrack(data.track as Track);
setDuration(Number(data.track?.info?.length ?? 0) || 0);
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
const p = data.position || 0;
setPosition(p);
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
}
} else {
setTrack(null);
setIsPlaying(false);
setPosition(0);
setDuration(0);
}
} catch (error) {
console.error("재생 정보 불러오기 실패:", error);
}
}, [selectedServer, session]);
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "player_update") {
fetchNowPlaying();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
eventSource.onerror = () => {
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
const isPausedRef = useRef(isPaused);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
useEffect(() => {
if (!isPlaying) return;
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
const tick = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
const a = anchorRef.current;
const next = a.pos + (Date.now() - a.at);
setPosition(duration > 0 ? Math.min(duration, next) : next);
}, 250);
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 15000);
return () => {
clearInterval(tick);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
// ================= [ 컨트롤러 액션 ] =================
// 재생/일시정지 토글 (player_pause)
const handleTogglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = session?.user?.id;
if (!userId) return;
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(nextPaused);
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
isPaused: nextPaused, // boolean 그대로 전송
})
});
const data = await res.json();
if (res.ok && data.success) {
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
if (typeof data.paused === "boolean") setIsPaused(data.paused);
} else {
// 실패 시 롤백
setIsPaused(!nextPaused);
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!nextPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const handleSkip = async () => {
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
try {
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
setTimeout(fetchNowPlaying, 500);
} catch (error) {
console.error("스킵 에러:", error);
}
};
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
isDragging.current = false;
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
const newPosition = Number(e.currentTarget.value);
setPosition(newPosition);
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
try {
await fetch('/api/player/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
seek: newPosition,
})
});
} catch (error) {
console.error("시간 이동 에러:", error);
}
};
// 볼륨 조절 (player_volume)
const handleVolumeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedServer) return;
setVolume(Number(e.target.value)); // UI 즉시 반영
};
// 🌟 [신규 추가] 드래그가 끝났을 때 (마우스를 뗐을 때) 딱 한 번 API 전송
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
setIsVolumeDragging(false);
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
const finalVolume = Number(e.currentTarget.value);
setVolume(finalVolume); // UI 즉시 반영
try {
const res = await fetch('/api/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
volume: finalVolume,
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
} catch (error) {
console.error("볼륨 조절 에러:", error);
setVolume(prevVolumeRef.current); // 실패 시 롤백
}
};
/** 볼륨 값을 봇에 전송(실패하면 이전 값으로 되돌린다). */
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";
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// 진행도 퍼센트 계산 (0 ~ 100)
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
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="relative z-50 flex-shrink-0 bg-yt-raised">
{/* 모바일: 진행바를 맨 위에 가로로 꽉 채워 터치 영역을 넓게 확보한다. */} {/* ===== 넓은 화면 ===== */}
<div className="flex items-center gap-2 px-3 pt-2 text-[11px] tabular-nums text-neutral-400 sm:hidden"> <SeekBar player={player} className="absolute inset-x-0 -top-2 hidden md:flex" />
<span className="w-9 text-right">{formatTime(position)}</span> <div className="hidden h-[72px] items-center gap-4 px-4 md:flex">
<div className="group relative flex h-6 flex-1 items-center"> {/* 1. 재생 조작 + 시간 */}
<input <div className="flex flex-shrink-0 items-center gap-2 lg:w-[292px]">
type="range" <button
min={0} type="button"
max={duration || 100} onClick={restart}
value={position}
disabled={!canControl} disabled={!canControl}
aria-label="재생 위치" title="처음부터 다시 재생"
onChange={(e) => { aria-label="처음부터 다시 재생"
isDragging.current = true; className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30"
setPosition(Number(e.target.value)); >
}} <SkipBack size={24} fill="currentColor" aria-hidden />
onMouseUp={handleSeekEnd} </button>
onTouchEnd={handleSeekEnd} {playButton(30, "h-12 w-12")}
className="absolute z-20 h-6 w-full cursor-pointer opacity-0" {nextButton("h-10 w-10")}
/> <span className="ml-2 hidden whitespace-nowrap text-xs tabular-nums text-white/70 lg:inline">
<div className="pointer-events-none absolute z-10 h-1.5 w-full overflow-hidden rounded-full bg-neutral-700"> {formatTime(position)} / {formatTime(duration)}
<div className="h-full bg-green-500" style={{ width: `${progressPercent}%` }} />
</div>
</div>
<span className="w-9">{formatTime(duration)}</span>
</div>
<div className="flex h-20 items-center justify-between gap-3 px-3 sm:h-24 sm:px-6">
{/* 1. 현재 곡 */}
<div className="flex min-w-0 flex-1 items-center gap-3 sm:w-1/4 sm:flex-none">
<span className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-md bg-neutral-800 sm:h-14 sm:w-14">
{track?.info?.artworkUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={track.info.artworkUrl} alt="" className="h-full w-full object-cover" />
)}
</span>
<span className="min-w-0 flex-1">
{track ? (
<>
<span className="block truncate text-sm font-semibold text-white">{track.info?.title}</span>
<span className="block truncate text-xs text-neutral-400">
{track.info?.author?.replace(" - Topic", "")}
</span>
</>
) : (
<>
<span className="block text-sm text-neutral-400">재생 중인 곡 없음</span>
<span className="hidden text-xs text-neutral-600 sm:block">검색해서 노래를 추가해보세요</span>
</>
)}
</span> </span>
</div> </div>
{/* 2. 컨트롤 (모바일에서는 버튼만, 진행바는 위쪽에 따로) */} {/* 2. 현재 곡(누르면 재생 화면) */}
<div className="flex flex-shrink-0 flex-col items-center gap-1 sm:w-2/4 sm:max-w-2xl sm:flex-1"> <button
<div className="flex items-center gap-1 sm:gap-4"> type="button"
<button onClick={onTogglePlayer}
disabled className="flex min-w-0 flex-1 items-center justify-center gap-4 text-left"
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다" aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
aria-label="이전 곡 (지원 예정)" >
className="hidden h-11 w-11 cursor-not-allowed items-center justify-center rounded-full text-neutral-700 sm:flex" <Thumb src={track?.info?.artworkUrl} className="h-10 w-10" />
> <span className="min-w-0 max-w-[420px] flex-1">
<SkipBack size={20} aria-hidden /> <span className="block truncate text-base text-white">{title}</span>
</button> {artist && <span className="block truncate text-base text-white/70">{artist}</span>}
</span>
</button>
<button {/* 3. 볼륨 + 재생 화면 열기 */}
onClick={handleTogglePause} <div className="flex flex-shrink-0 items-center justify-end gap-1 lg:w-[292px]">
disabled={!canControl} {/* 볼륨: 유튜브 뮤직처럼 마우스를 올리면 슬라이더가 옆으로 펼쳐진다 */}
aria-label={isPaused ? "재생" : "일시정지"} <div className="group flex items-center">
className={`flex h-12 w-12 items-center justify-center rounded-full text-black transition <div className="relative flex h-10 w-0 items-center overflow-hidden transition-[width] duration-200 group-focus-within:w-24 group-hover:w-24">
${canControl ? "bg-white hover:scale-105 active:scale-95" : "cursor-not-allowed bg-neutral-700 text-neutral-500"}`}
>
{!isPaused ? <Pause size={22} fill="currentColor" aria-hidden /> : <Play size={22} fill="currentColor" className="ml-0.5" aria-hidden />}
</button>
<button
onClick={handleSkip}
disabled={!canControl}
aria-label="다음 곡"
className={`flex h-11 w-11 items-center justify-center rounded-full transition-colors
${canControl ? "text-neutral-300 hover:bg-neutral-800 hover:text-white" : "cursor-not-allowed text-neutral-700"}`}
>
<SkipForward size={20} aria-hidden />
</button>
{/* 좁은 화면에서 대기열 열기(넓은 화면에는 우측 하단에 같은 버튼을 따로 둔다) */}
{onToggleQueue && (
<button
onClick={onToggleQueue}
aria-label={isQueueOpen ? "대기열 닫기" : "대기열 열기"}
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800 hover:text-white lg:hidden"
>
<ListMusic size={20} aria-hidden />
</button>
)}
</div>
{/* 데스크톱 진행바 */}
<div className="hidden w-full items-center gap-2 text-xs tabular-nums text-neutral-400 sm:flex">
<span className="w-10 text-right">{formatTime(position)}</span>
<div className="group relative flex h-4 flex-1 items-center">
<input <input
type="range" type="range"
min={0} min={0}
max={duration || 100} max={100}
value={position} value={volume}
disabled={!canControl} disabled={!canControl}
aria-label="재생 위치" aria-label="볼륨"
onChange={(e) => { aria-valuetext={`${volume}%`}
isDragging.current = true; onChange={(e) => dragVolume(Number(e.target.value))}
setPosition(Number(e.target.value)); onMouseDown={beginVolumeDrag}
}} onTouchStart={beginVolumeDrag}
onMouseUp={handleSeekEnd} onKeyDown={(e) => { if (!e.repeat) beginVolumeDrag(); }}
onTouchEnd={handleSeekEnd} onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
className="absolute z-20 h-4 w-full cursor-pointer opacity-0" onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
onKeyUp={(e) => commitVolume(Number(e.currentTarget.value))}
className="absolute inset-x-2 z-20 h-full cursor-pointer opacity-0"
/> />
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700"> <div className="pointer-events-none absolute inset-x-2 h-1 rounded-full bg-white/30">
<div <div className="h-full rounded-full bg-white" style={{ width: `${volume}%` }} />
className="h-full bg-white transition-colors group-hover:bg-green-500"
style={{ width: `${progressPercent}%` }}
/>
</div> </div>
<div <div
className="pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100" className="pointer-events-none absolute -ml-1.5 h-3 w-3 rounded-full bg-white"
style={{ left: `${progressPercent}%` }} style={{ left: `calc(0.5rem + (100% - 1rem) * ${volume / 100})` }}
/> />
</div> </div>
<span className="w-10">{formatTime(duration)}</span> {isVolumeDragging && (
</div> <span className="pointer-events-none absolute bottom-[76px] right-16 rounded bg-yt-menu px-2 py-1 text-xs text-white shadow-lg">
</div> {volume}
</span>
{/* 3. 우측 보조 조작: 대기열 + 볼륨 (스포티파이도 이 자리에 대기열 버튼을 둔다) */} )}
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
{onToggleDock && (
<button <button
onClick={onToggleDock} type="button"
aria-label={isQueueDocked ? "대기열 접기" : "대기열 펼치기"} onClick={toggleMute}
title="대기열" disabled={!canControl}
className={`hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white lg:flex aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
${isQueueDocked ? "text-green-500" : ""}`} title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
> >
<ListMusic size={20} aria-hidden /> {volume === 0 ? <VolumeX size={24} aria-hidden /> : <Volume2 size={24} aria-hidden />}
</button> </button>
)} </div>
<button <button
onClick={handleToggleMute} type="button"
disabled={!canControl} onClick={onShuffle}
aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"} disabled={!canShuffle}
title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"} aria-label="다음 트랙 섞기"
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" title="다음 트랙 섞기"
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
> >
{volume === 0 ? <VolumeX size={20} aria-hidden /> : <Volume2 size={20} aria-hidden />} <Shuffle size={22} aria-hidden />
</button> </button>
<div className="group relative flex h-8 w-24 items-center"> <button
<input type="button"
type="range" onClick={onTogglePlayer}
min={0} aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
max={100} title={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
value={volume} className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10"
disabled={!canControl} >
aria-label="볼륨" {isPlayerOpen ? <ChevronDown size={24} aria-hidden /> : <ChevronUp size={24} aria-hidden />}
aria-valuetext={`${volume}%`} </button>
onChange={handleVolumeChange} </div>
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }} </div>
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onMouseUp={handleVolumeEnd} {/* ===== 좁은 화면: 미니 플레이어 ===== */}
onTouchEnd={handleVolumeEnd} <div className="md:hidden">
className="absolute z-20 h-8 w-full cursor-pointer opacity-0" <div className="h-0.5 bg-white/20" aria-hidden>
/> <div className="h-full bg-yt-red" style={{ width: `${percent}%` }} />
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700"> </div>
<div <div className="flex h-16 items-center gap-2 pl-2 pr-1">
className="h-full bg-white transition-colors group-hover:bg-green-500" <button
style={{ width: `${volume}%` }} type="button"
/> onClick={onTogglePlayer}
</div> className="flex min-w-0 flex-1 items-center gap-3 text-left"
<div aria-label="재생 화면 열기"
className="pointer-events-none absolute z-10 -ml-1.5 flex h-3 w-3 justify-center rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100" >
style={{ left: `${volume}%` }} <Thumb src={track?.info?.artworkUrl} className="h-11 w-11" />
> <span className="min-w-0 flex-1">
{isVolumeDragging && ( <span className="block truncate text-sm font-medium text-white">{title}</span>
<span className="absolute bottom-4 whitespace-nowrap rounded border border-neutral-700 bg-neutral-800 px-2 py-1 text-[10px] font-bold text-white shadow-lg"> {artist && <span className="block truncate text-sm text-white/70">{artist}</span>}
{volume} </span>
</span> </button>
)} {playButton(26, "h-12 w-12")}
</div> {nextButton("h-12 w-12")}
</div>
</div> </div>
</div> </div>
</footer> </footer>
); );
} }

View File

@@ -0,0 +1,183 @@
"use client";
import { useEffect } from "react";
import { ChevronDown, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
import { Thumb } from "@/components/ui";
import { SeekBar } from "@/components/player/PlayerBar";
import QueueList from "@/components/player/QueueList";
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { QueueState } from "@/hooks/useQueue";
interface PlayerPageProps {
player: PlayerState;
queue: QueueState;
onClose: () => void;
}
/**
* 유튜브 뮤직 재생 화면(재생바의 ⌃ 로 펼침).
* 넓은 화면: 왼쪽 큰 앨범아트, 오른쪽 "다음 트랙" 탭 — 조작은 아래 재생바가 그대로 맡는다.
* 좁은 화면: 전체 화면 플레이어(앨범아트·진행바·조작·볼륨) + 아래에 다음 트랙.
*/
export default function PlayerPage({ player, queue, onClose }: PlayerPageProps) {
const {
track, isPaused, volume, position, duration, canControl,
togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
} = player;
const art = track?.info?.artworkUrl;
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
role="dialog"
aria-label="재생 화면"
className="fixed inset-0 z-[70] overflow-hidden bg-yt-bg md:absolute md:z-30"
>
{/* 배경: 앨범아트를 크게 흐려 깔고 어둡게 덮는다(유튜브 뮤직의 색감 배경 대용) */}
{art && (
// eslint-disable-next-line @next/next/no-img-element
<img src={art} alt="" aria-hidden className="absolute inset-0 h-full w-full scale-125 object-cover opacity-40 blur-3xl" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/70 to-yt-bg" aria-hidden />
{/* ===== 넓은 화면 ===== */}
<div className="relative hidden h-full gap-10 px-8 py-8 md:flex lg:gap-16 lg:px-16">
<div className="flex min-w-0 flex-1 items-center justify-center">
<Thumb
src={art}
rounded="rounded-md"
iconSize={96}
className="aspect-square w-full max-w-[min(100%,calc(100dvh-240px))] shadow-[0_20px_60px_rgba(0,0,0,0.6)]"
/>
</div>
<div className="flex w-[min(42%,480px)] flex-shrink-0 flex-col">
<div className="flex border-b border-white/20" role="tablist">
<span
role="tab"
aria-selected
className="flex-1 border-b-2 border-white pb-3 text-center text-sm font-medium uppercase tracking-wide text-white"
>
다음 트랙
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<QueueList player={player} queue={queue} variant="panel" />
</div>
</div>
</div>
{/* ===== 좁은 화면: 전체 화면 플레이어 ===== */}
<div className="relative flex h-full flex-col overflow-y-auto md:hidden">
<div className="flex h-14 flex-shrink-0 items-center px-2">
<button
type="button"
onClick={onClose}
aria-label="재생 화면 닫기"
className="flex h-11 w-11 items-center justify-center rounded-full text-white hover:bg-white/10"
>
<ChevronDown size={28} aria-hidden />
</button>
<span className="min-w-0 flex-1 truncate pr-11 text-center text-sm font-medium text-white/70">
{player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "지금 재생 중"}
</span>
</div>
<div className="px-6">
<Thumb
src={art}
rounded="rounded-lg"
iconSize={72}
className="mx-auto aspect-square w-full max-w-[min(100%,48dvh)] shadow-2xl"
/>
<div className="mt-6">
<p className="truncate text-xl font-bold text-white">{track?.info?.title || "재생 중인 곡 없음"}</p>
<p className="truncate text-base text-white/70">{cleanArtist(track?.info?.author)}</p>
</div>
<SeekBar player={player} thick className="mt-4" />
<div className="flex justify-between text-xs tabular-nums text-white/70">
<span>{formatTime(position)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="mt-2 flex items-center justify-center gap-6">
<button
type="button"
onClick={restart}
disabled={!canControl}
aria-label="처음부터 다시 재생"
className="flex h-12 w-12 items-center justify-center text-white disabled:text-white/30"
>
<SkipBack size={30} fill="currentColor" aria-hidden />
</button>
<button
type="button"
onClick={togglePause}
disabled={!canControl}
aria-label={isPaused ? "재생" : "일시정지"}
className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-white text-black transition active:scale-95 disabled:bg-white/30"
>
{isPaused ? <Play size={32} fill="currentColor" className="ml-1" aria-hidden /> : <Pause size={32} fill="currentColor" aria-hidden />}
</button>
<button
type="button"
onClick={skip}
disabled={!canControl}
aria-label="다음 곡"
className="flex h-12 w-12 items-center justify-center text-white disabled:text-white/30"
>
<SkipForward size={30} fill="currentColor" aria-hidden />
</button>
</div>
{/* 봇 볼륨(음성 채널 전체에 적용된다) */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={toggleMute}
disabled={!canControl}
aria-label={volume === 0 ? "음소거 해제" : "음소거"}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center text-white/70 disabled:opacity-40"
>
{volume === 0 ? <VolumeX size={22} aria-hidden /> : <Volume2 size={22} aria-hidden />}
</button>
<div className="relative flex h-10 flex-1 items-center">
<input
type="range"
min={0}
max={100}
value={volume}
disabled={!canControl}
aria-label="볼륨"
aria-valuetext={`${volume}%`}
onChange={(e) => dragVolume(Number(e.target.value))}
onMouseDown={beginVolumeDrag}
onTouchStart={beginVolumeDrag}
onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
className="absolute inset-0 z-20 h-full w-full cursor-pointer opacity-0"
/>
<div className="pointer-events-none absolute inset-x-0 h-1 rounded-full bg-white/30">
<div className="h-full rounded-full bg-white" style={{ width: `${volume}%` }} />
</div>
</div>
<span className="w-8 text-right text-xs tabular-nums text-white/70">{volume}</span>
</div>
</div>
<div className="mt-6 rounded-t-2xl bg-yt-raised/80 px-3 pt-3">
<p className="mb-2 border-b border-white/20 pb-3 text-center text-sm font-medium uppercase tracking-wide text-white">
다음 트랙
</p>
<QueueList player={player} queue={queue} variant="panel" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
"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>
);
}

View File

@@ -1,365 +0,0 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Trash2, GripVertical, Music, ChevronUp, ChevronDown } from "lucide-react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
interface QueueSidebarProps {
selectedServer: DiscordServer | null;
nowPlaying?: Track | null;
onClose?: () => void;
}
export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSidebarProps) {
const { data: session, status } = useSession();
const { showToast } = useToast();
const [queue, setQueue] = useState<Track[]>([]);
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 fetchQueue = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/queue/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
setQueue(data.queue as Track[]);
} else {
setQueue([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
setQueue([]);
}
}, [selectedServer, session]);
useEffect(() => {
if (status === "loading" || !selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchQueue();
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "queue_update") {
fetchQueue();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
eventSource.onerror = () => {
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
};
return () => {
eventSource.close();
};
}, [selectedServer, status, fetchQueue]);
/** 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다). */
const applyReorder = async (newQueue: Track[]) => {
if (!selectedServer) return;
const previous = queue;
setQueue(newQueue);
try {
const res = await fetch('/api/queue/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: session?.user?.id,
newQueue,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
setQueue(previous);
fetchQueue();
}
} catch (error) {
console.error("순서 동기화 실패:", error);
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
setQueue(previous);
fetchQueue();
}
};
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 || !selectedServer) return;
let insertIndex = toIndex;
if (fromIndex < toIndex && toIndex !== queue.length) {
insertIndex = toIndex - 1;
}
if (fromIndex === insertIndex) return; // 순서가 그대로면 통신하지 않는다
const newQueue = [...queue];
const draggedItem = newQueue.splice(fromIndex, 1)[0];
newQueue.splice(insertIndex, 0, draggedItem);
await applyReorder(newQueue);
};
/** 터치 기기에서는 드래그앤드롭이 동작하지 않아, 한 칸씩 옮기는 버튼을 함께 제공한다. */
const moveItem = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= queue.length) return;
const newQueue = [...queue];
[newQueue[index], newQueue[target]] = [newQueue[target], newQueue[index]];
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) => {
if (!selectedServer) return;
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
const targetEncoded = queue[indexToRemove]?.encoded;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);
try {
const res = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
index: indexToRemove,
encoded: targetEncoded,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue();
}
} catch (error) {
console.error("삭제 실패:", error);
showToast("노래를 삭제하지 못했습니다.", "error");
fetchQueue();
}
};
return (
<aside className="flex h-full flex-col bg-neutral-950 p-4 lg:border-l lg:border-neutral-800">
{/* "지금 재생 중"을 대기열 맨 위에 두는 건 스포티파이·유튜브뮤직 대기열의 공통 구조다. */}
{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>
{queue.length > 0 && (
<span className="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
{queue.length}곡
</span>
)}
</div>
{queue.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-neutral-500">
<Music size={32} className="opacity-50" aria-hidden />
<p className="text-sm">대기 중인 노래가 없습니다.</p>
<p className="text-xs text-neutral-600">
검색해서 나온 곡의 재생 버튼을 누르면 여기에 쌓입니다.
</p>
</div>
) : (
<ul className="flex flex-col gap-1 overflow-y-auto pb-24 pr-1">
{queue.map((item, index) => {
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
const title = item.info?.title || "제목 없음";
return (
<li
key={(item.encoded as string) || `${index}-${title}`}
draggable
onDragStart={() => {
dragItem.current = index;
setTimeout(() => setDraggingIndex(index), 0);
}}
onDragEnter={() => {
dragOverItem.current = index;
setDragOverIndex(index);
}}
onDragEnd={handleSort}
onDragOver={(e) => e.preventDefault()}
className={`relative flex items-center gap-2 rounded-lg border border-transparent bg-neutral-900/60 p-2 transition-colors hover:border-neutral-700
${draggingIndex === index ? "opacity-40" : "opacity-100"}
${isDropTarget ? "border-green-500/70" : ""}`}
>
{/* 드래그 핸들: 마우스 환경에서만 의미가 있어 큰 화면에서만 노출 */}
<span className="hidden cursor-grab text-neutral-600 active:cursor-grabbing lg:block" aria-hidden>
<GripVertical size={16} />
</span>
{/* 곡을 누르면 그 곡부터 재생 — 다른 음악 서비스의 대기열과 같은 조작감 */}
<button
type="button"
onClick={() => playFrom(index)}
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left"
aria-label={`${title} 부터 재생`}
title="이 곡부터 재생"
>
<span className="w-4 flex-shrink-0 text-center text-xs tabular-nums text-neutral-500">
{index + 1}
</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 가 없어 아예 못 쓴다).
순서 이동 버튼은 드래그가 안 되는 좁은 화면에서만 띄워 제목 폭을 확보한다. */}
<span className="flex flex-shrink-0 items-center">
<button
onClick={() => moveItem(index, -1)}
disabled={index === 0}
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
aria-label={`${title} 위로 옮기기`}
>
<ChevronUp size={16} aria-hidden />
</button>
<button
onClick={() => moveItem(index, 1)}
disabled={index === queue.length - 1}
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
aria-label={`${title} 아래로 옮기기`}
>
<ChevronDown size={16} aria-hidden />
</button>
<button
onClick={() => handleDelete(index)}
className="flex h-9 w-9 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-red-400"
aria-label={`${title} 대기열에서 빼기`}
>
<Trash2 size={16} aria-hidden />
</button>
</span>
</li>
);
})}
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */}
{draggingIndex !== null && (
<li
onDragEnter={() => {
dragOverItem.current = queue.length;
setDragOverIndex(queue.length);
}}
onDragOver={(e) => e.preventDefault()}
className="hidden h-10 items-center justify-center lg:flex"
>
{dragOverIndex === queue.length && (
<span className="h-0.5 w-full rounded-full bg-green-500" />
)}
</li>
)}
</ul>
)}
</aside>
);
}

100
page/src/components/ui.tsx Normal file
View File

@@ -0,0 +1,100 @@
"use client";
import { Music, Server } from "lucide-react";
import type { DiscordServer } from "@/types/music";
import { serverIconUrl } from "@/hooks/useServers";
/** 유튜브 뮤직 로고 형태(빨간 원 + 흰 링 + 재생 삼각형) */
export function Logo({ className = "" }: { className?: string }) {
return (
<span className={`flex items-center gap-1 ${className}`}>
<svg viewBox="0 0 24 24" className="h-6 w-6 flex-shrink-0" aria-hidden>
<circle cx="12" cy="12" r="12" fill="#ff0000" />
<circle cx="12" cy="12" r="6.6" fill="none" stroke="#fff" strokeWidth="1.1" />
<path d="M10 9.2v5.6l4.9-2.8z" fill="#fff" />
</svg>
<span className="text-[20px] font-bold tracking-[-0.04em] text-white">MusicBot</span>
</span>
);
}
/** 유튜브 기본 썸네일(hq/mq/sd/0.jpg)은 4:3 안에 16:9 영상이 위아래 검은 띠와 함께 들어 있다. */
const LETTERBOXED_YT = /i\.ytimg\.com\/vi\/[^/]+\/(hqdefault|mqdefault|sddefault|0)\.jpg/;
/** 앨범아트/썸네일(이미지가 없으면 음표 아이콘) */
export function Thumb({
src,
className = "",
rounded = "rounded-sm",
iconSize = 18,
wide = false,
}: {
src?: string | null;
className?: string;
rounded?: string;
iconSize?: number;
/** 16:9 상자(동영상 행)면 true. 정사각형 상자에서만 검은 띠를 잘라낸다. */
wide?: boolean;
}) {
// 정사각형 상자에 4:3 유튜브 썸네일을 넣으면 검은 띠가 보이므로 4/3 배 확대해 띠를 잘라낸다.
const cropBars = !wide && !!src && LETTERBOXED_YT.test(src);
return (
<span className={`relative flex flex-shrink-0 items-center justify-center overflow-hidden bg-yt-raised ${rounded} ${className}`}>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" className={`h-full w-full object-cover ${cropBars ? "scale-[1.34]" : ""}`} />
) : (
<Music size={iconSize} className="text-white/40" aria-hidden />
)}
</span>
);
}
/** 디스코드 서버 아이콘(원형, 없으면 서버 아이콘) */
export function ServerAvatar({
server,
className = "",
iconSize = 20,
size = 128,
rounded = "rounded-full",
}: {
server: DiscordServer;
className?: string;
iconSize?: number;
size?: number;
rounded?: string;
}) {
const url = serverIconUrl(server, size);
return (
<span className={`flex flex-shrink-0 items-center justify-center overflow-hidden bg-yt-raised ${rounded} ${className}`}>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt="" className="h-full w-full object-cover" />
) : (
<Server size={iconSize} className="text-white/50" aria-hidden />
)}
</span>
);
}
/** 유튜브 뮤직 스타일 원형 로딩 표시 */
export function Spinner({ size = 32, label = "불러오는 중" }: { size?: number; label?: string }) {
return (
<span
role="status"
aria-label={label}
className="inline-block animate-spin rounded-full border-[3px] border-white/15 border-t-white"
style={{ width: size, height: size }}
/>
);
}
/** 재생 중 이퀄라이저 표시 */
export function Equalizer({ paused = false, className = "" }: { paused?: boolean; className?: string }) {
return (
<span className={`yt-eq flex items-end gap-[2px] ${paused ? "paused" : ""} ${className}`} aria-hidden>
<span />
<span />
<span />
</span>
);
}

376
page/src/hooks/usePlayer.ts Normal file
View File

@@ -0,0 +1,376 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
/**
* 봇의 재생 상태(현재 곡·진행·볼륨)와 조작 액션.
* 재생바와 펼친 재생 화면이 같은 상태를 써야 해서 컴포넌트 밖으로 뺐다(폴링/SSE 는 한 번만).
*/
export function usePlayer(selectedServer: DiscordServer | null) {
const { data: session } = useSession();
const { showToast } = useToast();
// 재생 상태
const [track, setTrack] = useState<Track | null>(null);
const [botPlayer, setBotPlayer] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isPaused, setIsPaused] = useState<boolean>(false);
const [volume, setVolume] = useState<number>(50);
// 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) · 봇이 들어가 있는 음성채널 이름
const [autoplay, setAutoplay] = useState<boolean>(false);
const [voiceChannelName, setVoiceChannelName] = useState<string | null>(null);
// 시간 및 진행도
const [position, setPosition] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const isDragging = useRef<boolean>(false); // 진행바를 드래그 중인지
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
// eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
const mutedVolumeRef = useRef<number>(50); // 음소거 해제 시 복원할 볼륨
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
// 1. 현재 재생 상태 불러오기 (player_now)
const fetchNowPlaying = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/player/now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
const data = await res.json();
// 봇이 옛 버전이면 이 두 값이 없을 수 있다 → 있을 때만 반영
if (res.ok && data.success) {
if (typeof data.recommend === "boolean") setAutoplay(data.recommend);
setVoiceChannelName(typeof data.voiceChannelName === "string" ? data.voiceChannelName : null);
}
if (res.ok && data.success && data.track) {
setBotPlayer(Boolean(data.botPlayer));
setIsPlaying(Boolean(data.isPlaying));
setIsPaused(Boolean(data.isPaused));
setTrack(data.track as Track);
setDuration(Number(data.track?.info?.length ?? 0) || 0);
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
const p = data.position || 0;
setPosition(p);
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
}
} else {
setTrack(null);
setIsPlaying(false);
setPosition(0);
setDuration(0);
}
} catch (error) {
console.error("재생 정보 불러오기 실패:", error);
}
}, [selectedServer, session]);
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "player_update") {
fetchNowPlaying();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
eventSource.onerror = () => {
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
const isPausedRef = useRef(isPaused);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
useEffect(() => {
if (!isPlaying) return;
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
const tick = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
const a = anchorRef.current;
const next = a.pos + (Date.now() - a.at);
setPosition(duration > 0 ? Math.min(duration, next) : next);
}, 250);
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 15000);
return () => {
clearInterval(tick);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
const canControl = !!botPlayer && !!isPlaying && !!track;
// ================= [ 컨트롤러 액션 ] =================
// 재생/일시정지 토글 (player_pause)
const togglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = session?.user?.id;
if (!userId) return;
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(nextPaused);
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
isPaused: nextPaused,
})
});
const data = await res.json();
if (res.ok && data.success) {
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
if (typeof data.paused === "boolean") setIsPaused(data.paused);
} else {
setIsPaused(!nextPaused); // 실패 시 롤백
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!nextPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const skip = async () => {
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
try {
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
setTimeout(fetchNowPlaying, 500);
} catch (error) {
console.error("스킵 에러:", error);
}
};
/** 이전 곡 버튼: 유튜브 뮤직처럼 지금 곡을 처음부터 다시 재생(봇에 이전 곡 기록이 없어서) */
const restart = () => {
if (!canControl) return;
commitSeek(0);
};
/** 자동재생 켜기/끄기 */
const toggleAutoplay = async () => {
if (!selectedServer) return;
const next = !autoplay;
setAutoplay(next); // 낙관적 반영
try {
const res = await fetch('/api/player/autoplay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
if (typeof data.recommend === "boolean") setAutoplay(data.recommend);
showToast(data.recommend ?? next ? "자동재생을 켰어요. 대기열이 끝나면 비슷한 곡을 이어서 틀어요." : "자동재생을 껐어요.", "success");
} else {
setAutoplay(!next);
showToast(data.message || data.error || "자동재생을 바꾸지 못했습니다.", "error");
}
} catch {
setAutoplay(!next);
showToast("서버와 통신할 수 없습니다.", "error");
}
};
/** 진행바를 끄는 중: 화면만 갱신하고 서버 동기화는 멈춘다. */
const dragSeek = (ms: number) => {
isDragging.current = true;
setPosition(ms);
};
/** 진행바에서 손을 뗐을 때(또는 키보드 조작 후) 실제로 탐색한다. */
const commitSeek = async (ms: number) => {
isDragging.current = false;
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
setPosition(ms);
anchorRef.current = { pos: ms, at: Date.now() }; // 탐색 후 기준 갱신
try {
await fetch('/api/player/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
seek: ms,
})
});
} catch (error) {
console.error("시간 이동 에러:", error);
}
};
/** 볼륨 값을 봇에 전송(실패하면 이전 값으로 되돌린다). */
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 beginVolumeDrag = () => {
prevVolumeRef.current = volume;
setIsVolumeDragging(true);
};
/** 드래그 중에는 화면만 바꾼다. */
const dragVolume = (v: number) => {
if (!selectedServer) return;
setVolume(v);
};
/** 손을 뗐을 때 딱 한 번 API 전송 */
const commitVolume = async (v: number) => {
setIsVolumeDragging(false);
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
setVolume(v);
await pushVolume(v, prevVolumeRef.current);
};
/** 스피커 아이콘 클릭 = 음소거/해제 (해제 시 직전 볼륨 복원) */
const toggleMute = 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);
}
};
/**
* 웹 음악 플레이어 표준 단축키(유튜브뮤직·스포티파이 공통):
* 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();
togglePause();
} else if (e.key === "m" || e.key === "M" || e.key === "ㅡ") {
e.preventDefault();
toggleMute();
} else if (e.key === "ArrowRight" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
skip();
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const delta = e.key === "ArrowRight" ? 5000 : -5000;
commitSeek(Math.min(Math.max(position + delta, 0), duration || 0));
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
return {
track,
isPlaying,
isPaused,
volume,
position,
duration,
canControl,
isVolumeDragging,
autoplay,
voiceChannelName,
togglePause,
restart,
toggleAutoplay,
skip,
dragSeek,
commitSeek,
beginVolumeDrag,
dragVolume,
commitVolume,
toggleMute,
};
}
export type PlayerState = ReturnType<typeof usePlayer>;
/** ms -> m:ss (유튜브뮤직 표기: 0:05, 3:21, 1:02:03) */
export function formatTime(ms?: number) {
if (!ms || Number.isNaN(ms) || ms < 0) return "0:00";
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = (total % 60).toString().padStart(2, "0");
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s}` : `${m}:${s}`;
}
/** 유튜브 자동 생성 채널 접미사(" - Topic") 제거 */
export function cleanArtist(author?: string) {
return author?.replace(" - Topic", "") ?? "";
}

252
page/src/hooks/useQueue.ts Normal file
View File

@@ -0,0 +1,252 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
/**
* 대기열 상태와 조작. 서버 페이지 목록과 재생 화면의 "다음 트랙" 탭이 같은 데이터를 보여서
* 구독(SSE)을 한 번만 열도록 훅으로 뺐다.
*/
export function useQueue(selectedServer: DiscordServer | null) {
const { data: session, status } = useSession();
const { showToast } = useToast();
// 봇은 대기열 앞부분(최대 200곡)만 보내고, 전체 곡 수·총 길이는 따로 보낸다(스포티파이 재생목록은 최대 5만 곡).
const [queue, setQueue] = useState<Track[]>([]);
const [total, setTotal] = useState(0);
const [totalMs, setTotalMs] = useState(0);
const applyList = (items: Track[], count?: unknown, ms?: unknown) => {
setQueue(items);
setTotal(typeof count === "number" ? count : items.length);
setTotalMs(typeof ms === "number" ? ms : items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0));
};
const fetchQueue = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/queue/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
applyList(data.queue as Track[], data.total, data.totalMs);
} else {
applyList([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
applyList([]);
}
}, [selectedServer, session]);
useEffect(() => {
if (status === "loading" || !selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchQueue();
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "queue_update") {
fetchQueue();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
eventSource.onerror = () => {
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
};
return () => {
eventSource.close();
};
}, [selectedServer, status, fetchQueue]);
/**
* 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다).
* 봇은 화면에 있던 앞부분(windowSize곡)만 newQueue로 바꾸고 그 뒤 곡은 그대로 둔다. clearAll이면 전부 비운다.
*/
const applyReorder = async (newQueue: Track[], clearAll = false) => {
if (!selectedServer) return;
const previous = queue;
const windowSize = queue.length;
setQueue(newQueue);
if (clearAll) setTotal(0);
try {
const res = await fetch('/api/queue/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: session?.user?.id,
newQueue,
windowSize,
clearAll,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
setQueue(previous);
fetchQueue();
}
} catch (error) {
console.error("순서 동기화 실패:", error);
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
setQueue(previous);
fetchQueue();
}
};
/** 드래그앤드롭 결과 반영. toIndex === queue.length 이면 맨 끝으로 옮긴다. */
const moveTo = async (fromIndex: number, toIndex: number) => {
let insertIndex = toIndex;
if (fromIndex < toIndex && toIndex !== queue.length) {
insertIndex = toIndex - 1;
}
if (toIndex === queue.length) insertIndex = queue.length - 1;
if (fromIndex === insertIndex) return; // 순서가 그대로면 통신하지 않는다
const newQueue = [...queue];
const draggedItem = newQueue.splice(fromIndex, 1)[0];
newQueue.splice(insertIndex, 0, draggedItem);
await applyReorder(newQueue);
};
/** 터치 기기에서는 드래그앤드롭이 동작하지 않아, 한 칸씩 옮기는 버튼을 함께 제공한다. */
const moveBy = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= queue.length) return;
const newQueue = [...queue];
[newQueue[index], newQueue[target]] = [newQueue[target], newQueue[index]];
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 {
if (index > 0) {
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,
windowSize: queue.length,
})
});
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 remove = async (indexToRemove: number) => {
if (!selectedServer) return;
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
const targetEncoded = queue[indexToRemove]?.encoded;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);
try {
const res = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
index: indexToRemove,
encoded: targetEncoded,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue();
}
} catch (error) {
console.error("삭제 실패:", error);
showToast("노래를 삭제하지 못했습니다.", "error");
fetchQueue();
}
};
/** 다음 트랙 섞기(봇이 섞고 queue_update 로 새 순서를 알려준다) */
const shuffle = async () => {
if (!selectedServer) return;
if (total < 2) {
showToast("섞을 곡이 2곡 이상 있어야 해요.", "error");
return;
}
try {
const res = await fetch('/api/queue/shuffle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showToast("다음 트랙을 섞었어요.", "success");
fetchQueue();
} else {
showToast(data.message || data.error || "대기열을 섞지 못했습니다.", "error");
}
} catch {
showToast("서버와 통신할 수 없습니다.", "error");
}
};
/** 다음 트랙 모두 지우기(지금 곡은 계속 재생) */
const clear = async () => {
if (total === 0) return;
await applyReorder([], true);
};
return { queue, total, totalMs, moveTo, moveBy, playFrom, remove, shuffle, clear };
}
export type QueueState = ReturnType<typeof useQueue>;

View File

@@ -0,0 +1,107 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import type { DiscordServer } from "@/types/music";
const CACHE_KEY = "filtered_servers";
const LAST_SERVER_KEY = "musicbot:last-server";
/** 봇이 들어가 있는 내 서버 목록(세션 캐시를 먼저 보여주고 뒤에서 새로 받는다). */
export function useServers() {
const { status } = useSession();
const [servers, setServers] = useState<DiscordServer[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
/** 캐시든 새로 받은 것이든 목록을 한 번이라도 손에 쥐었는지 */
const [loaded, setLoaded] = useState(false);
const load = useCallback((useCache: boolean) => {
setIsFetching(true);
setError(null);
if (useCache) {
const cached = sessionStorage.getItem(CACHE_KEY);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed)) {
setServers(parsed);
setLoaded(true);
}
} catch {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
sessionStorage.removeItem(CACHE_KEY);
}
}
}
fetch("/api/servers")
.then(async (res) => {
const data = await res.json().catch(() => null);
if (Array.isArray(data)) {
setServers(data);
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
} else {
setError((data && (data.error || data.message)) || "서버 목록을 가져오지 못했습니다.");
}
})
.catch(() => setError("서버와 통신할 수 없습니다."))
.finally(() => {
setIsFetching(false);
setLoaded(true);
});
}, []);
useEffect(() => {
if (status !== "authenticated") return;
// eslint-disable-next-line react-hooks/set-state-in-effect
load(true);
}, [status, load]);
/** 캐시를 버리고 디스코드에서 다시 받아온다(봇을 막 초대했을 때 등). */
const refresh = useCallback(() => {
sessionStorage.removeItem(CACHE_KEY);
load(false);
}, [load]);
return { servers, isFetching, error, loaded, refresh };
}
/** 마지막으로 연 서버(서버 선택 화면에서 맨 앞에 "최근" 표시) */
export function getLastServerId(): string | null {
try {
return localStorage.getItem(LAST_SERVER_KEY);
} catch {
return null;
}
}
export function setLastServerId(id: string) {
try {
localStorage.setItem(LAST_SERVER_KEY, id);
} catch {
// 저장 불가(사생활 보호 모드 등)면 무시
}
}
/** 디스코드 서버 아이콘 URL(없으면 null) */
export function serverIconUrl(server: DiscordServer, size = 128) {
return server.icon
? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png?size=${size}`
: null;
}
/** 내 권한 표기(서버 주인 / 관리자 / 매니저 / 멤버) */
export function permissionLabel(server: DiscordServer | null) {
if (!server) return "알 수 없음";
if (server.owner) return "서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
const raw: unknown = server.permissions;
if (typeof raw !== "string" && typeof raw !== "number") return "멤버";
try {
const perms = BigInt(raw);
if ((perms & 0x8n) === 0x8n) return "관리자";
if ((perms & 0x20n) === 0x20n) return "매니저";
return "멤버";
} catch {
return "멤버";
}
}

21
page/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import { getServerSession, type Session } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
/** 서버 컴포넌트용: 로그인 안 돼 있으면 로그인 페이지로 보낸다(돌아올 주소를 함께 넘긴다). */
export async function requirePageSession(callbackUrl: string): Promise<Session> {
const session = (await getServerSession(authOptions)) as Session | null;
if (!session?.user?.id) redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
return session;
}
/** 로그인 여부만 확인(로그인 페이지에서 이미 로그인된 사람을 넘길 때) */
export async function getPageSession(): Promise<Session | null> {
return (await getServerSession(authOptions)) as Session | null;
}
/** 로그인 후 돌아갈 주소: 같은 사이트 경로만 허용(외부 주소로 튕기는 오픈 리다이렉트 방지) */
export function safeCallbackUrl(raw: unknown, fallback = "/servers") {
if (typeof raw !== "string" || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/login")) return fallback;
return raw;
}

24
page/src/lib/recent.ts Normal file
View File

@@ -0,0 +1,24 @@
/** 최근 검색어(브라우저에만 저장). 상단 검색창 목록과 서버 홈의 "최근 검색" 칩이 같이 쓴다. */
const RECENT_KEY = "musicbot:recent-searches";
export const RECENT_MAX = 8;
export function readRecent(): string[] {
try {
const v = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").slice(0, RECENT_MAX) : [];
} catch {
return [];
}
}
export function writeRecent(list: string[]) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
window.dispatchEvent(new Event(RECENT_EVENT));
} catch {
// 저장 불가면 무시
}
}
/** 최근 검색어가 바뀌었음을 같은 탭의 다른 컴포넌트에 알린다 */
export const RECENT_EVENT = "musicbot:recent-changed";

36
page/src/lib/url.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* 검색창에 붙여넣은 값이 봇이 바로 불러올 수 있는 음악 주소인지 판단한다.
* (유튜브·유튜브 뮤직·스포티파이 — 곡/재생목록/앨범 모두 봇의 resolveQuery 가 처리)
*/
const MUSIC_HOSTS = [
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
"open.spotify.com",
];
export function isMusicUrl(value: string): boolean {
const v = value.trim();
if (!/^https?:\/\//i.test(v)) return false;
try {
return MUSIC_HOSTS.includes(new URL(v).hostname.toLowerCase());
} catch {
return false;
}
}
/** 재생목록·앨범처럼 여러 곡을 담은 주소인지(안내 문구용) */
export function isCollectionUrl(value: string): boolean {
try {
const u = new URL(value.trim());
return (
u.searchParams.has("list") ||
/\/(playlist|album|artist)\//.test(u.pathname) ||
u.pathname === "/playlist"
);
} catch {
return false;
}
}

12
soloist/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
# soloist-bridge: 스포티파이 Soloist(공식 헤드리스 클라이언트) → MP3 스트림 중계 서버
# soloist 실행 파일은 이미지에 넣지 않고 /data/bin 에 내려받는다(빌드가 90일마다 만료되므로 자동 갱신).
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends pulseaudio pulseaudio-utils ffmpeg python3 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY bridge.py entrypoint.sh /app/
RUN chmod +x /app/entrypoint.sh
ENV SOLOIST_DATA=/data SOLOIST_PORT=8780 PULSE_SINK=soloist
VOLUME /data
EXPOSE 8780
ENTRYPOINT ["/app/entrypoint.sh"]

50
soloist/README.md Normal file
View File

@@ -0,0 +1,50 @@
# soloist-bridge — 스포티파이 곡을 스포티파이 공식 클라이언트로 재생
스포티파이 곡(스포티파이 주소·재생목록·`-p` 검색·자동재생 추천곡)을 유튜브로 바꾸지 않고
스포티파이 공식 헤드리스 클라이언트 [Soloist](https://developer.spotify.com/documentation/soloist)로 재생한다.
Soloist가 안 되면(설정 없음·페어링 전·다른 서버가 사용 중·재생 오류) 봇이 바로 기존 유튜브 경로로 재생한다.
```
봇 ── GET /prepare?track=<id>&owner=<봇:길드>&duration=<ms> ──▶ soloist-bridge(.5:8780, docker, host 네트워크)
├ soloist --single-track spotify:track:<id>
│ └ PulseAudio 빈 싱크 "soloist"로 출력
├ parec(싱크 모니터) → 앞쪽 무음 제거 → ffmpeg MP3 320k
└ 첫 소리가 나오면 {"path":"/stream/<sid>.mp3"} 응답
Lavalink(.6) ── GET /stream/<sid>.mp3 ──▶ 곡이 끝날 때까지 실시간 MP3 스트림(http 소스로 재생)
```
## 동작 규칙
- **한 번에 한 곡**: 스포티파이 계정 하나는 동시에 한 곳에서만 재생된다. 다른 길드(또는 다른 봇)가 쓰는 중이면
`409 busy` → 그 길드는 유튜브로 재생한다. 같은 길드의 다음 곡 요청은 이전 곡을 끊고 시작한다.
- **실패 판정**: 15초 안에 소리가 안 나오거나 soloist가 먼저 끝나면 실패 → 유튜브.
- **곡 도중 끊김**(같은 계정을 휴대폰에서 재생 등): 청크 종료 없이 연결을 끊어 Lavalink가 오류로 받게 한다.
봇은 그 위치부터 같은 곡의 유튜브 음원으로 이어서 재생한다.
- **일시정지**: 받은 MP3를 세션에 모두 보관하므로 멈췄다 다시 재생해도 이어진다.
- **위치 이동(seek)**: 실시간 스트림이라 직접은 안 된다. 봇이 같은 곡의 유튜브 음원으로 바꿔 그 위치부터 재생한다.
- **빌드 만료(90일)**: 실행 파일은 `/data/bin/soloist`에 자동으로 내려받는다. 만료 7일 전이거나
종료 코드 10(만료)이 나오면 최신 빌드로 자동 교체한다.
## 처음 설정 (사람이 해야 하는 일)
1. 스포티파이 **Premium** 계정으로 https://developer.spotify.com/dashboard 에 로그인 →
Spotify Soloist API Key 메뉴에서 약관 동의 후 키 발급.
2. .5에 키 저장: `/root/bot/soloist/soloist.env` 에 `SOLOIST_API_KEY=<키>` (권한 600).
3. 컨테이너 기동: `docker compose -f /root/bot/music_bot_v2/soloist/compose.yml up -d --build`
4. **페어링(1회)**: 키가 있고 로그인 정보가 없으면 브리지가 자동으로 페어링 대기 상태가 된다.
서버와 **같은 네트워크(LAN)** 에 있는 휴대폰/PC의 스포티파이 앱 → 기기 목록 → `music_bot` 선택.
`curl http://192.168.10.5:8780/health` 의 `"paired": true` 로 확인.
5. 봇 환경변수에 `SOLOIST_URL=http://192.168.10.5:8780` 을 넣고 봇을 다시 빌드/기동.
페어링한 계정으로 다른 기기에서 음악을 틀면 봇 재생이 끊긴다(→ 유튜브로 이어짐). 봇 전용 계정을 권장한다.
계정을 바꾸려면 컨테이너를 멈추고 `/root/bot/soloist/data/state` 를 지운 뒤 다시 띄워 4번을 반복한다.
## 상태 확인
- `GET /health` → `enabled`(키 있음), `paired`, `pairing`, `version`, `expiresInDays`, `busy`(지금 재생 중인 세션)
- `docker logs soloist-bridge` → `소리 시작`, `곡 도중 끊김`, `스트림 연결 종료` 등
## 주의
- 스포티파이 약관상 개인 이용 범위를 벗어난다(디스코드 여러 명에게 송출). 계정 제재 위험은 사용자가 감수한다.
- `/health`·`/prepare`에는 인증이 없다. LAN 밖으로 8780 포트를 열지 말 것.

552
soloist/bridge.py Normal file
View File

@@ -0,0 +1,552 @@
#!/usr/bin/env python3
"""
soloist-bridge — 스포티파이 공식 헤드리스 클라이언트(Soloist)로 곡을 재생하고, 그 소리를 MP3 스트림으로
Lavalink(http 소스)에 넘겨 주는 중계 서버.
흐름 (곡 하나마다)
봇 → GET /prepare?track=<스포티파이 곡 id>&owner=<봇:길드>&duration=<ms>
→ parec(빈 싱크 모니터) + ffmpeg(MP3 인코딩)을 먼저 띄우고 soloist --single-track 실행
→ 앞쪽 무음을 버리고 실제 소리가 나오기 시작하면 {"path": "/stream/<sid>.mp3"} 응답
(정해진 시간 안에 소리가 없거나 soloist가 먼저 끝나면 오류 응답 → 봇이 유튜브로 재생)
Lavalink → GET /stream/<sid>.mp3 (처음부터 끝까지 받은 MP3를 그대로 흘려 보냄)
→ soloist가 곡을 다 재생하고 종료하면 스트림도 끝남(→ Lavalink "finished")
→ 곡 도중에 끊기면(다른 기기에서 같은 계정 재생 등) 청크 종료 없이 연결을 끊어 Lavalink가 오류로 보게 함
계정 하나는 한 번에 한 곡만 재생할 수 있으므로 세션은 동시에 하나만 돈다. 다른 길드가 쓰는 중이면 409(busy)를 준다.
같은 owner의 새 요청은 이전 세션을 끊고 새로 시작한다(건너뛰기).
환경변수
SOLOIST_API_KEY 개발자 대시보드에서 만든 Soloist API 키(필수, 없으면 비활성 → /prepare 503)
SOLOIST_DEVICE_NAME 스포티파이 앱에 보일 기기 이름(기본 music_bot)
SOLOIST_DATA 세션·실행 파일 보관 폴더(기본 /data, 볼륨으로 유지해야 페어링이 남는다)
SOLOIST_BIN soloist 실행 파일 경로(기본 $SOLOIST_DATA/bin/soloist, 없으면 자동 다운로드)
SOLOIST_PORT HTTP 포트(기본 8780)
SOLOIST_PREPARE_TIMEOUT 첫 소리가 나올 때까지 기다리는 최대 초(기본 15)
PULSE_SINK 캡처할 PulseAudio 싱크 이름(기본 soloist)
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import threading
import time
import urllib.request
import uuid
from array import array
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
PORT = int(os.environ.get("SOLOIST_PORT", "8780"))
API_KEY = os.environ.get("SOLOIST_API_KEY", "").strip()
DEVICE_NAME = os.environ.get("SOLOIST_DEVICE_NAME", "music_bot").strip() or "music_bot"
DATA = os.environ.get("SOLOIST_DATA", "/data")
BIN = os.environ.get("SOLOIST_BIN", "").strip() or os.path.join(DATA, "bin", "soloist")
STATE_DIR = os.path.join(DATA, "state")
CACHE_DIR = os.path.join(DATA, "cache")
SINK = os.environ.get("PULSE_SINK", "soloist")
PREPARE_TIMEOUT = float(os.environ.get("SOLOIST_PREPARE_TIMEOUT", "15"))
BUILD_URL = os.environ.get(
"SOLOIST_BUILD_URL", "https://soloist-builds.spotifycdn.com/soloist_release_x86_64.tar.gz")
RATE, CHANNELS = 48000, 2
PCM_BYTES_PER_SEC = RATE * CHANNELS * 2
SILENCE_LEVEL = 8 # 이 값보다 큰 샘플이 나오면 '소리 시작'으로 본다(빈 싱크의 무음은 정확히 0)
TAIL_SEC = 0.8 # soloist 종료 후 싱크에 남은 소리를 더 받는 시간
IDLE_SEC = 10 # 스트림을 받는 쪽이 없을 때 세션을 정리하기까지의 시간
MAX_BUF = 80 * 1024 * 1024 # 세션당 MP3 보관 상한(320kbps 기준 30분 이상)
TRACK_RE = re.compile(r"^[A-Za-z0-9]{22}$")
EXIT_EXPIRED = 10 # soloist: 빌드 만료
def log(msg: str) -> None:
print(f"[soloist-bridge] {msg}", flush=True)
class Soloist:
"""soloist 실행 파일·페어링 상태 관리."""
def __init__(self) -> None:
self.lock = threading.Lock()
self.version = ""
self.expires_days: int | None = None
self.pairing = False
self.last_update = 0.0
self.last_error = ""
@property
def enabled(self) -> bool:
return bool(API_KEY)
@property
def paired(self) -> bool:
# soloist는 로그인 토큰을 <data-dir>/cache/dbrts 에 저장한다(페어링 전에는 없거나 0바이트).
p = os.path.join(STATE_DIR, "cache", "dbrts")
return os.path.exists(p) and os.path.getsize(p) > 0
def base_args(self) -> list[str]:
return [BIN, "-n", DEVICE_NAME, "-k", API_KEY, "-D", STATE_DIR, "-C", CACHE_DIR, "-i", "100", "-v"]
def read_version(self) -> None:
try:
out = subprocess.run([BIN, "--version"], capture_output=True, text=True, timeout=15)
self.version = (out.stdout or out.stderr).strip()
except Exception as e: # noqa: BLE001
self.version = ""
log(f"버전 확인 실패: {e}")
def note_output(self, line: str) -> None:
m = re.search(r"client expires in (-?\d+) days", line)
if m:
self.expires_days = int(m.group(1))
if self.expires_days <= 7:
self.update_async(f"만료 {self.expires_days}일 전")
def update(self, reason: str) -> bool:
"""최신 빌드를 받아 실행 파일을 교체한다. 실행 중인 프로세스는 이전 파일을 계속 쓴다."""
with self.lock:
if time.time() - self.last_update < 3600 and os.path.exists(BIN):
return False
self.last_update = time.time()
log(f"soloist 빌드 내려받기 ({reason})")
try:
os.makedirs(os.path.dirname(BIN), exist_ok=True)
tmp_tar = BIN + ".tar.gz"
with urllib.request.urlopen(BUILD_URL, timeout=120) as r, open(tmp_tar, "wb") as f:
shutil.copyfileobj(r, f)
with tarfile.open(tmp_tar) as tar:
member = tar.getmember("soloist")
src = tar.extractfile(member)
if src is None:
raise RuntimeError("tar 안에 soloist 파일이 없습니다")
with open(BIN + ".new", "wb") as f:
shutil.copyfileobj(src, f)
os.chmod(BIN + ".new", 0o755)
check = subprocess.run([BIN + ".new", "--version"], capture_output=True, text=True, timeout=15)
if check.returncode != 0:
raise RuntimeError(f"새 빌드 실행 실패(code {check.returncode})")
os.replace(BIN + ".new", BIN)
os.remove(tmp_tar)
self.expires_days = None
self.read_version()
log(f"soloist 갱신 완료: {self.version}")
return True
except Exception as e: # noqa: BLE001
log(f"soloist 갱신 실패: {e}")
return False
def update_async(self, reason: str) -> None:
threading.Thread(target=self.update, args=(reason,), daemon=True).start()
def start_pairing(self) -> None:
"""페어링이 안 돼 있으면 soloist --pair 를 띄워 둔다. 같은 네트워크의 스포티파이 앱 기기 목록에서
이 기기를 한 번 고르면 로그인 정보가 저장되고 종료된다."""
if not self.enabled:
return
with self.lock:
if self.pairing:
return
self.pairing = True
threading.Thread(target=self._pair_loop, daemon=True).start()
def _pair_loop(self) -> None:
try:
while not self.paired:
if sessions.running():
time.sleep(5)
continue
log(f'페어링 대기: 스포티파이 앱의 기기 목록에서 "{DEVICE_NAME}"를 고르세요')
proc = subprocess.Popen(self.base_args() + ["-p"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, errors="replace")
assert proc.stdout is not None
for line in proc.stdout:
line = strip_ansi(line.rstrip())
self.note_output(line)
if " E " in line or " W " in line or "pair" in line.lower():
log(f"[pair] {line}")
code = proc.wait()
if code == 0 and self.paired:
log("페어링 완료")
break
if code == EXIT_EXPIRED:
self.update("빌드 만료")
self.last_error = f"pair exit {code}"
time.sleep(30)
finally:
with self.lock:
self.pairing = False
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def strip_ansi(s: str) -> str:
return ANSI_RE.sub("", s)
def kill(proc: subprocess.Popen | None, wait: float = 3.0) -> None:
if proc is None or proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=wait)
except Exception: # noqa: BLE001
try:
proc.kill()
proc.wait(timeout=wait)
except Exception: # noqa: BLE001
pass
class Session:
def __init__(self, owner: str, track: str, duration_ms: int) -> None:
self.id = uuid.uuid4().hex
self.owner = owner
self.track = track
self.duration_ms = duration_ms
self.created = time.time()
self.buf = bytearray()
self.cond = threading.Condition()
self.started = threading.Event() # 실제 소리가 나오기 시작함
self.finished = False # MP3 출력이 끝남(정상/비정상 모두)
self.truncated = False # 곡 도중에 끊김 → 클라이언트에 오류로 알림
self.error = ""
self.clients = 0
self.last_client_seen = time.time()
self.fed_bytes = 0
self.solo_exit: int | None = None
self.solo_exit_at = 0.0
self.stopping = False
self.logs: deque[str] = deque(maxlen=40)
self.parec: subprocess.Popen | None = None
self.ffmpeg: subprocess.Popen | None = None
self.solo: subprocess.Popen | None = None
# ── 상태 ──
@property
def solo_running(self) -> bool:
return self.solo is not None and self.solo.poll() is None
@property
def played_ms(self) -> int:
return self.fed_bytes * 1000 // PCM_BYTES_PER_SEC
def summary(self) -> dict:
return {"id": self.id, "owner": self.owner, "track": self.track, "started": self.started.is_set(),
"finished": self.finished, "truncated": self.truncated, "error": self.error,
"playedMs": self.played_ms, "clients": self.clients, "bytes": len(self.buf)}
# ── 실행 ──
def start(self) -> None:
self.parec = subprocess.Popen(
["parec", "-d", f"{SINK}.monitor", "--format=s16le", f"--rate={RATE}", f"--channels={CHANNELS}",
"--raw", "--latency-msec=50"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
self.ffmpeg = subprocess.Popen(
["ffmpeg", "-hide_banner", "-loglevel", "error",
# 입력 분석(기본 최대 5초 분량)을 끄지 않으면 첫 MP3가 몇 초 늦게 나온다
"-probesize", "32", "-analyzeduration", "0", "-fflags", "nobuffer", "-f", "s16le", "-ar", str(RATE), "-ac", str(CHANNELS),
"-i", "pipe:0", "-c:a", "libmp3lame", "-b:a", "320k", "-write_xing", "0", "-id3v2_version", "0",
"-flush_packets", "1", "-f", "mp3", "pipe:1"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
self.solo = subprocess.Popen(
soloist.base_args() + ["-s", f"spotify:track:{self.track}"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace")
threading.Thread(target=self._read_solo, daemon=True).start()
threading.Thread(target=self._pump_pcm, daemon=True).start()
threading.Thread(target=self._pump_mp3, daemon=True).start()
def _read_solo(self) -> None:
assert self.solo is not None and self.solo.stdout is not None
for line in self.solo.stdout:
line = strip_ansi(line.rstrip())
if not line:
continue
self.logs.append(line)
soloist.note_output(line)
code = self.solo.wait()
self.solo_exit, self.solo_exit_at = code, time.time()
if code == EXIT_EXPIRED:
soloist.update_async("빌드 만료")
if not self.started.is_set():
self.fail(self.reason_from_logs(code))
def reason_from_logs(self, code: int) -> str:
text = "\n".join(self.logs)
if code == EXIT_EXPIRED:
return "build_expired"
if "requires stored credentials" in text or "No session to restore" in text:
soloist.start_pairing()
return "not_paired"
errs = [line for line in self.logs if " E " in line or " W " in line]
return f"soloist exit {code}: " + (errs[-1] if errs else (self.logs[-1] if self.logs else ""))
def _pump_pcm(self) -> None:
"""parec → (앞쪽 무음 버림) → ffmpeg. soloist가 끝나면 TAIL_SEC 뒤 ffmpeg 입력을 닫는다."""
assert self.parec and self.parec.stdout and self.ffmpeg and self.ffmpeg.stdin
fd = self.parec.stdout.fileno()
pending = b""
try:
while not self.stopping:
if self.solo_exit is not None and time.time() - self.solo_exit_at > TAIL_SEC:
break
chunk = os.read(fd, 9600) # 50ms
if not chunk:
break
if not self.started.is_set():
chunk = pending + chunk
usable = len(chunk) - (len(chunk) % 4)
pending = chunk[usable:]
samples = array("h", chunk[:usable])
if not samples or (max(samples) <= SILENCE_LEVEL and -min(samples) <= SILENCE_LEVEL):
continue
# 첫 소리가 있는 프레임부터 보낸다
idx = next(i for i, v in enumerate(samples) if v > SILENCE_LEVEL or -v > SILENCE_LEVEL)
chunk = chunk[(idx - idx % CHANNELS) * 2:usable]
self.started.set()
log(f"[{self.id[:8]}] 소리 시작 ({time.time() - self.created:.2f}초) track={self.track}")
self.ffmpeg.stdin.write(chunk)
self.fed_bytes += len(chunk)
except (BrokenPipeError, OSError, ValueError):
pass
finally:
kill(self.parec, 1)
try:
self.ffmpeg.stdin.close()
except Exception: # noqa: BLE001
pass
if self.started.is_set() and not self.stopping:
expected = self.duration_ms
if (self.solo_exit not in (0, None)) or (expected and self.played_ms < expected - 10000):
self.truncated = True
log(f"[{self.id[:8]}] 곡 도중 끊김: 재생 {self.played_ms}ms / 곡 {expected}ms, "
f"soloist exit={self.solo_exit} {self.logs[-1] if self.logs else ''}")
def _pump_mp3(self) -> None:
assert self.ffmpeg and self.ffmpeg.stdout
fd = self.ffmpeg.stdout.fileno()
while True:
data = os.read(fd, 65536)
if not data:
break
with self.cond:
self.buf += data
self.cond.notify_all()
if len(self.buf) > MAX_BUF:
log(f"[{self.id[:8]}] 버퍼 상한 초과, 세션 종료")
self.stop(truncate=True)
break
self.ffmpeg.wait()
with self.cond:
self.finished = True
self.cond.notify_all()
kill(self.solo)
def fail(self, reason: str) -> None:
if not self.error:
self.error = reason
self.stop(truncate=True)
self.started.set() # prepare 대기를 깨운다(error로 판별)
def stop(self, truncate: bool = False) -> None:
if truncate:
self.truncated = True
self.stopping = True
kill(self.solo)
kill(self.parec, 1)
if self.ffmpeg and self.ffmpeg.stdin:
try:
self.ffmpeg.stdin.close()
except Exception: # noqa: BLE001
pass
with self.cond:
self.cond.notify_all()
class Sessions:
def __init__(self) -> None:
self.lock = threading.Lock()
self.items: dict[str, Session] = {}
def running(self) -> Session | None:
with self.lock:
return next((s for s in self.items.values() if s.solo_running or (not s.started.is_set() and not s.error)), None)
def get(self, sid: str) -> Session | None:
with self.lock:
return self.items.get(sid)
def create(self, owner: str, track: str, duration_ms: int) -> tuple[Session | None, str]:
with self.lock:
active = [s for s in self.items.values() if s.solo_running or (not s.started.is_set() and not s.error)]
for s in active:
in_use = s.clients > 0 or time.time() - s.last_client_seen < IDLE_SEC
if s.owner != owner and in_use:
return None, "busy"
for s in active:
if s.started.is_set():
s.stop(truncate=True)
else:
s.fail("preempted") # 아직 준비 중인 요청은 바로 실패로 돌려준다
session = Session(owner, track, duration_ms)
self.items[session.id] = session
# 이전 soloist가 데이터 폴더 잠금(.lock)을 놓을 때까지 기다린다
for s in active:
if s.solo:
try:
s.solo.wait(timeout=5)
except Exception: # noqa: BLE001
pass
session.start()
return session, ""
def reap(self) -> None:
while True:
time.sleep(1)
now = time.time()
with self.lock:
items = list(self.items.values())
for s in items:
idle = s.clients == 0 and now - s.last_client_seen > IDLE_SEC
if idle and not s.finished and s.started.is_set():
log(f"[{s.id[:8]}] 받는 쪽이 없어 세션 종료")
s.stop(truncate=True)
if (s.finished or s.error) and idle:
with self.lock:
self.items.pop(s.id, None)
soloist = Soloist()
sessions = Sessions()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # noqa: N802
pass
def send_json(self, code: int, obj: dict) -> None:
body = json.dumps(obj, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self): # noqa: N802
u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()}
if u.path == "/health":
run = sessions.running()
return self.send_json(200, {
"enabled": soloist.enabled, "paired": soloist.paired, "pairing": soloist.pairing,
"deviceName": DEVICE_NAME, "version": soloist.version, "expiresInDays": soloist.expires_days,
"busy": run.summary() if run else None})
if u.path == "/prepare":
return self.prepare(q)
m = re.fullmatch(r"/stream/([0-9a-f]{32})\.mp3", u.path)
if m:
return self.stream(m.group(1))
self.send_json(404, {"error": "not_found"})
def prepare(self, q: dict) -> None:
track = q.get("track", "")
owner = q.get("owner", "")
try:
duration = int(q.get("duration", "0") or 0)
except ValueError:
duration = 0
if not TRACK_RE.match(track) or not owner:
return self.send_json(400, {"error": "bad_request"})
if not soloist.enabled:
return self.send_json(503, {"error": "disabled"})
if not soloist.paired:
soloist.start_pairing()
return self.send_json(503, {"error": "not_paired"})
if soloist.pairing:
return self.send_json(503, {"error": "pairing"})
t0 = time.time()
session, err = sessions.create(owner, track, duration)
if not session:
return self.send_json(409, {"error": err})
session.started.wait(PREPARE_TIMEOUT)
if not session.started.is_set():
session.fail("no_audio_timeout")
if session.error:
log(f"[{session.id[:8]}] 준비 실패 track={track}: {session.error}")
return self.send_json(502, {"error": session.error})
session.last_client_seen = time.time()
self.send_json(200, {"path": f"/stream/{session.id}.mp3", "id": session.id,
"readyMs": int((time.time() - t0) * 1000)})
def stream(self, sid: str) -> None:
s = sessions.get(sid)
if not s or s.error:
return self.send_json(404, {"error": "no_session"})
self.send_response(200)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.close_connection = True
with s.cond:
s.clients += 1
s.last_client_seen = time.time()
pos = 0
clean = False
t_open = time.time()
try:
while True:
with s.cond:
while len(s.buf) <= pos and not s.finished and not s.stopping:
s.cond.wait(1.0)
data = bytes(s.buf[pos:pos + 65536])
done = (s.finished or s.stopping) and pos + len(data) >= len(s.buf)
if data:
self.wfile.write(b"%x\r\n" % len(data) + data + b"\r\n")
pos += len(data)
if done:
if not s.truncated:
self.wfile.write(b"0\r\n\r\n")
clean = True
break
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
with s.cond:
s.clients -= 1
s.last_client_seen = time.time()
log(f"[{s.id[:8]}] 스트림 연결 종료: {pos}바이트, {time.time() - t_open:.1f}초, 정상종료={clean}")
if not clean:
# 청크 종료 없이 끊어 받는 쪽이 '비정상 종료'로 알게 한다
try:
self.connection.shutdown(2)
except OSError:
pass
def main() -> None:
if not soloist.enabled:
log("SOLOIST_API_KEY 없음 → 비활성 상태로 시작(/prepare는 503, 봇은 유튜브로 재생)")
else:
if not os.path.exists(BIN):
soloist.update("실행 파일 없음")
soloist.read_version()
log(f"soloist: {soloist.version or '(없음)'} / 기기 이름: {DEVICE_NAME} / 페어링: {soloist.paired}")
os.makedirs(STATE_DIR, exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
if not soloist.paired:
soloist.start_pairing()
threading.Thread(target=sessions.reap, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
log(f"listening on :{PORT}")
server.serve_forever()
if __name__ == "__main__":
sys.exit(main())

17
soloist/compose.yml Normal file
View File

@@ -0,0 +1,17 @@
# .5 호스트 배포 예시: /root/bot 에서
# docker compose -f music_bot_v2/soloist/compose.yml up -d --build
# 스포티파이 앱의 기기 목록에 보이려면(페어링) 같은 LAN에서 mDNS가 닿아야 하므로 host 네트워크를 쓴다.
services:
soloist-bridge:
build: .
image: soloist-bridge:latest
container_name: soloist-bridge
restart: always
network_mode: host
# SOLOIST_API_KEY=... (스포티파이 개발자 대시보드에서 발급, 커밋 금지)
env_file: /root/bot/soloist/soloist.env
environment:
- SOLOIST_DEVICE_NAME=music_bot
volumes:
# 페어링 정보·soloist 실행 파일이 여기 남는다(지우면 다시 페어링해야 함)
- /root/bot/soloist/data:/data

12
soloist/entrypoint.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# PulseAudio를 빈 싱크(soloist) 하나만 둔 채로 띄우고 중계 서버를 실행한다.
# soloist는 PipeWire가 없으면 PulseAudio 기본 출력으로 재생하고, 중계 서버는 그 싱크의 모니터를 캡처한다.
set -e
export XDG_RUNTIME_DIR=/run/soloist-pulse
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
pulseaudio -n --daemonize=yes --exit-idle-time=-1 --disallow-exit --log-target=stderr \
-L "module-native-protocol-unix" \
-L "module-null-sink sink_name=${PULSE_SINK:-soloist} rate=48000 channels=2 format=s16le"
for i in $(seq 1 50); do pactl info >/dev/null 2>&1 && break; sleep 0.1; done
pactl set-default-sink "${PULSE_SINK:-soloist}"
exec python3 /app/bridge.py