Compare commits
10 Commits
master
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| 5811c16c20 | |||
| fed948ca02 | |||
| ef16b67839 | |||
| 3c6dbc9631 | |||
| e7283ae862 | |||
| 5d5bf9b3f3 | |||
| 7f057d818c | |||
| 4fbf8d9326 | |||
| 7994e97631 | |||
| 6762b8faba |
@@ -10,7 +10,6 @@
|
||||
| [`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) |
|
||||
| [`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 |
|
||||
|
||||
## 전체 아키텍처
|
||||
|
||||
@@ -34,10 +34,6 @@ YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
|
||||
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
|
||||
YTRESOLVER_URL=http://192.168.10.5:8779
|
||||
|
||||
# --- 스포티파이 Soloist 중계 서버 (선택, 리포 루트 soloist/) ---
|
||||
# 스포티파이 곡을 Soloist로 먼저 재생하고, 안 되면 유튜브로 재생한다. 비우면 유튜브로만 재생.
|
||||
SOLOIST_URL=http://192.168.10.5:8780
|
||||
|
||||
# --- Redis (대시보드 상태 연동, 선택) ---
|
||||
REDIS=true
|
||||
REDIS_HOST=192.168.10.7
|
||||
|
||||
@@ -100,7 +100,6 @@ sqlite DB(`music1.db` 등)와 쿠키(`ytcookie.txt`)가 컨테이너에서 `/db/
|
||||
| `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 |
|
||||
| `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 |
|
||||
| `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` | 대시보드 제어/상태 연동(선택) |
|
||||
|
||||
> `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다.
|
||||
@@ -143,16 +142,6 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
|
||||
- 지정 채널에 `PREFIX + 검색어/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 믹스 폴백).
|
||||
|
||||
### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임)
|
||||
@@ -170,9 +159,7 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
|
||||
봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`).
|
||||
사이트는 그 키를 짧게 폴링해 결과를 받는다.
|
||||
- action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
|
||||
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`,
|
||||
`queue_shuffle`(다음 트랙 섞기), `player_recommend`(자동재생 토글 — 재생 중이 아니어도 바꿀 수 있음).
|
||||
- `player_now` 응답에는 대시보드 표시용 `recommend`(자동재생 상태)와 `voiceChannelName`(봇이 있는 음성채널)도 들어 있다.
|
||||
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
|
||||
- **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish
|
||||
(`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다.
|
||||
- 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`.
|
||||
@@ -203,7 +190,6 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
|
||||
- **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`,
|
||||
`curl http://192.168.10.5:8779/health`, Lavalink `:2333`.
|
||||
- **유튜브뮤직 검색 품질**: 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 설치 필수).
|
||||
|
||||
---
|
||||
|
||||
72
bot/scripts/ytresolver_innertube.py
Normal file
72
bot/scripts/ytresolver_innertube.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
[실험 후보] 유튜브 내부 API(VISIONOS 클라이언트) 직접 호출로 오디오 URL을 얻는 리졸버 조각.
|
||||
|
||||
운영 리졸버(scripts/ytresolver.py)의 _do_resolve 맨 앞에 0순위로 끼워 넣어 쓴다.
|
||||
yt-dlp 를 아예 거치지 않으므로 리졸브가 0.3초대로 떨어진다(상주 lib 1.7~2.3초).
|
||||
|
||||
동작 조건
|
||||
- 대상이 유튜브 영상 URL 이어야 한다(영상 ID 필요). 검색어면 None 을 돌려 기존 경로로 폴백.
|
||||
- visitorData 가 있어야 한다. 없으면 playabilityStatus=LOGIN_REQUIRED 로 실패한다.
|
||||
운영 적용 시에는 봇이 2시간마다 생성하는 visitorData 를 공유받도록 배선해야 한다.
|
||||
|
||||
실측(2026-09-17)
|
||||
추출 0.30~0.38s / itag 251(yt-dlp 와 동일 포맷) / Lavalink loadType=track
|
||||
range 0·1MB·2MB·3MB 전부 206 (이어받기 정상) / 실제 재생 90초 끊김 0
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from urllib.parse import unquote
|
||||
|
||||
# 운영 적용 시에는 파일 대신 봇이 갱신하는 값을 받아오도록 교체할 것.
|
||||
VISITOR_DATA_PATH = os.environ.get("YT_VISITOR_DATA_PATH", "/tmp/vd.txt")
|
||||
|
||||
_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/26.0 Safari/605.1.15")
|
||||
_VID_RE = re.compile(r"(?:v=|youtu\.be/|/shorts/|/embed/|/live/)([A-Za-z0-9_-]{11})")
|
||||
|
||||
|
||||
def _visitor_data():
|
||||
if not os.path.exists(VISITOR_DATA_PATH):
|
||||
return ""
|
||||
return unquote(open(VISITOR_DATA_PATH).read().strip())
|
||||
|
||||
|
||||
def innertube_resolve(target):
|
||||
"""유튜브 URL -> 최고 비트레이트 오디오 직링크. 불가하면 None(호출측이 폴백)."""
|
||||
m = _VID_RE.search(target)
|
||||
visitor = _visitor_data()
|
||||
if not m or not visitor:
|
||||
return None
|
||||
|
||||
ctx = {
|
||||
"clientName": "VISIONOS", "clientVersion": "1.02",
|
||||
"deviceMake": "Apple", "deviceModel": "RealityDevice17,1",
|
||||
"osName": "visionOS", "osVersion": "26.5.23O471",
|
||||
"hl": "ko", "gl": "KR", "visitorData": visitor,
|
||||
}
|
||||
body = {"videoId": m.group(1), "context": {"client": ctx},
|
||||
"contentCheckOk": True, "racyCheckOk": True}
|
||||
req = urllib.request.Request(
|
||||
"https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": _UA,
|
||||
"X-Goog-Visitor-Id": visitor,
|
||||
"X-Youtube-Client-Name": "101",
|
||||
"X-Youtube-Client-Version": "1.02",
|
||||
},
|
||||
)
|
||||
data = json.loads(urllib.request.urlopen(req, timeout=15).read())
|
||||
if data.get("playabilityStatus", {}).get("status") != "OK":
|
||||
return None
|
||||
|
||||
formats = (data.get("streamingData") or {}).get("adaptiveFormats") or []
|
||||
audio = [f for f in formats
|
||||
if str(f.get("mimeType", "")).startswith("audio") and f.get("url")]
|
||||
if not audio:
|
||||
return None
|
||||
audio.sort(key=lambda f: f.get("bitrate", 0), reverse=True)
|
||||
return audio[0]["url"]
|
||||
181
bot/scripts/ytresolver_lib.py
Normal file
181
bot/scripts/ytresolver_lib.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ytresolver - yt-dlp 오디오 URL 리졸버 (HTTP 서비스)
|
||||
|
||||
봇(GuildPlayer.resolvePlayable)이 유튜브/스포티파이-미러 트랙을 재생하기 직전에 호출한다.
|
||||
Lavalink의 youtube-plugin은 집 IP의 SABR 차단/성인인증(연령제한) 때문에 실재생이 막히므로,
|
||||
여기서 yt-dlp로 "직접 오디오 URL"을 뽑아 봇에 돌려주고 봇은 그 URL을 Lavalink http 소스로 재생한다.
|
||||
|
||||
동작:
|
||||
1) 먼저 익명(쿠키 없이)으로 시도 -> 일반 영상은 계정을 태우지 않는다(밴 리스크 최소화).
|
||||
2) 실패하면 쿠키 + node(EJS 시그니처 해독) 로 재시도 -> 성인인증(연령제한) 영상 우회.
|
||||
|
||||
환경변수:
|
||||
YTRESOLVER_COOKIE : Netscape 쿠키 파일 경로 (기본 /root/bot/db/ytcookie.txt)
|
||||
YTRESOLVER_PORT : 리슨 포트 (기본 8779)
|
||||
YTDLP : yt-dlp 실행 경로 (기본 /usr/local/bin/yt-dlp)
|
||||
|
||||
HTTP API:
|
||||
GET /resolve?url=<유튜브 URL | ytsearch: | 검색어> -> {"url": "<직접 오디오 URL>"}
|
||||
GET /health -> {"ok": true}
|
||||
"""
|
||||
import json, subprocess, time, threading, os, sys
|
||||
sys.path.insert(0, os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")) # zipapp 내부 모듈 임포트
|
||||
import yt_dlp
|
||||
|
||||
_YDL_OPTS = {"format": "bestaudio/best", "noplaylist": True, "quiet": True,
|
||||
"no_warnings": True, "nocheckcertificate": True, "socket_timeout": 15,
|
||||
"extractor_args": {"youtube": {"player_client": ["default"]}}}
|
||||
_ydl = yt_dlp.YoutubeDL(_YDL_OPTS) # 프로세스 상주(임포트/초기화 1회)
|
||||
_ydl_lock = threading.Lock()
|
||||
|
||||
def lib_resolve(target):
|
||||
print("[lib] enter " + target[:40], file=sys.stderr, flush=True)
|
||||
# yt-dlp 를 라이브러리로 직접 호출: 매 요청 프로세스 기동(약 0.4s) 을 없앤다.
|
||||
q = target if (target.startswith("http") or target.startswith("ytsearch")) else "ytsearch1:" + target
|
||||
with _ydl_lock:
|
||||
info = _ydl.extract_info(q, download=False)
|
||||
print("[lib] got info keys=" + str(list(info.keys())[:6]), file=sys.stderr, flush=True)
|
||||
if info.get("entries"):
|
||||
info = info["entries"][0]
|
||||
u = info.get("url")
|
||||
if not u:
|
||||
for f in (info.get("requested_formats") or []):
|
||||
if f.get("url"): u = f["url"]; break
|
||||
return u
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")
|
||||
COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt")
|
||||
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
|
||||
CACHE = {}
|
||||
INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing)
|
||||
LOCK = threading.Lock()
|
||||
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
|
||||
|
||||
# 속도/안정성 공통 플래그
|
||||
COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings",
|
||||
"--no-check-certificate", "--socket-timeout", "15"]
|
||||
|
||||
|
||||
def run_ytdlp(target, use_cookies=False):
|
||||
if use_cookies and os.path.exists(COOKIE_FILE):
|
||||
# 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요
|
||||
cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE,
|
||||
*COMMON, "--extractor-args", "youtube:player_client=web"]
|
||||
else:
|
||||
# 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합.
|
||||
cmd = [YTDLP, *COMMON,
|
||||
"--extractor-args", "youtube:player_client=default,web_safari,tv"]
|
||||
cmd += ["--get-url", target]
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=45)
|
||||
|
||||
|
||||
def first_url(out):
|
||||
lines = (out.stdout or "").strip().splitlines()
|
||||
return lines[0] if lines else None
|
||||
|
||||
|
||||
def _do_resolve(target):
|
||||
# 1순위: 라이브러리 상주 호출(가장 빠름). 실패하면 기존 subprocess 경로로 폴백.
|
||||
try:
|
||||
u = lib_resolve(target)
|
||||
if u and u.startswith("http"):
|
||||
print("[result] OK(lib) " + u[:50], file=sys.stderr, flush=True)
|
||||
with LOCK:
|
||||
CACHE[target] = (u, time.time() + TTL)
|
||||
return u
|
||||
except Exception as e:
|
||||
print("[lib-fail] " + str(e)[:150], file=sys.stderr, flush=True)
|
||||
try:
|
||||
out = run_ytdlp(target, use_cookies=False)
|
||||
url = first_url(out)
|
||||
if not (url and url.startswith("http")) and os.path.exists(COOKIE_FILE):
|
||||
print(f"[retry-cookies] {(out.stderr or out.stdout or '')[:180]}", file=sys.stderr, flush=True)
|
||||
out = run_ytdlp(target, use_cookies=True)
|
||||
url = first_url(out)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[result] TIMEOUT", file=sys.stderr, flush=True)
|
||||
return None
|
||||
if not (url and url.startswith("http")):
|
||||
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
|
||||
return None
|
||||
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
|
||||
with LOCK:
|
||||
CACHE[target] = (url, time.time() + TTL)
|
||||
return url
|
||||
|
||||
|
||||
def resolve(target):
|
||||
now = time.time()
|
||||
with LOCK:
|
||||
hit = CACHE.get(target)
|
||||
if hit and hit[1] > now:
|
||||
return hit[0]
|
||||
# 같은 target이 이미 리졸브 중이면, 새 yt-dlp를 띄우지 않고 그 결과를 기다린다.
|
||||
# (봇이 음성 접속과 동시에 미리 리졸브를 걸어두면, 실제 재생 요청이 이 진행분에 합쳐져
|
||||
# 음성 접속 시간과 리졸브 시간이 겹쳐 첫 재생 지연이 크게 줄어든다.)
|
||||
entry = INFLIGHT.get(target)
|
||||
owner = entry is None
|
||||
if owner:
|
||||
entry = {"event": threading.Event(), "url": None}
|
||||
INFLIGHT[target] = entry
|
||||
if not owner:
|
||||
entry["event"].wait(50)
|
||||
with LOCK:
|
||||
hit = CACHE.get(target)
|
||||
return hit[0] if hit and hit[1] > time.time() else entry.get("url")
|
||||
# owner: 실제 리졸브 수행
|
||||
url = None
|
||||
try:
|
||||
url = _do_resolve(target)
|
||||
except Exception as e:
|
||||
print(f"[result] ERR {str(e)[:120]}", file=sys.stderr, flush=True)
|
||||
finally:
|
||||
entry["url"] = url
|
||||
with LOCK:
|
||||
INFLIGHT.pop(target, None)
|
||||
entry["event"].set()
|
||||
return url
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
p = urlparse(self.path)
|
||||
q = parse_qs(p.query)
|
||||
if p.path == "/health":
|
||||
self._send(200, {"ok": True, "cookie": os.path.exists(COOKIE_FILE)})
|
||||
return
|
||||
if p.path != "/resolve":
|
||||
self._send(404, {"error": "not found"})
|
||||
return
|
||||
target = (q.get("url") or q.get("q") or [None])[0]
|
||||
print(f"[resolve] {target[:80] if target else None}", file=sys.stderr, flush=True)
|
||||
if not target:
|
||||
self._send(400, {"error": "missing url/q"})
|
||||
return
|
||||
if not (target.startswith("http") or target.startswith("ytsearch")):
|
||||
target = "ytsearch1:" + target
|
||||
url = resolve(target)
|
||||
if url:
|
||||
self._send(200, {"url": url})
|
||||
else:
|
||||
self._send(502, {"error": "resolve failed"})
|
||||
|
||||
def _send(self, code, obj):
|
||||
b = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"ytresolver listening on :{PORT} (cookie={COOKIE_FILE}, exists={os.path.exists(COOKIE_FILE)})",
|
||||
file=sys.stderr, flush=True)
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()
|
||||
@@ -9,8 +9,6 @@ import { shuffle } from "../utils/Shuffle";
|
||||
import { checkTextChannelAndMsg } from "../utils/music/Channel";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
import { Soloist } from "../utils/api/Soloist";
|
||||
|
||||
const DelayAfterErrMs = 1000 * 5;
|
||||
const idleEndTime = 1000 * 60 * 10;
|
||||
@@ -22,8 +20,7 @@ const LoadFailRetryDelayMs = 2500;
|
||||
|
||||
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
|
||||
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
|
||||
// _soloist: 지금 Soloist(스포티파이 공식 클라이언트) 스트림으로 재생 중인지. 이 스트림은 위치 이동이 안 된다.
|
||||
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; _soloist?: boolean; };
|
||||
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; };
|
||||
|
||||
export class GuildPlayer {
|
||||
private isDead = false;
|
||||
@@ -31,8 +28,6 @@ export class GuildPlayer {
|
||||
private _GDB: GuildType | undefined;
|
||||
private lastPlayedTrack: QueueTrack | undefined;
|
||||
public queue: QueueTrack[] = [];
|
||||
/** 대기열을 통째로 비울 때(정지·끝·모두 지우기)마다 1 증가. 백그라운드로 재생목록을 이어 받는 작업이 중단 여부를 판단한다. */
|
||||
public queueEpoch = 0;
|
||||
private errorTimer: NodeJS.Timeout | undefined;
|
||||
private endTimer: NodeJS.Timeout | undefined;
|
||||
private closedTimer: NodeJS.Timeout | undefined;
|
||||
@@ -40,8 +35,6 @@ export class GuildPlayer {
|
||||
private lastPosition = 0;
|
||||
private lastProgressAt = 0;
|
||||
private loadFailCount = 0;
|
||||
/** Soloist 스트림이 곡 도중에 끊긴 재생 위치(ms). end 이벤트에서 유튜브 음원으로 그 위치부터 이어 재생한다. */
|
||||
private soloistCutAt: number | undefined;
|
||||
|
||||
constructor(
|
||||
public guild: Guild,
|
||||
@@ -73,21 +66,6 @@ export class GuildPlayer {
|
||||
try {
|
||||
if (this.isDead) 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): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
|
||||
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
|
||||
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
|
||||
@@ -99,13 +77,9 @@ export class GuildPlayer {
|
||||
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
|
||||
if (this.isDead) return;
|
||||
if (this.queue[0]?.encoded === cur.encoded) {
|
||||
// Soloist 스트림이 끊긴 경우도 여기로 온다 → 유튜브 음원으로 다시 재생
|
||||
cur._soloist = false;
|
||||
const reEncoded = await this.resolvePlayable(cur);
|
||||
if (reEncoded) {
|
||||
await this.player.playTrack({ track: { encoded: reEncoded } });
|
||||
return;
|
||||
}
|
||||
await this.player.playTrack({ track: { encoded: reEncoded } });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
|
||||
@@ -157,7 +131,6 @@ export class GuildPlayer {
|
||||
|
||||
this.player.on("exception", async (data) => {
|
||||
try {
|
||||
if (this.queue[0]?._soloist) this.soloistCutAt = this.player.position;
|
||||
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
|
||||
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
|
||||
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
|
||||
@@ -220,10 +193,9 @@ export class GuildPlayer {
|
||||
this.attachPlayerListeners();
|
||||
await this.player.setGlobalVolume(savedVolume || 50);
|
||||
|
||||
// 곡이 있었으면 이어서 재생 (Soloist 스트림은 이어 받을 수 없으므로 유튜브 음원으로 그 위치부터)
|
||||
if (currentTrack) currentTrack._soloist = false;
|
||||
const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
|
||||
if (currentTrack && reEncoded) {
|
||||
// 곡이 있었으면 이어서 재생
|
||||
if (currentTrack) {
|
||||
const reEncoded = await this.resolvePlayable(currentTrack);
|
||||
await this.player.playTrack({
|
||||
track: { encoded: reEncoded },
|
||||
position: currentPosition,
|
||||
@@ -271,11 +243,9 @@ export class GuildPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/** 여러 곡을 대기열에 넣는다. 넣은 대기열 항목을 돌려준다. */
|
||||
public async addTracks(tracks: Track[], userId: string): Promise<QueueTrack[]> {
|
||||
public async addTracks(tracks: Track[], userId: string) {
|
||||
const first = this.queue.length === 0;
|
||||
const added = tracks.map((track) => ({ ...track, userId }));
|
||||
for (const item of added) this.queue.push(item);
|
||||
for (const track of tracks) this.queue.push({ ...track, userId });
|
||||
if (first) {
|
||||
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
|
||||
this.prefetch(this.queue[1]);
|
||||
@@ -285,67 +255,31 @@ export class GuildPlayer {
|
||||
this.prefetch(this.queue[1]);
|
||||
this.setMsg({ player: false, queue: true });
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재생목록의 나머지 곡을 대기열 뒤에 붙인다(백그라운드로 이어 받는 용도). 디스코드 메시지는 고치지 않고
|
||||
* 대시보드에만 알린다(페이지마다 메시지를 고치면 디스코드 요청 제한에 걸린다).
|
||||
* 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로 폴백.
|
||||
* 스포티파이 곡은 여기서(재생 직전에) 유튜브 원곡을 찾아 그 오디오를 쓴다. 재생목록 항목은 대기열에
|
||||
* 곡 정보만 들어 있어서, 원곡을 못 찾으면 재생할 수 없음을 뜻하는 빈 문자열을 돌려준다. */
|
||||
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
|
||||
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
|
||||
private async resolvePlayable(track: QueueTrack): Promise<string> {
|
||||
let source: Track = track;
|
||||
let fallback = Spotify.isLazyTrack(track) ? "" : track.encoded;
|
||||
try {
|
||||
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 ?? {};
|
||||
// 실험: ytresolver 비활성화 시 Lavalink youtube-source(poToken)로 직접 재생 — yt-dlp 단계 생략.
|
||||
if (!Config.ytresolverEnabled) return track.encoded;
|
||||
const info: any = (track as any).info ?? {};
|
||||
const src = info.sourceName;
|
||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return fallback;
|
||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
|
||||
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
|
||||
if (!target) return fallback;
|
||||
if (!target) return track.encoded;
|
||||
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
|
||||
if (!r.ok) return fallback;
|
||||
if (!r.ok) return track.encoded;
|
||||
const data = (await r.json()) as { url?: string };
|
||||
if (!data.url) return fallback;
|
||||
if (!data.url) return track.encoded;
|
||||
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
|
||||
if (!node) return fallback;
|
||||
if (!node) return track.encoded;
|
||||
const res = await node.rest.resolve(data.url);
|
||||
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
|
||||
return fallback;
|
||||
return track.encoded;
|
||||
} catch (e) {
|
||||
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
|
||||
return fallback;
|
||||
return track.encoded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +289,7 @@ export class GuildPlayer {
|
||||
track._resolved = this.resolvePlayable(track).catch((e) => {
|
||||
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
|
||||
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
|
||||
return Spotify.isLazyTrack(track) ? "" : track.encoded;
|
||||
return track.encoded;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,46 +299,12 @@ export class GuildPlayer {
|
||||
return track._resolved!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스포티파이 곡을 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> {
|
||||
public async playNext() {
|
||||
const track = this.queue[0];
|
||||
if (!track) return this.end();
|
||||
const tResolve = Date.now();
|
||||
// 스포티파이 곡은 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);
|
||||
const encoded = await this.getResolved(track);
|
||||
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]);
|
||||
await this.player.playTrack({ track: { encoded } });
|
||||
@@ -423,7 +323,6 @@ export class GuildPlayer {
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.queueEpoch++;
|
||||
this.queue = [];
|
||||
this.canRecommend = false;
|
||||
this.player.stopTrack();
|
||||
@@ -458,18 +357,6 @@ export class GuildPlayer {
|
||||
public seek(num: number) {
|
||||
if (!this.isPlaying) 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);
|
||||
Redis?.publishState("player_update", { guildId: this.guild.id });
|
||||
}
|
||||
@@ -647,7 +534,6 @@ export class GuildPlayer {
|
||||
this.delete(true);
|
||||
}, idleEndTime);
|
||||
this.lastPlayedTrack = undefined;
|
||||
this.queueEpoch++;
|
||||
this.queue = [];
|
||||
this.canRecommend = true;
|
||||
this.setMsg();
|
||||
|
||||
@@ -7,27 +7,12 @@ import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { parseLink } from "../utils/music/Url";
|
||||
import { shuffle } from "../utils/Shuffle";
|
||||
import { Spotify, SpotifyCollectionMax, type SpotifyCollectionPage } from "../utils/api/Spotify";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||||
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
|
||||
|
||||
/** 스포티파이 재생목록에서 아직 받지 않은 나머지(offset부터 total까지). 대기열 추가 후 백그라운드로 이어 받는다. */
|
||||
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 {
|
||||
public shoukaku: Shoukaku;
|
||||
private players: Map<string, GuildPlayer> = new Map();
|
||||
private matchCache: Map<string, Promise<Track | null>> = new Map();
|
||||
|
||||
constructor(readonly client: Client) {
|
||||
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
|
||||
@@ -58,6 +43,11 @@ export class LavalinkManager {
|
||||
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
|
||||
*/
|
||||
private refreshPoToken(): void {
|
||||
// [실험] poToken 주입을 끄고 비교하기 위한 플래그. 미설정 시 기존 동작(주입).
|
||||
if (process.env.POTOKEN_INJECT?.trim()?.toLocaleLowerCase() === "false") {
|
||||
Logger.warn(`[LavalinkManager] [실험] POTOKEN_INJECT=false — poToken 주입 생략`);
|
||||
return;
|
||||
}
|
||||
const script = join(__dirname, "..", "..", "scripts", "potoken-refresh.mjs");
|
||||
const child = spawn(process.execPath, [script], {
|
||||
env: {
|
||||
@@ -124,40 +114,17 @@ export class LavalinkManager {
|
||||
* 오디오 URL이 이미 준비되도록 한다.
|
||||
* 못 찾으면 null.
|
||||
*/
|
||||
public async resolveQuery(query: string): Promise<Resolved | null> {
|
||||
public async resolveQuery(query: string): Promise<{ tracks: Track[]; playlist: boolean; flags: Set<string> } | null> {
|
||||
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
||||
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
|
||||
const { isUrl, text, flags } = parseLink(query.trim());
|
||||
// 스포티파이 플레이리스트/앨범: 곡 목록(제목·아티스트·길이)만 받아 곧바로 대기열에 넣는다.
|
||||
// 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 첫 100곡(~0.5초)만 받고 바로
|
||||
// 재생을 시작하며, 나머지(최대 SpotifyCollectionMax곡)는 addResolved 뒤에 백그라운드로 이어 받는다.
|
||||
// partner API가 실패하면 임베드 페이지(100곡), 그것도 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
|
||||
const collection = isUrl ? Spotify.parseCollection(text) : null;
|
||||
if (collection) {
|
||||
let page: SpotifyCollectionPage | null = await Spotify.getCollectionPage(collection.kind, collection.id, 0, SpotifyFirstPage);
|
||||
if (!page || page.tracks.length === 0) {
|
||||
const embed = await Spotify.getCollectionTracks(collection.kind, collection.id);
|
||||
page = { tracks: embed.tracks, total: 0, cover: embed.cover }; // total 0 → 이어 받지 않음
|
||||
}
|
||||
if (page.tracks.length > 0) {
|
||||
const isPlaylist = collection.kind === "playlist";
|
||||
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) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
|
||||
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
||||
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
|
||||
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
|
||||
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 끝까지 기다린 뒤 다시
|
||||
// ytsearch(~0.5초)를 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도
|
||||
// 추가 대기가 없다. (적중 시엔 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
|
||||
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
|
||||
// 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
|
||||
// (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
|
||||
let searchText: string;
|
||||
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
|
||||
if (isUrl) searchText = text;
|
||||
@@ -171,19 +138,14 @@ export class LavalinkManager {
|
||||
if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음
|
||||
}
|
||||
// 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
|
||||
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트+길이)로
|
||||
// 유튜브 검색 결과 중 같은 곡의 원곡 오디오를 골라 대체한다.
|
||||
// (유튜브뮤직 검색은 현재 노래 대신 팟캐스트·분석 영상을 주는 경우가 많아 엉뚱한 곡이 걸렸다)
|
||||
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
|
||||
// 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
|
||||
const spotifyTrackId = Spotify.parseTrackId(searchText);
|
||||
if (spotifyTrackId) {
|
||||
const meta = await Spotify.getTrackById(spotifyTrackId);
|
||||
if (meta) {
|
||||
const matched = await this.findYoutubeMatch(meta);
|
||||
if (matched) {
|
||||
this.prewarmTrack(matched);
|
||||
return { tracks: [matched], playlist: false, flags };
|
||||
}
|
||||
searchText = `ytsearch:${meta.title} ${meta.artist}`.trim();
|
||||
const q = `${meta.title} ${meta.artist}`.trim();
|
||||
searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
|
||||
}
|
||||
}
|
||||
if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
|
||||
@@ -198,7 +160,6 @@ export class LavalinkManager {
|
||||
if (result.loadType === LoadType.PLAYLIST) {
|
||||
tracks = result.data.tracks;
|
||||
playlist = true;
|
||||
if (flags.has('s')) shuffle(tracks);
|
||||
} else if (result.loadType === LoadType.TRACK) {
|
||||
tracks = [result.data];
|
||||
} else if (result.loadType === LoadType.SEARCH) {
|
||||
@@ -215,106 +176,26 @@ export class LavalinkManager {
|
||||
*/
|
||||
public async addResolved(
|
||||
player: GuildPlayer,
|
||||
resolved: Resolved,
|
||||
resolved: { tracks: Track[]; playlist: boolean; flags: Set<string> },
|
||||
userId: string,
|
||||
) {
|
||||
if (resolved.playlist) {
|
||||
// '-s'(섞기)는 resolveQuery에서 이미 적용했다(섞은 뒤 첫 곡을 예열해야 하므로).
|
||||
const added = await player.addTracks(resolved.tracks, userId);
|
||||
if (resolved.spotifyRemaining) {
|
||||
const group = resolved.flags.has("s") ? new Set(added) : undefined;
|
||||
this.appendSpotifyPages(player, resolved.spotifyRemaining, userId, group)
|
||||
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 불러오기 실패: ${String(e)}`));
|
||||
}
|
||||
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId);
|
||||
return;
|
||||
}
|
||||
await player.addTrack(resolved.tracks[0], userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스포티파이 곡 메타데이터로 유튜브를 검색해 같은 곡의 원곡 오디오 트랙을 고른다. 못 찾으면 null.
|
||||
*/
|
||||
public async findYoutubeMatch(meta: SongMeta): Promise<Track | null> {
|
||||
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
||||
if (!node) return null;
|
||||
const artist = meta.artist.split(",")[0].trim();
|
||||
try {
|
||||
const result = await node.rest.resolve(`ytsearch:${artist} ${meta.title}`.trim());
|
||||
const candidates = result?.loadType === LoadType.SEARCH ? result.data : [];
|
||||
const picked = pickBestMatch(candidates, meta);
|
||||
if (!picked) Logger.warn(`[LavalinkManager] 유튜브에서 같은 곡을 찾지 못함: "${meta.artist} - ${meta.title}"`);
|
||||
return picked;
|
||||
} catch (e) {
|
||||
Logger.warn(`[LavalinkManager] 유튜브 매칭 검색 실패: "${meta.artist} - ${meta.title}" ${String(e)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스포티파이 재생목록의 나머지 곡을 페이지 단위로 받아 대기열 뒤에 순서대로 붙인다(곡 정보만, 변환은 재생 직전).
|
||||
* 정지·모두 지우기·퇴장으로 대기열이 바뀌면(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 offset = rest.offset;
|
||||
while (offset < rest.total) {
|
||||
const offsets: number[] = [];
|
||||
for (let k = 0; k < SpotifyPageConcurrency && offset < rest.total; k++, offset += SpotifyPageSize) offsets.push(offset);
|
||||
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;
|
||||
}
|
||||
pages.forEach((page, k) => {
|
||||
if (!page) {
|
||||
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}곡 추가 완료 (전체 ${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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget).
|
||||
* GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.
|
||||
*/
|
||||
public prewarmTrack(track: Track) {
|
||||
try {
|
||||
if (!Config.ytresolverEnabled) return; // yt-dlp 미사용 실험 모드에서는 예열 자체가 불필요
|
||||
const info: any = (track as any).info ?? {};
|
||||
const src = info.sourceName;
|
||||
// 스포티파이 곡은 유튜브 원곡 매칭부터 예열하고, 찾으면 그 유튜브 주소를 리졸브해 둔다.
|
||||
if (src === "spotify") {
|
||||
this.matchSpotifyTrack(track).then((m) => { if (m) this.prewarmTrack(m); }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (src !== "youtube" && src !== "youtubemusic") return;
|
||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
|
||||
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
|
||||
if (!target) return;
|
||||
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});
|
||||
|
||||
@@ -9,14 +9,6 @@ import { channelJoin } from "../commands/join";
|
||||
import { GuildPlayer } from "./GuildPlayer";
|
||||
import { Guild, VoiceChannel } from "discord.js";
|
||||
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 =
|
||||
"search" |
|
||||
@@ -29,9 +21,7 @@ type SubAction =
|
||||
"player_volume" |
|
||||
"queue_list" |
|
||||
"queue_set" |
|
||||
"queue_remove" |
|
||||
"queue_shuffle" |
|
||||
"player_recommend";
|
||||
"queue_remove";
|
||||
|
||||
export const RedisClient = () => {
|
||||
if (Config.redis.state) return new RedisClientClass();
|
||||
@@ -151,10 +141,7 @@ class RedisClientClass {
|
||||
isPaused: player?.isPaused,
|
||||
position: player?.position,
|
||||
volume: player?.volume,
|
||||
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,
|
||||
track: player?.nowTrack ?? null
|
||||
}));
|
||||
}
|
||||
if (data.action === "queue_list") {
|
||||
@@ -165,15 +152,7 @@ class RedisClientClass {
|
||||
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: "이 서버의 멤버가 아닙니다." }));
|
||||
const player = lavalinkManager.getPlayer(data.serverId);
|
||||
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,
|
||||
}));
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
|
||||
}
|
||||
if (data.action === "queue_set") {
|
||||
const resultKey = `queue:set:${data.requestId}`;
|
||||
@@ -183,20 +162,11 @@ class RedisClientClass {
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
|
||||
// 대시보드는 대기열 앞부분(windowSize곡, 기본 QueueListLimit)만 갖고 있으므로 그 부분만 바꾸고 뒤는 남긴다.
|
||||
// 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);
|
||||
context.player.queue.length = 0;
|
||||
if (nowTrack) context.player.queue.push(nowTrack);
|
||||
for (const rawTrack of data.newQueue) {
|
||||
if (!rawTrack?.encoded) continue;
|
||||
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
|
||||
const { _resolved, ...track } = rawTrack;
|
||||
head.push(track);
|
||||
if (rawTrack.encoded) context.player.queue.push(rawTrack);
|
||||
}
|
||||
context.player.queue = head.concat(tail);
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
context.player.setMsg();
|
||||
}
|
||||
@@ -224,39 +194,6 @@ class RedisClientClass {
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
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") {
|
||||
const resultKey = `player:paused:${data.requestId}`;
|
||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { client, handler, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { DB } from "../utils/Database";
|
||||
import { channelRegister } from "../commands/channel";
|
||||
import { runSelfTest } from "../utils/SelfTest";
|
||||
|
||||
export const clientReady = async () => {
|
||||
if (!client.user) return;
|
||||
@@ -20,6 +21,9 @@ export const clientReady = async () => {
|
||||
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
|
||||
// [실험 전용] 환경변수가 있을 때만 동작. 프로덕션에는 변수 미설정이라 아무 일도 안 한다.
|
||||
runSelfTest();
|
||||
|
||||
if (!Config.dev) return;
|
||||
try {
|
||||
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
||||
|
||||
@@ -106,9 +106,10 @@ export const Config = {
|
||||
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
|
||||
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(/\/+$/, ""),
|
||||
// ytresolver(yt-dlp) 사용 여부. false 면 Lavalink youtube-source(poToken 주입)로 직접 재생한다.
|
||||
// 첫 재생에서 yt-dlp 단계(콜드 3.6~4.2s)를 통째로 없애지만, 집 IP의 SABR/403 차단에
|
||||
// 다시 걸릴 수 있어 실험용 플래그로 둔다. 미설정 시 기존 동작(true) 유지.
|
||||
ytresolverEnabled: (process.env.YTRESOLVER_ENABLED?.trim()?.toLocaleLowerCase() ?? "true") !== "false",
|
||||
|
||||
proxyUrl: process.env.PROXY_URL?.trim() || "",
|
||||
|
||||
|
||||
95
bot/src/utils/SelfTest.ts
Normal file
95
bot/src/utils/SelfTest.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { client, lavalinkManager } from "../index";
|
||||
import { channelJoin } from "../commands/join";
|
||||
import { channelRegister } from "../commands/channel";
|
||||
import { Logger } from "./Logger";
|
||||
|
||||
/**
|
||||
* [실험 전용] poToken 직결 재생 검증용 자가 테스트.
|
||||
*
|
||||
* SELFTEST_GUILD_ID / SELFTEST_VOICE_CHANNEL_ID 가 설정되면, 봇이 스스로 음성채널에 들어가
|
||||
* 한 곡을 재생한 뒤 실제로 오디오가 흐르는지(position 이 증가하는지) 확인해 결과를 로그로 남긴다.
|
||||
* 사람이 음성채널에 없어도 검증할 수 있게 하기 위한 것으로, 실험 브랜치에서만 사용한다.
|
||||
*/
|
||||
export async function runSelfTest() {
|
||||
const guildId = process.env.SELFTEST_GUILD_ID?.trim();
|
||||
const voiceChannelId = process.env.SELFTEST_VOICE_CHANNEL_ID?.trim();
|
||||
const query = process.env.SELFTEST_QUERY?.trim() || "아이유 좋은 날";
|
||||
if (!guildId || !voiceChannelId) return;
|
||||
|
||||
try {
|
||||
Logger.info(`[SelfTest] 시작: guild=${guildId} voice=${voiceChannelId} query="${query}"`);
|
||||
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) return Logger.error(`[SelfTest] guild 를 찾을 수 없습니다: ${guildId}`);
|
||||
|
||||
// Lavalink 노드 연결을 기다린다(연결 전이면 channelJoin 이 실패한다).
|
||||
for (let i = 0; i < 60 && !lavalinkManager.hasReadyNode(); i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!lavalinkManager.hasReadyNode()) return Logger.error(`[SelfTest] Lavalink 노드 연결 대기 실패`);
|
||||
|
||||
// 음악 텍스트 채널이 등록돼 있어야 재생 세션을 만들 수 있다.
|
||||
const textChannelId = process.env.SELFTEST_TEXT_CHANNEL_ID?.trim();
|
||||
if (textChannelId) {
|
||||
const embed = await channelRegister(guild, textChannelId);
|
||||
Logger.info(`[SelfTest] 음악채널 등록: ${embed.data.title ?? "-"}`);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const { player } = await channelJoin(guild, voiceChannelId);
|
||||
if (!player) return Logger.error(`[SelfTest] 음성채널 참가 실패`);
|
||||
const tJoin = Date.now();
|
||||
Logger.info(`[SelfTest] 음성접속 완료 ${tJoin - t0}ms`);
|
||||
|
||||
await lavalinkManager.search(guild.id, query, client.user?.id ?? "selftest", player);
|
||||
const tPlay = Date.now();
|
||||
Logger.info(`[SelfTest] 재생 요청까지 ${tPlay - t0}ms (검색+해석+리졸브 ${tPlay - tJoin}ms)`);
|
||||
|
||||
// 실제 오디오가 흐르는지 확인: position 이 시간에 따라 증가해야 한다.
|
||||
// SELFTEST_WATCH_SEC 로 관찰 시간을 늘리면 장시간 재생 안정성(중간 끊김)을 검증할 수 있다.
|
||||
const watchSec = Number(process.env.SELFTEST_WATCH_SEC?.trim() || "9");
|
||||
// Lavalink 는 position 을 약 5초 주기로 보고하므로 그 주기에 맞춰 샘플링해야 오탐이 없다.
|
||||
const stepMs = 5000;
|
||||
const steps = Math.max(3, Math.round((watchSec * 1000) / stepMs));
|
||||
const samples: number[] = [];
|
||||
let stallCount = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await new Promise((r) => setTimeout(r, stepMs));
|
||||
const pos = player.position;
|
||||
if (samples.length > 0 && pos <= samples[samples.length - 1]) stallCount++;
|
||||
samples.push(pos);
|
||||
if (i % 3 === 0 || i === steps - 1) {
|
||||
Logger.log(`[SelfTest] t+${((i + 1) * stepMs / 1000).toFixed(0)}s position=${pos} isPlaying=${player.isPlaying} track="${player.nowTrack?.info?.title ?? "-"}"`);
|
||||
}
|
||||
}
|
||||
const advanced = samples[samples.length - 1] > samples[0] && samples[samples.length - 1] > 0;
|
||||
if (advanced) {
|
||||
Logger.ready(`[SelfTest] 결과: 재생 성공 ✅ (${(watchSec).toFixed(0)}초 관찰, position ${samples[0]} → ${samples[samples.length - 1]}, 멈춤구간 ${stallCount}/${steps - 1}, 첫 소리까지 약 ${tPlay - t0}ms)`);
|
||||
} else {
|
||||
Logger.error(`[SelfTest] 결과: 재생 실패 ❌ (position 이 진행하지 않음: ${samples.slice(0, 6).join(", ")})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 곡 전환 검증: 두 번째 곡을 큐에 넣고 스킵해서 실제로 다음 곡이 재생되는지 본다.
|
||||
const secondQuery = process.env.SELFTEST_SECOND_QUERY?.trim();
|
||||
if (secondQuery) {
|
||||
const firstTitle = player.nowTrack?.info?.title ?? "";
|
||||
await lavalinkManager.search(guild.id, secondQuery, client.user?.id ?? "selftest", player);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const tSkip = Date.now();
|
||||
player.skip();
|
||||
let switched = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const cur = player.nowTrack?.info?.title ?? "";
|
||||
if (cur && cur !== firstTitle && player.position > 0) { switched = true; break; }
|
||||
}
|
||||
if (switched) {
|
||||
Logger.ready(`[SelfTest] 곡전환: 성공 ✅ ("${firstTitle.slice(0, 25)}" → "${(player.nowTrack?.info?.title ?? "").slice(0, 25)}", ${Date.now() - tSkip}ms)`);
|
||||
} else {
|
||||
Logger.error(`[SelfTest] 곡전환: 실패 ❌ (다음 곡이 재생되지 않음)`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[SelfTest] 예외: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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}분 동안 유튜브로만 재생합니다.`);
|
||||
},
|
||||
};
|
||||
@@ -2,39 +2,6 @@
|
||||
import "dotenv/config";
|
||||
import { Logger } from "../Logger";
|
||||
import { SongItem } from "../../types/Track";
|
||||
import type { Track } from "shoukaku";
|
||||
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_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
|
||||
@@ -58,20 +25,15 @@ export const Spotify = {
|
||||
cached: "",
|
||||
expiresAt: 0,
|
||||
},
|
||||
// 토크너(웹 플레이어) 토큰은 정식 API 토큰(_token)과 쓰임새가 달라 캐시를 따로 둔다.
|
||||
_webToken: {
|
||||
cached: "",
|
||||
expiresAt: 0,
|
||||
},
|
||||
|
||||
async getTokenByTokener(): Promise<string | null> {
|
||||
if (this._webToken.cached && Date.now() < this._webToken.expiresAt - 60000) return this._webToken.cached;
|
||||
if (this._token.cached && Date.now() < this._token.expiresAt) return this._token.cached;
|
||||
try {
|
||||
const response = await fetch(TOKENR_URL, { signal: AbortSignal.timeout(10000) });
|
||||
const response = await fetch(TOKENR_URL);
|
||||
if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`);
|
||||
const jsonData = await response.json() as TOKENER_RESPONSE;
|
||||
this._webToken.cached = jsonData.accessToken;
|
||||
this._webToken.expiresAt = jsonData.accessTokenExpirationTimestampMs;
|
||||
this._token.cached = jsonData.accessToken;
|
||||
this._token.expiresAt = jsonData.accessTokenExpirationTimestampMs;
|
||||
return jsonData.accessToken;
|
||||
} catch (err) {
|
||||
Logger.error(`토크너 접근 실패: ${err}`);
|
||||
@@ -166,173 +128,14 @@ export const Spotify = {
|
||||
return track?.url ?? null;
|
||||
},
|
||||
|
||||
/** open.spotify.com/(intl-xx/)track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
|
||||
/** open.spotify.com/track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
|
||||
parseTrackId(text: string): string | null {
|
||||
const m = text.trim().match(/(?:open\.spotify\.com\/(?:intl-[\w-]+\/)?track\/|spotify:track:)([A-Za-z0-9]+)/);
|
||||
const m = text.trim().match(/(?:open\.spotify\.com\/track\/|spotify:track:)([A-Za-z0-9]+)/);
|
||||
return m ? m[1] : null;
|
||||
},
|
||||
|
||||
/** 스포티파이 플레이리스트/앨범 링크면 종류와 id를 추출. 아니면 null. */
|
||||
parseCollection(text: string): { kind: "playlist" | "album"; id: string } | null {
|
||||
const m = text.trim().match(/(?:open\.spotify\.com\/(?:intl-[\w-]+\/)?|spotify:)(playlist|album)[\/:]([A-Za-z0-9]+)/);
|
||||
return m ? { kind: m[1] as "playlist" | "album", id: m[2] } : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 플레이리스트/앨범의 곡 목록 한 페이지(offset부터 limit개)를 partner API로 가져온다.
|
||||
* 곡마다 id·제목·아티스트·길이·앨범 커버가 들어 있다. 로컬 파일·삭제된 곡은 빠진다. 실패하면 null.
|
||||
*/
|
||||
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 {
|
||||
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" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`);
|
||||
return { tracks: [] };
|
||||
}
|
||||
const html = await response.text();
|
||||
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
const entity = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity : null;
|
||||
const list = entity?.trackList;
|
||||
if (!Array.isArray(list)) {
|
||||
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
|
||||
return { tracks: [] };
|
||||
}
|
||||
const tracks = list
|
||||
.map((t: any) => ({ t, id: String(t?.uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1] }))
|
||||
.filter(({ t, id }: any) => id && t?.title)
|
||||
.map(({ t, id }: any) => ({
|
||||
id,
|
||||
title: String(t.title),
|
||||
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
|
||||
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) {
|
||||
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`);
|
||||
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로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
|
||||
async getTrackById(id: string): Promise<SongMeta | null> {
|
||||
/** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */
|
||||
async getTrackById(id: string): Promise<{ title: string; artist: string } | null> {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
if (!token) return null;
|
||||
@@ -348,7 +151,6 @@ export const Spotify = {
|
||||
return {
|
||||
title: track.name,
|
||||
artist: (track.artists ?? []).map((a) => a.name).join(", "),
|
||||
durationMs: track.duration_ms ?? 0,
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.error(`스포티파이 트랙 조회 실패: ${err}`);
|
||||
|
||||
@@ -11,9 +11,6 @@ const JUNK_RE = /(\bMR\b|inst(?:rumental)?|instrumental|karaoke|노래방|\bcove
|
||||
export const ORIGIN = "https://music.youtube.com";
|
||||
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
|
||||
const searchCache = new Map<string, string>();
|
||||
// 검색 결과 행의 종류 표기(부제 첫 칸). 노래/동영상이 아닌 팟캐스트 에피소드·재생목록 등은 곡 후보에서 뺀다.
|
||||
// (현재 YTM 검색이 노래 대신 '분석/루프' 같은 팟캐스트 에피소드를 상단에 주는 경우가 많다)
|
||||
const NON_SONG_TYPES = new Set(["에피소드", "팟캐스트", "재생목록", "아티스트", "앨범", "싱글", "EP", "프로필", "Episode", "Podcast", "Playlist", "Artist", "Album", "Single", "Profile"]);
|
||||
|
||||
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
|
||||
const defaultCookies: Cookies[] = [
|
||||
@@ -134,7 +131,6 @@ export const YoutubeMusic = {
|
||||
// 1. 최상단 'Top Result' 카드 파싱
|
||||
if (section.musicCardShelfRenderer) {
|
||||
const card = section.musicCardShelfRenderer;
|
||||
const cardType = card.subtitle?.runs?.[0]?.text;
|
||||
const title = card.title?.runs?.[0]?.text;
|
||||
const videoId = card.onTap?.watchEndpoint?.videoId || card.title?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.videoId;
|
||||
|
||||
@@ -152,7 +148,7 @@ export const YoutubeMusic = {
|
||||
const thumbnails = card.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || [];
|
||||
const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : "";
|
||||
|
||||
if (videoId && title && !NON_SONG_TYPES.has(cardType)) {
|
||||
if (videoId && title) {
|
||||
results.push({
|
||||
videoId,
|
||||
url: `https://music.youtube.com/watch?v=${videoId}`,
|
||||
@@ -177,7 +173,6 @@ export const YoutubeMusic = {
|
||||
|
||||
let artist = "Unknown Artist";
|
||||
const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || [];
|
||||
if (NON_SONG_TYPES.has(subtitleRuns[0]?.text)) continue;
|
||||
|
||||
if (subtitleRuns.length >= 3) {
|
||||
artist = subtitleRuns[2]?.text || artist;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
|
||||
});
|
||||
}
|
||||
|
||||
export const buttonRecommend = async (guild: Guild) => {
|
||||
const buttonRecommend = async (guild: Guild) => {
|
||||
const gdb = DB.guild.get(guild.id);
|
||||
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
|
||||
if (!change) return;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Track } from "shoukaku";
|
||||
|
||||
/** 스포티파이 곡 메타데이터(제목/아티스트/길이). 유튜브에서 같은 곡을 고르는 기준. */
|
||||
export type SongMeta = { title: string; artist: string; durationMs: number };
|
||||
|
||||
// 원곡 오디오가 아닌 2차 영상(무대·직캠·리액션·분석·커버·루프 등). 원곡 제목 자체에 있는 단어는 감점하지 않는다.
|
||||
const NOT_ORIGINAL_RE = /\b(cover|karaoke|inst|instrumental|mr|remix|sped up|slowed|nightcore|8d|live|reaction|dance practice|choreography|mirrored|fancam|performance|stage|teaser|behind|making|analysis|loop|1 ?hour|1hr|hours|tutorial|full album|playlist|medley|mashup|inkigayo|music ?core|music ?bank|m ?countdown|it's live)\b|노래방|커버|리액션|반응|분석|해석|직캠|무대|방송|인기가요|음악중심|엠카운트다운|뮤직뱅크|라이브|비하인드|연습|교차편집|모음|메들리|\d+\s?분|\d+\s?시간/gi;
|
||||
|
||||
const norm = (s: string) => s
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[’‘`´]/g, "'")
|
||||
.replace(/[^\p{L}\p{N}']+/gu, " ")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* 유튜브 검색 결과 중 스포티파이 곡과 '같은 곡의 원곡 오디오'로 보이는 후보를 고른다.
|
||||
* 기준: 제목 포함 여부, 아티스트 포함 여부, 재생시간 일치(스포티파이 길이와 ±수 초), 2차 영상 감점.
|
||||
* 제목이 안 맞는 후보는 재생시간·아티스트가 정확히 맞을 때만 인정한다. 적당한 후보가 없으면 null.
|
||||
*/
|
||||
export function pickBestMatch(candidates: Track[], want: SongMeta): Track | null {
|
||||
const wantTitle = norm(want.title);
|
||||
const titleCore = norm(want.title.replace(/\s*[\(\[][^\)\]]*[\)\]]/g, "")) || wantTitle;
|
||||
const coreTokens = titleCore.split(" ").filter(Boolean);
|
||||
const artists = want.artist.split(",").map(norm).filter(Boolean);
|
||||
|
||||
let best: { track: Track; score: number } | null = null;
|
||||
for (const track of candidates) {
|
||||
const info = track.info;
|
||||
if (!info || info.isStream) continue;
|
||||
const hay = norm(`${info.title} ${info.author}`);
|
||||
const titleHit = hay.includes(titleCore) ? 3 : coreTokens.every((t) => hay.includes(t)) ? 2 : 0;
|
||||
const artistHit = artists.some((a) => hay.includes(a)) ? 1 : 0;
|
||||
const junk = (info.title.match(NOT_ORIGINAL_RE) ?? []).some((w) => !wantTitle.includes(norm(w)));
|
||||
const diffSec = want.durationMs > 0 ? Math.abs(info.length - want.durationMs) / 1000 : -1;
|
||||
const durScore = diffSec < 0 ? 0 : diffSec <= 2 ? 3 : diffSec <= 5 ? 2 : diffSec <= 12 ? 1 : diffSec <= 40 ? 0 : -3;
|
||||
const audioBonus = /\baudio\b|오디오/i.test(info.title) || / - topic$/i.test(info.author) ? 1 : 0;
|
||||
|
||||
// 제목이 안 맞으면 길이(±2초)와 아티스트가 모두 맞는 경우만 같은 곡으로 인정
|
||||
if (titleHit === 0 && !(diffSec >= 0 && diffSec <= 2 && artistHit)) continue;
|
||||
const score = titleHit + artistHit + durScore + audioBonus - (junk ? 4 : 0);
|
||||
if (!best || score > best.score) best = { track, score };
|
||||
}
|
||||
return best && best.score > 0 ? best.track : null;
|
||||
}
|
||||
21
docker-compose.exp.yml
Normal file
21
docker-compose.exp.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
music-exp:
|
||||
build: ./bot
|
||||
container_name: music-exp
|
||||
restart: "no"
|
||||
network_mode: host
|
||||
env_file:
|
||||
- ./bot/.env
|
||||
environment:
|
||||
- REDIS=false
|
||||
- LAVALINK_HOST=192.168.10.6
|
||||
- LAVALINK_PORT=2333
|
||||
- SELFTEST_GUILD_ID=1352269198297923648
|
||||
- SELFTEST_VOICE_CHANNEL_ID=1352269198914621465
|
||||
- SELFTEST_TEXT_CHANNEL_ID=1421342055774289980
|
||||
- SELFTEST_QUERY=aespa Supernova
|
||||
- SELFTEST_WATCH_SEC=15
|
||||
- YTRESOLVER_ENABLED=true
|
||||
- YTRESOLVER_URL=http://192.168.10.5:8779
|
||||
volumes:
|
||||
- ./testdb:/db
|
||||
@@ -50,7 +50,7 @@ site: 그 키를 짧게 폴링(100ms→최대400ms 백오프, 기본 3초 타임
|
||||
```
|
||||
- `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험.
|
||||
- action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
|
||||
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`, `queue_shuffle`, `player_recommend`.
|
||||
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
|
||||
|
||||
### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream`
|
||||
```
|
||||
@@ -66,9 +66,8 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
||||
|
||||
모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다.
|
||||
|
||||
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `autoplay`(자동재생 토글), `events`(SSE)
|
||||
- **queue**: `list`, `set`, `remove`, `shuffle`(다음 트랙 섞기), `events`(SSE)
|
||||
- `play`/`playlist`는 음성채널 접속 + 곡·재생목록 해석까지 기다려야 해서 응답 대기 시간을 15초로 둔다.
|
||||
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `events`(SSE)
|
||||
- **queue**: `list`, `set`, `remove`, `events`(SSE)
|
||||
- **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링)
|
||||
- **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과)
|
||||
- **auth**: `[...nextauth]`(Discord OAuth)
|
||||
@@ -79,67 +78,11 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
||||
|
||||
## 5. UI 구성 (`src/`)
|
||||
|
||||
디자인은 **유튜브 뮤직(웹) 다크 테마**를 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`,
|
||||
보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`).
|
||||
|
||||
### 5-1. 페이지(주소)
|
||||
|
||||
| 주소 | 내용 | 접근 규칙 |
|
||||
|------|------|-----------|
|
||||
| `/` | 바로 이동만 한다 | 로그인 안 했으면 `/login`, 했으면 `/servers` |
|
||||
| `/login` | 로그인 전용 페이지(Discord 로그인 버튼 + 사용 3단계 안내, 로그인 오류 표시) | 이미 로그인했으면 `callbackUrl`(없으면 `/servers`)로 이동 |
|
||||
| `/servers` | 서버 선택 전용 페이지(봇이 있는 내 서버, 최근 사용 서버를 맨 앞에 "최근" 표시, 새로고침, 8개 넘으면 이름 검색) | 로그인 필수 |
|
||||
| `/servers/[serverId]` | 유튜브 뮤직 형태의 봇 플레이어. 검색하면 `?q=검색어`가 붙어 뒤로가기로 홈↔검색을 오간다 | 로그인 필수. 내 서버 목록에 없는 서버면 "열 수 없어요" 안내 후 서버 선택으로 |
|
||||
|
||||
- 로그인 검사는 서버 컴포넌트에서 한다(`lib/auth.ts`의 `requirePageSession`). 돌아올 주소(`callbackUrl`)는 같은 사이트 경로만 허용한다.
|
||||
- NextAuth 로그인/오류 화면도 `/login`으로 돌린다(`authOptions.pages`).
|
||||
- 쓰다가 세션이 끊기면 클라이언트에서 `/login`으로 보낸다.
|
||||
|
||||
### 5-2. 파일 구성
|
||||
|
||||
- `app/login`, `app/servers`, `app/servers/[serverId]` — 위 페이지(서버 컴포넌트, 로그인 검사 후 화면 컴포넌트 렌더).
|
||||
- `components/pages/` — `LoginView`, `ServerPicker`, `PlayerApp`(플레이어 전체 배치·검색 주소 관리·탭 제목).
|
||||
- `components/layout/` — `TopNav`(☰·로고·검색·최근 검색어), `LeftSidebar`(홈/검색/서버 변경 + 조작 중인 서버·음성채널),
|
||||
`AccountMenu`(프로필 → 서버 변경·로그아웃만).
|
||||
- `components/player/` — `MainContent`(서버 홈·검색 결과·주소 불러오기), `PlayerBar`(하단 재생바), `PlayerPage`(펼친 재생 화면),
|
||||
`QueueList`(다음 트랙 + 섞기/모두 지우기/자동재생).
|
||||
- `components/ui.tsx` — 로고, 썸네일, 서버 아이콘, 로딩 표시, 재생 중 이퀄라이저. `ToastProvider.tsx` — 하단 스낵바.
|
||||
- `hooks/` — `usePlayer`(재생 상태·조작·단축키·자동재생), `useQueue`(대기열·섞기·지우기), `useServers`(서버 목록·최근 서버).
|
||||
재생 상태·대기열은 `PlayerApp`에서 한 번만 구독해 화면 여러 곳에 내려준다.
|
||||
- `lib/` — `auth.ts`(페이지 로그인 검사), `url.ts`(음악 주소 판별), `recent.ts`(최근 검색어), `api.ts`(봇 RPC).
|
||||
|
||||
### 5-3. 플레이어 화면 구성
|
||||
|
||||
| 화면 | 진입 | 보이는 것 |
|
||||
|------|------|-----------|
|
||||
| 서버 홈 | 서버 선택 직후, 로고·"홈" | 왼쪽: 서버 아이콘·이름·권한·봇이 있는 음성채널, 노래 검색 버튼, (재생 전) 사용 안내, 최근 검색 칩 / 오른쪽: 재생 목록(자동재생 스위치, 지금 곡, 다음 트랙) |
|
||||
| 검색 결과 | 검색창 Enter, 최근 검색어 | 필터 칩(전체/노래/동영상/Spotify, 개수 표시), 상위 검색결과, 구역별 목록(5곡 + "모두 표시"), 실패 시 "다시 시도" |
|
||||
| 주소 불러오기 | 유튜브·유튜브 뮤직·스포티파이 주소를 검색창에 붙여넣고 Enter | 곡/재생목록·앨범 구분, "대기열에 추가" → `POST /api/player/playlist` |
|
||||
| 재생 화면 | 재생바 곡 정보·⌃, 서버 홈의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + 다음 트랙 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 |
|
||||
|
||||
- 재생바는 재생 중인 곡이 있을 때만 보인다. 브라우저 탭 제목에 지금 곡(▶/⏸)이 표시된다.
|
||||
- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침/접힘, 좁은 화면은 ☰ 로 서랍.
|
||||
|
||||
### 5-4. 버튼·조작 동작 정리
|
||||
|
||||
| 위치 | 요소 | 동작 |
|
||||
|------|------|------|
|
||||
| 상단 | 검색창 | Enter 로 검색(주소면 불러오기 화면). 누르면 최근 검색어 8개(✕로 개별 삭제) |
|
||||
| 상단 | 프로필 | 서버 변경 · 로그아웃 |
|
||||
| 왼쪽 메뉴 | 홈 / 검색 / 서버 변경 | 서버 홈 / 검색창 포커스 / `/servers` |
|
||||
| 검색 | 곡 행·상위 검색결과 "재생" | `POST /api/player/play`. 요청 중에는 스피너를 띄우고 다른 곡 추가를 막아 중복 추가를 방지 |
|
||||
| 재생바 | ⏮ | 지금 곡을 처음부터 다시(유튜브 뮤직 동작. 봇에 이전 곡 기록이 없다) |
|
||||
| 재생바 | ⏯ / ⏭ / 진행바 | 일시정지·재개 / 다음 곡 / 탐색 |
|
||||
| 재생바 | 스피커 | 클릭 = 음소거/해제, hover 시 볼륨 슬라이더 |
|
||||
| 재생바 | 섞기 | 다음 트랙 섞기(2곡 이상일 때) |
|
||||
| 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) |
|
||||
| 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) |
|
||||
| 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 |
|
||||
| 다음 트랙 | (목록 길이) | 앞 200곡만 표시하고 곡 수·총 길이는 전체 기준. 나머지는 "외 N곡"으로 표시(스포티파이 재생목록은 최대 5만 곡) |
|
||||
| 재생 화면 | ⌄ / Esc | 닫기 |
|
||||
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 |
|
||||
|
||||
미지원(봇 기능 없음): 이전 곡으로 돌아가기, 반복 재생, 좋아요/가사. 동작하지 않는 버튼이 되므로 넣지 않았다.
|
||||
- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지. `components/Providers.tsx`(세션 등), `ToastProvider.tsx`.
|
||||
- `components/layout/` — `TopNav`(상단/서버선택·로그인), `LeftSidebar`(탐색).
|
||||
- `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'` 등).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,11 +32,6 @@ export const authOptions: NextAuthOptions = {
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
// 로그인/로그인 오류는 NextAuth 기본 화면 대신 우리 로그인 페이지로 보낸다.
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||
async jwt({ token, account, profile }) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,6 @@ export async function POST(request: Request) {
|
||||
userId,
|
||||
track,
|
||||
},
|
||||
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
|
||||
@@ -30,8 +30,6 @@ export async function POST(request: Request) {
|
||||
userId,
|
||||
playlistUrl: urlResult.value,
|
||||
},
|
||||
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,10 +5,6 @@ import { botRpc, errorResponse, readJsonBody, requireSession, requireString } fr
|
||||
interface QueueSetBody {
|
||||
serverId?: unknown;
|
||||
newQueue?: unknown;
|
||||
/** 화면에 있던 대기열 앞부분 곡 수(봇은 이 부분만 newQueue로 바꾸고 뒤는 남긴다) */
|
||||
windowSize?: unknown;
|
||||
/** 모두 지우기(뒤에 남은 곡과 불러오는 중인 재생목록까지 비움) */
|
||||
clearAll?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -33,8 +29,6 @@ export async function POST(request: Request) {
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
newQueue,
|
||||
windowSize: typeof bodyResult.data.windowSize === "number" ? bodyResult.data.windowSize : undefined,
|
||||
clearAll: bodyResult.data.clearAll === true,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* 유튜브 뮤직(웹) 다크 테마 색상.
|
||||
* - 배경 #030303, 재생바/메뉴 #212121, 강조 빨강 #ff0000
|
||||
* - 보조 글자는 흰색 70%/50% 투명도(유튜브 뮤직과 같은 방식)
|
||||
*/
|
||||
/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */
|
||||
@theme {
|
||||
--color-background: #030303;
|
||||
--color-background: #171717; /* neutral-900 */
|
||||
--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 {
|
||||
@@ -27,54 +12,19 @@ body {
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 유튜브 뮤직처럼 얇고 어두운 스크롤바 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(255 255 255 / 0.2) transparent;
|
||||
}
|
||||
/* 스크롤바 예쁘게 숨기기 (선택 사항) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
background: #3f3f46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
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); }
|
||||
background: #52525b;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 233 B |
@@ -1,17 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,8 +1,67 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPageSession } from "@/lib/auth";
|
||||
"use client";
|
||||
|
||||
/** 첫 화면: 로그인 안 했으면 로그인 페이지, 했으면 서버 선택 페이지로 보낸다. */
|
||||
export default async function Home() {
|
||||
const session = await getPageSession();
|
||||
redirect(session?.user?.id ? "/servers" : "/login");
|
||||
import { useState } from "react";
|
||||
import TopNav from "@/components/layout/TopNav";
|
||||
import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||
import MainContent from "@/components/player/MainContent";
|
||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
||||
import PlayerBar from "@/components/player/PlayerBar";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
// 화면 모드 타입 정의
|
||||
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||
|
||||
export default function MusicPlayerLayout() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
||||
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");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-neutral-900 text-white overflow-hidden font-sans">
|
||||
<TopNav
|
||||
onSearch={handleSearch}
|
||||
onHome={handleHome}
|
||||
selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<LeftSidebar />
|
||||
<MainContent
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
selectedServer={selectedServer}
|
||||
setSelectedServer={setSelectedServer}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onSelectServer={handleSelectServer}
|
||||
/>
|
||||
<QueueSidebar selectedServer={selectedServer} />
|
||||
</div>
|
||||
|
||||
<PlayerBar selectedServer={selectedServer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 : ""} />;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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 />;
|
||||
}
|
||||
@@ -29,13 +29,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
|
||||
{/* 유튜브 뮤직 스낵바: 왼쪽 아래(재생바 위)에 뜨는 어두운 알림 */}
|
||||
{/* 화면 최상단에 떠있는 실제 토스트 UI */}
|
||||
{toast && (
|
||||
<div
|
||||
role={toast.type === "error" ? "alert" : "status"}
|
||||
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"
|
||||
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 ${
|
||||
toast.type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{toast.type === "error" && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-yt-red" aria-hidden />}
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +1,20 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeftRight, Home, Menu, Search, Volume2 } from "lucide-react";
|
||||
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 {
|
||||
/** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */
|
||||
expanded: boolean;
|
||||
/** 좁은 화면에서 서랍처럼 열린 상태 */
|
||||
drawerOpen: boolean;
|
||||
onCloseDrawer: () => void;
|
||||
viewMode: ViewMode;
|
||||
server: DiscordServer | null;
|
||||
/** 봇이 들어가 있는 음성채널 이름(재생 중일 때) */
|
||||
voiceChannelName: string | null;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 뮤직 왼쪽 메뉴(가이드): 홈 / 검색 / 서버 변경 + 아래에 지금 조작 중인 서버.
|
||||
* 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다.
|
||||
*/
|
||||
export default function LeftSidebar({
|
||||
expanded,
|
||||
drawerOpen,
|
||||
onCloseDrawer,
|
||||
viewMode,
|
||||
server,
|
||||
voiceChannelName,
|
||||
onHome,
|
||||
}: LeftSidebarProps) {
|
||||
const focusSearch = () => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT));
|
||||
|
||||
type Item = { key: string; label: string; icon: typeof Home; active?: boolean } & (
|
||||
| { href: string; onClick?: never }
|
||||
| { onClick: () => void; href?: never }
|
||||
);
|
||||
const nav: Item[] = [
|
||||
{ key: "home", label: "홈", icon: Home, active: viewMode === "HOME", onClick: onHome },
|
||||
{ key: "search", label: "검색", icon: Search, active: viewMode === "SEARCH", onClick: focusSearch },
|
||||
{ key: "switch", label: "서버 변경", icon: ArrowLeftRight, href: "/servers" },
|
||||
];
|
||||
|
||||
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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
afterClick?.();
|
||||
item.onClick?.();
|
||||
}}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
className={cls}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */
|
||||
const full = (afterClick?: () => void) => (
|
||||
<div className="flex h-full flex-col">
|
||||
<ul className="flex flex-col gap-1 px-2 pt-2">
|
||||
{nav.map((item) => (
|
||||
<li key={item.key}>{renderItem(item, itemClass(item.active), afterClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{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>
|
||||
);
|
||||
import { ListMusic } from "lucide-react";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
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>
|
||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 font-medium">
|
||||
<ListMusic size={20} />
|
||||
<span>음악 봇 컨트롤</span>
|
||||
</div>
|
||||
|
||||
{/* 좁은 화면: 서랍 메뉴 */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
<hr className="border-neutral-800" />
|
||||
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
|
||||
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,224 +1,83 @@
|
||||
"use client";
|
||||
import { ArrowLeft, Clock, Link2, Menu, Search, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Logo } from "@/components/ui";
|
||||
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";
|
||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
interface TopNavProps {
|
||||
/** 지금 화면에 걸린 검색어(뒤로가기 등으로 바뀌면 입력칸도 맞춘다) */
|
||||
query: string;
|
||||
onSearch: (query: string) => void;
|
||||
onHome: () => void;
|
||||
onToggleGuide: () => void;
|
||||
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
|
||||
* - 검색창을 누르면 최근 검색어가 펼쳐진다(유튜브 뮤직과 같은 방식).
|
||||
* - 좁은 화면은 돋보기 버튼 → 바 전체가 검색창으로 바뀐다.
|
||||
*/
|
||||
export default function TopNav({ query, onSearch, onHome, onToggleGuide }: TopNavProps) {
|
||||
const [term, setTerm] = useState(query);
|
||||
const [mobileSearch, setMobileSearch] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [term, setTerm] = useState("");
|
||||
|
||||
// 화면의 검색어가 바뀌면(뒤로가기·홈) 입력칸도 맞춘다.
|
||||
useEffect(() => {
|
||||
|
||||
setTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && term.trim()) {
|
||||
onSearch(term);
|
||||
}
|
||||
onSearch(q);
|
||||
setFocused(false);
|
||||
inputRef.current?.blur();
|
||||
setMobileSearch(false);
|
||||
};
|
||||
|
||||
const removeRecent = (r: string) => {
|
||||
const next = recent.filter((x) => x !== r);
|
||||
setRecent(next);
|
||||
writeRecent(next);
|
||||
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
|
||||
const handleHomeClick = () => {
|
||||
setTerm(""); // 검색창 비우기
|
||||
onHome(); // 원래 홈 기능 실행
|
||||
};
|
||||
|
||||
const showDropdown = focused && (recent.length > 0 || isMusicUrl(term));
|
||||
|
||||
return (
|
||||
<header className="z-40 flex-shrink-0 border-b border-yt-line bg-yt-bg">
|
||||
<nav className="relative flex h-16 items-center gap-2 px-2 sm:px-4">
|
||||
{/* 좁은 화면 검색 모드: 뒤로 + 검색창이 바 전체를 차지한다 */}
|
||||
{mobileSearch && (
|
||||
<div className="absolute inset-0 z-10 flex items-center gap-2 bg-yt-bg px-2 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMobileSearch(false);
|
||||
setFocused(false);
|
||||
}}
|
||||
aria-label="검색 닫기"
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white hover:bg-yt-chip"
|
||||
>
|
||||
<ArrowLeft size={22} aria-hidden />
|
||||
<nav className="h-16 flex-shrink-0 bg-black border-b border-neutral-800 flex items-center justify-between px-6 z-20">
|
||||
{/* 1. 왼쪽: 로고 */}
|
||||
<div className="flex items-center gap-2 text-xl font-bold text-white w-52">
|
||||
<ListMusic className="text-green-500" />
|
||||
<span>MusicBot</span>
|
||||
</div>
|
||||
|
||||
{/* 2. 가운데: 검색창 & 홈 버튼 */}
|
||||
<div className="flex-1 max-w-2xl px-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleHomeClick} // 🌟 수정된 핸들러 연결
|
||||
className="p-2 hover:bg-neutral-800 rounded-full transition-colors cursor-pointer text-neutral-400 hover:text-white"
|
||||
title="홈으로"
|
||||
>
|
||||
<Home size={22} />
|
||||
</button>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!selectedServer} // 🌟 서버 미선택 시 입력창 비활성화 (선택 사항)
|
||||
placeholder={selectedServer ? "노래 검색 후 엔터를 누르세요" : "서버를 먼저 선택해주세요"}
|
||||
className={`w-full h-10 pl-10 pr-4 rounded-full bg-neutral-800 border border-neutral-700 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/30 transition-all ${!selectedServer ? 'opacity-50 cursor-not-allowed' : 'opacity-100'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 오른쪽: 유저 프로필 영역 (기존과 동일) */}
|
||||
<div className="flex items-center justify-end w-auto min-w-[13rem]">
|
||||
{status === "loading" ? (
|
||||
<div className="text-sm text-neutral-400">로딩 중...</div>
|
||||
) : status === "authenticated" && session?.user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-neutral-800/80 pr-4 pl-1 py-1 rounded-full border border-neutral-700 shadow-sm cursor-default">
|
||||
<img src={session.user.image || ""} alt="Profile" className="w-7 h-7 rounded-full" />
|
||||
<span className="text-sm font-medium text-white whitespace-nowrap">{session.user.name}</span>
|
||||
</div>
|
||||
<button onClick={() => signOut()} className="cursor-pointer text-neutral-400 hover:text-red-400 p-2 rounded-full hover:bg-neutral-800 transition-colors">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => signIn("discord")} className="cursor-pointer flex items-center gap-2 bg-[#5865F2] hover:bg-[#4752C4] text-white px-5 py-2 rounded-full font-medium transition-colors text-sm shadow-md">
|
||||
<LogIn size={16} /> Discord 로그인
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 왼쪽: 메뉴 + 로고 (넓은 화면에서는 왼쪽 메뉴 폭에 맞춘다) */}
|
||||
<div className="flex flex-shrink-0 items-center gap-1 lg:w-[232px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGuide}
|
||||
aria-label="메뉴"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-yt-chip"
|
||||
>
|
||||
<Menu size={22} aria-hidden />
|
||||
</button>
|
||||
<button type="button" onClick={onHome} aria-label="홈으로" className="rounded px-1 py-1">
|
||||
<Logo />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div
|
||||
ref={boxRef}
|
||||
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"}`}
|
||||
>
|
||||
<form
|
||||
role="search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
run(term);
|
||||
}}
|
||||
className="relative w-full max-w-[480px]"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTerm("");
|
||||
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"
|
||||
>
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 최근 검색어 / 주소 안내 */}
|
||||
{showDropdown && (
|
||||
<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) ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-11 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"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">“{filter}”과(와) 일치하는 서버가 없어요.</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>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +1,329 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Clock, Headphones, Link2, ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react";
|
||||
import type { ViewMode } from "@/components/pages/PlayerApp";
|
||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react";
|
||||
import { ViewMode } from "@/app/page";
|
||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
||||
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";
|
||||
|
||||
type SourceKey = keyof SearchResults;
|
||||
type Filter = "all" | SourceKey;
|
||||
|
||||
/** 검색 결과 구역(유튜브 뮤직 검색 화면의 "노래 / 동영상" 구성 + 스포티파이) */
|
||||
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 {
|
||||
viewMode: ViewMode;
|
||||
server: DiscordServer;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
selectedServer: DiscordServer | null;
|
||||
setSelectedServer: (server: DiscordServer | null) => void;
|
||||
searchQuery: string;
|
||||
onSearch: (query: string) => void;
|
||||
onOpenPlayer: () => void;
|
||||
/** 재생 화면이 열려 있으면 서버 홈의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */
|
||||
showQueue: boolean;
|
||||
player: PlayerState;
|
||||
queue: QueueState;
|
||||
setSearchQuery: (query: string) => void;
|
||||
onSelectServer: (server: DiscordServer) => void;
|
||||
}
|
||||
|
||||
export default function MainContent({
|
||||
viewMode,
|
||||
server,
|
||||
setViewMode,
|
||||
selectedServer,
|
||||
setSelectedServer,
|
||||
searchQuery,
|
||||
onSearch,
|
||||
onOpenPlayer,
|
||||
showQueue,
|
||||
player,
|
||||
queue,
|
||||
setSearchQuery,
|
||||
onSelectServer,
|
||||
}: MainContentProps) {
|
||||
const { data: session } = useSession();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResults>(EMPTY_RESULTS);
|
||||
const [servers, setServers] = useState<DiscordServer[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||
spotify: [],
|
||||
youtubeMusic: [],
|
||||
youtubeVideo: []
|
||||
});
|
||||
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 isUrlQuery = isMusicUrl(searchQuery);
|
||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
||||
if (!selectedServer) {
|
||||
// 🌟 alert -> showToast 교체
|
||||
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
showToast("로그인이 필요합니다.", "error");
|
||||
return;
|
||||
}
|
||||
if (pendingKey) return;
|
||||
setPendingKey(key);
|
||||
|
||||
let endpoint = "";
|
||||
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 {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverId: server.id, ...body }),
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
showToast(okText, "success");
|
||||
// 🌟 성공 시 초록색 알림
|
||||
showToast("재생목록에 곡을 추가했습니다!", "success");
|
||||
} else {
|
||||
// 🌟 에러 시 빨간색 알림
|
||||
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Music Action Error:", error);
|
||||
// 🌟 네트워크 에러 시 알림
|
||||
showToast('서버와 통신할 수 없습니다.', "error");
|
||||
} finally {
|
||||
setPendingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addTrack = (track: SearchTrack, key: string) =>
|
||||
addToQueue(key, { track }, "/api/player/play", `"${track.title}"을(를) 대기열에 추가했어요.`);
|
||||
|
||||
const addUrl = (url: string) =>
|
||||
addToQueue(
|
||||
"url",
|
||||
{ playlistUrl: url },
|
||||
"/api/player/playlist",
|
||||
isCollectionUrl(url) ? "재생목록을 대기열에 추가했어요. 나머지 곡은 이어서 채워져요." : "대기열에 추가했어요.",
|
||||
);
|
||||
const getPermissionLabel = (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 "👤 일반 멤버";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== "SEARCH" || !searchQuery || isMusicUrl(searchQuery)) return;
|
||||
let cancelled = false;
|
||||
// 새 검색마다 결과·탭을 초기화하는 의도적 패턴
|
||||
|
||||
setIsSearching(true);
|
||||
setSearchError(null);
|
||||
setFilter("all");
|
||||
setSearchResults(EMPTY_RESULTS);
|
||||
|
||||
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
||||
.then(async (res) => {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (res.ok && data) {
|
||||
setSearchResults({
|
||||
spotify: data.spotify || [],
|
||||
youtubeMusic: data.youtubeMusic || [],
|
||||
youtubeVideo: data.youtubeVideo || []
|
||||
});
|
||||
} else {
|
||||
setSearchError((data && (data.error || data.message)) || "검색하지 못했습니다.");
|
||||
if (status === "authenticated") {
|
||||
setIsFetching(true);
|
||||
const cached = sessionStorage.getItem("filtered_servers");
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (Array.isArray(parsed)) {
|
||||
setServers(parsed);
|
||||
setIsFetching(false);
|
||||
}
|
||||
} catch {
|
||||
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
|
||||
sessionStorage.removeItem("filtered_servers");
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setSearchError("서버와 통신할 수 없습니다."))
|
||||
.finally(() => !cancelled && setIsSearching(false));
|
||||
// 결과가 늦게 와도 이미 다른 검색어로 넘어갔으면 버린다.
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [viewMode, searchQuery, retry]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이".
|
||||
* 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다.
|
||||
*/
|
||||
const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean, sourceKey: string, index: number) => {
|
||||
const key = `${sourceKey}:${track.videoId || track.id || track.url || index}`;
|
||||
const pending = pendingKey === key;
|
||||
const meta = [kind, track.artist, track.duration ? formatTime(track.duration) : null].filter(Boolean).join(" • ");
|
||||
fetch("/api/servers")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setServers(data);
|
||||
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
|
||||
}
|
||||
})
|
||||
.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 (
|
||||
<li key={key} className="border-b border-yt-line last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addTrack(track, key)}
|
||||
disabled={!!pendingKey}
|
||||
aria-label={`${track.title} 대기열에 추가`}
|
||||
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={`relative flex-shrink-0 ${wide ? "h-14 w-[100px]" : "h-14 w-14"}`}>
|
||||
<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>
|
||||
<main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
|
||||
<Server size={48} className="text-neutral-500 mb-4" />
|
||||
<h2 className="text-2xl font-bold">로그인이 필요합니다</h2>
|
||||
<p className="text-neutral-400">서비스를 이용하시려면 디스코드 로그인을 해주세요.</p>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const counts: Record<SourceKey, number> = {
|
||||
youtubeMusic: searchResults.youtubeMusic.length,
|
||||
youtubeVideo: searchResults.youtubeVideo.length,
|
||||
spotify: searchResults.spotify.length,
|
||||
};
|
||||
const hasAnyResults = counts.youtubeMusic + counts.youtubeVideo + counts.spotify > 0;
|
||||
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
|
||||
|
||||
// 상위 검색결과: 유튜브 뮤직 → 스포티파이 → 영상 순으로 첫 곡
|
||||
const topKey = (["youtubeMusic", "spotify", "youtubeVideo"] as const).find((k) => counts[k] > 0);
|
||||
const topSource = SOURCES.find((s) => s.key === topKey);
|
||||
const topTrack = topSource ? searchResults[topSource.key][0] : undefined;
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
<div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMusicAction('player_play', track);
|
||||
}}
|
||||
className="absolute right-2 bottom-2 cursor-pointer bg-green-500 hover:bg-green-400 rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl"
|
||||
title="바로 재생"
|
||||
>
|
||||
<Play fill="black" stroke="black" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="font-medium text-white text-sm truncate">{track.title}</h3>
|
||||
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
|
||||
|
||||
{/* 서버 홈 — 유튜브 뮤직 앨범 페이지 형태(왼쪽 서버 정보, 오른쪽 재생 목록) */}
|
||||
{viewMode === "HOME" && (
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:gap-12">
|
||||
<section className="flex flex-col items-center text-center lg:sticky lg:top-6 lg:w-[320px] lg:flex-shrink-0">
|
||||
<ServerAvatar
|
||||
server={server}
|
||||
rounded="rounded-lg"
|
||||
className="h-32 w-32 shadow-2xl sm:h-60 sm:w-60"
|
||||
iconSize={64}
|
||||
size={512}
|
||||
/>
|
||||
<h1 className="mt-6 break-words text-[28px] font-bold leading-tight sm:text-[32px]">{server.name || "서버"}</h1>
|
||||
<p className="mt-2 text-sm text-white/70">디스코드 서버 • 내 권한: {permissionLabel(server)}</p>
|
||||
<p className="mt-1 flex items-center justify-center gap-1.5 text-sm text-white/60">
|
||||
<Headphones size={14} aria-hidden />
|
||||
{player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "봇이 아직 음성 채널에 없어요"}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT))}
|
||||
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"
|
||||
{/* 화면 1: 서버 목록 */}
|
||||
{viewMode === "SERVER_LIST" && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
|
||||
{isFetching && servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
||||
<p>서버 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => onSelectServer(server)}
|
||||
className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md w-full"
|
||||
>
|
||||
<Search size={18} aria-hidden />
|
||||
노래 검색
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPlayer}
|
||||
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"
|
||||
>
|
||||
<ListMusic size={20} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 처음 쓰는 사람을 위한 안내: 아무것도 재생 중이 아닐 때만 */}
|
||||
{!player.track && (
|
||||
<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">
|
||||
<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 className="aspect-square bg-neutral-700 rounded-full mb-2 overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{server.icon ? (
|
||||
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<Server size={24} className="text-neutral-500" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
|
||||
</div>
|
||||
))}
|
||||
</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="flex items-end gap-6 mb-8">
|
||||
<div className="w-48 h-48 bg-neutral-800 rounded-2xl shadow-2xl flex items-center justify-center overflow-hidden">
|
||||
{selectedServer.icon ? (
|
||||
<img src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} className="w-full h-full object-cover" alt="" />
|
||||
) : <Server size={64} className="text-neutral-500" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold uppercase text-neutral-400 tracking-wider">서버 대시보드</span>
|
||||
<h1 className="text-5xl font-black mt-2 mb-4">{selectedServer.name}</h1>
|
||||
<p className="text-neutral-400">이 서버에서 음악 봇이 활발하게 작동 중입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/20 rounded-xl p-6 border border-neutral-800">
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Music size={20} /> 상세 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
|
||||
<p className="text-neutral-500 mb-1">서버 고유 ID</p>
|
||||
<p className="font-mono text-white">{selectedServer.id}</p>
|
||||
</div>
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
|
||||
<p className="text-neutral-500 mb-1">나의 서버 권한</p>
|
||||
<p className="text-green-400 font-bold">{getPermissionLabel(selectedServer)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
||||
{viewMode === "SEARCH_RESULT" && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">“{searchQuery}” 검색 결과</h2>
|
||||
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
||||
<p>노래를 찾는 중입니다...</p>
|
||||
</div>
|
||||
) : !hasAnyResults ? (
|
||||
<div className="flex flex-col items-center py-20 text-neutral-500">
|
||||
<SearchX size={48} className="mb-4" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-10 pb-10">
|
||||
|
||||
{/* 카테고리 1: Spotify */}
|
||||
{searchResults.spotify.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
|
||||
<Disc size={24} /> Spotify 결과
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{searchResults.spotify.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 flex-1">
|
||||
{showQueue && <QueueList player={player} queue={queue} variant="page" />}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{/* 카테고리 2: YouTube Music */}
|
||||
{searchResults.youtubeMusic.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
|
||||
<Music size={24} /> YouTube Music
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{searchResults.youtubeMusic.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 카테고리 3: YouTube Video */}
|
||||
{searchResults.youtubeVideo.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
|
||||
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{searchResults.youtubeVideo.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 주소를 붙여넣은 경우: 검색 대신 바로 불러오기 카드 */}
|
||||
{viewMode === "SEARCH" && isUrlQuery && (
|
||||
<section className="mx-auto max-w-2xl">
|
||||
<h2 className="mb-4 text-2xl font-bold">주소에서 불러오기</h2>
|
||||
<div className="rounded-lg bg-white/[0.08] p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
|
||||
<Link2 size={22} aria-hidden />
|
||||
</span>
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */}
|
||||
{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>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="flex flex-col items-center gap-4 py-24 text-white/70">
|
||||
<Spinner label="노래를 찾는 중" />
|
||||
<p className="text-sm">“{searchQuery}” 검색 중...</p>
|
||||
</div>
|
||||
) : searchError ? (
|
||||
<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">{searchError}</p>
|
||||
<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">“{searchQuery}”에 대한 검색 결과가 없습니다.</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>
|
||||
)}
|
||||
|
||||
{SOURCES.filter((s) => (filter === "all" || filter === s.key) && counts[s.key] > 0).map((s) => {
|
||||
const list = searchResults[s.key];
|
||||
const shown = filter === "all" ? list.slice(0, PREVIEW_COUNT) : list;
|
||||
return (
|
||||
<section key={s.key}>
|
||||
<div className="mb-2 flex items-end justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold">{s.title}</h2>
|
||||
{filter === "all" && list.length > PREVIEW_COUNT && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
모두 표시
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul>{shown.map((t, i) => renderTrackRow(t, s.kind, s.wide, s.key, i))}</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +1,386 @@
|
||||
"use client";
|
||||
import { ChevronDown, ChevronUp, Pause, Play, Shuffle, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
|
||||
import { Thumb } from "@/components/ui";
|
||||
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface PlayerBarProps {
|
||||
player: PlayerState;
|
||||
onShuffle: () => void;
|
||||
canShuffle: boolean;
|
||||
isPlayerOpen: boolean;
|
||||
onTogglePlayer: () => void;
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
/** 진행바(유튜브 뮤직: 재생바 위쪽 가장자리의 얇은 빨간 선, 마우스를 올리면 굵어지고 손잡이가 보인다) */
|
||||
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;
|
||||
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// 재생 상태 관리
|
||||
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 [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 [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 && 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); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
// 시간 포맷 변환 함수 (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;
|
||||
|
||||
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}%` }} />
|
||||
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
||||
|
||||
{/* 1. 노래 정보 영역 */}
|
||||
<div className="flex items-center gap-4 w-1/4">
|
||||
{track ? (
|
||||
<>
|
||||
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md overflow-hidden">
|
||||
{track.info?.artworkUrl && <img src={track.info.artworkUrl} alt="cover" className="w-full h-full object-cover" />}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-sm font-bold text-white truncate cursor-pointer hover:underline">{track.info?.title}</span>
|
||||
<span className="text-xs text-neutral-400 truncate cursor-pointer hover:underline">{track.info?.author?.replace(" - Topic", "")}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// 재생 중인 곡이 없을 때 빈 공간
|
||||
<div className="flex items-center gap-4 text-neutral-600">
|
||||
<div className="w-14 h-14 bg-neutral-900 rounded-md"></div>
|
||||
<span className="text-sm">재생 중인 곡 없음</span>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 뮤직 하단 재생바.
|
||||
* 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃]
|
||||
* 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다.
|
||||
*/
|
||||
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 artist = cleanArtist(track?.info?.author);
|
||||
const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
|
||||
|
||||
const playButton = (size: number, className: string) => (
|
||||
<button
|
||||
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 nextButton = (className: string) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={skip}
|
||||
disabled={!canControl}
|
||||
aria-label="다음 곡"
|
||||
title="다음 곡 (Ctrl+→)"
|
||||
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}`}
|
||||
>
|
||||
<SkipForward size={24} fill="currentColor" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<footer className="relative z-50 flex-shrink-0 bg-yt-raised">
|
||||
{/* ===== 넓은 화면 ===== */}
|
||||
<SeekBar player={player} className="absolute inset-x-0 -top-2 hidden md:flex" />
|
||||
<div className="hidden h-[72px] items-center gap-4 px-4 md:flex">
|
||||
{/* 1. 재생 조작 + 시간 */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2 lg:w-[292px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={restart}
|
||||
disabled={!canControl}
|
||||
title="처음부터 다시 재생"
|
||||
aria-label="처음부터 다시 재생"
|
||||
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"
|
||||
>
|
||||
<SkipBack size={24} fill="currentColor" aria-hidden />
|
||||
{/* 2. 중앙 컨트롤러 영역 */}
|
||||
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* 이전 곡 (비활성화 상태) */}
|
||||
<button className="text-neutral-600 cursor-not-allowed">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* 재생/일시정지 버튼 */}
|
||||
<button
|
||||
onClick={handleTogglePause}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform text-black
|
||||
${track ? 'bg-white hover:scale-105' : 'bg-neutral-700 cursor-not-allowed'}`}
|
||||
>
|
||||
{!isPaused ? <Pause size={18} fill="black" /> : <Play size={18} fill="black" className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
{/* 다음 곡 스킵 버튼 */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
className={`transition-colors ${isPlaying && track ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 cursor-not-allowed'}`}
|
||||
>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
{playButton(30, "h-12 w-12")}
|
||||
{nextButton("h-10 w-10")}
|
||||
<span className="ml-2 hidden whitespace-nowrap text-xs tabular-nums text-white/70 lg:inline">
|
||||
{formatTime(position)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2. 현재 곡(누르면 재생 화면) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlayer}
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-4 text-left"
|
||||
aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
|
||||
>
|
||||
<Thumb src={track?.info?.artworkUrl} className="h-10 w-10" />
|
||||
<span className="min-w-0 max-w-[420px] flex-1">
|
||||
<span className="block truncate text-base text-white">{title}</span>
|
||||
{artist && <span className="block truncate text-base text-white/70">{artist}</span>}
|
||||
</span>
|
||||
</button>
|
||||
{/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
|
||||
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
||||
<span className="w-10 text-right">{formatTime(position)}</span>
|
||||
|
||||
{/* 3. 볼륨 + 재생 화면 열기 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-end gap-1 lg:w-[292px]">
|
||||
{/* 볼륨: 유튜브 뮤직처럼 마우스를 올리면 슬라이더가 옆으로 펼쳐진다 */}
|
||||
<div className="group flex items-center">
|
||||
<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">
|
||||
<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}
|
||||
onKeyDown={(e) => { if (!e.repeat) beginVolumeDrag(); }}
|
||||
onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
|
||||
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 inset-x-2 h-1 rounded-full bg-white/30">
|
||||
<div className="h-full rounded-full bg-white" style={{ width: `${volume}%` }} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center group relative h-4">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={position}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={(e) => {
|
||||
isDragging.current = true;
|
||||
setPosition(Number(e.target.value)); // 드래그 중 화면 즉시 업데이트
|
||||
}}
|
||||
onMouseUp={handleSeekEnd}
|
||||
onTouchEnd={handleSeekEnd}
|
||||
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||
// ↑ 투명한 진짜 input을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다.
|
||||
/>
|
||||
{/* 시각적으로 보여주는 커스텀 바 */}
|
||||
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-none absolute -ml-1.5 h-3 w-3 rounded-full bg-white"
|
||||
style={{ left: `calc(0.5rem + (100% - 1rem) * ${volume / 100})` }}
|
||||
/>
|
||||
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{isVolumeDragging && (
|
||||
<span className="pointer-events-none absolute bottom-[76px] right-16 rounded bg-yt-menu px-2 py-1 text-xs text-white shadow-lg">
|
||||
{volume}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
disabled={!canControl}
|
||||
aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
||||
title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={24} aria-hidden /> : <Volume2 size={24} aria-hidden />}
|
||||
</button>
|
||||
{/* 동그란 핸들바 */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5"
|
||||
style={{ left: `${progressPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShuffle}
|
||||
disabled={!canShuffle}
|
||||
aria-label="다음 트랙 섞기"
|
||||
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"
|
||||
>
|
||||
<Shuffle size={22} aria-hidden />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlayer}
|
||||
aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
|
||||
title={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10"
|
||||
>
|
||||
{isPlayerOpen ? <ChevronDown size={24} aria-hidden /> : <ChevronUp size={24} aria-hidden />}
|
||||
</button>
|
||||
<span className="w-10">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 좁은 화면: 미니 플레이어 ===== */}
|
||||
<div className="md:hidden">
|
||||
<div className="h-0.5 bg-white/20" aria-hidden>
|
||||
<div className="h-full bg-yt-red" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className="flex h-16 items-center gap-2 pl-2 pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlayer}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
aria-label="재생 화면 열기"
|
||||
{/* 3. 우측 볼륨 영역 */}
|
||||
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
||||
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
|
||||
<div className="w-24 h-4 flex items-center group relative">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
||||
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
|
||||
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
|
||||
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||
/>
|
||||
{/* 커스텀 볼륨 바 디자인 */}
|
||||
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
||||
<div
|
||||
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
||||
style={{ width: `${volume}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5 flex justify-center"
|
||||
style={{ left: `${volume}%` }}
|
||||
>
|
||||
<Thumb src={track?.info?.artworkUrl} className="h-11 w-11" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white">{title}</span>
|
||||
{artist && <span className="block truncate text-sm text-white/70">{artist}</span>}
|
||||
</span>
|
||||
</button>
|
||||
{playButton(26, "h-12 w-12")}
|
||||
{nextButton("h-12 w-12")}
|
||||
{/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */}
|
||||
{isVolumeDragging && (
|
||||
<div className="absolute bottom-4 bg-neutral-800 text-white text-[10px] font-bold py-1 px-2 rounded shadow-lg border border-neutral-700 whitespace-nowrap animate-in fade-in zoom-in duration-150">
|
||||
{volume}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
266
page/src/components/player/QueueSidebar.tsx
Normal file
266
page/src/components/player/QueueSidebar.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Trash2, GripVertical, Music } 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;
|
||||
}
|
||||
|
||||
export default function QueueSidebar({ selectedServer }: 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 handleSort = async () => {
|
||||
const fromIndex = dragItem.current;
|
||||
const toIndex = dragOverItem.current;
|
||||
|
||||
// 1. 마우스를 떼는 즉시 UI 애니메이션을 원래대로 되돌립니다.
|
||||
setDraggingIndex(null);
|
||||
setDragOverIndex(null);
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
|
||||
// 드래그 위치가 없거나 서버가 없으면 종료
|
||||
if (fromIndex === null || toIndex === null || !selectedServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 실제 노래가 삽입될 최종 인덱스 계산
|
||||
let insertIndex = toIndex;
|
||||
if (fromIndex < toIndex && toIndex !== queue.length) {
|
||||
insertIndex = toIndex - 1;
|
||||
}
|
||||
|
||||
// 🌟 [신규 추가] 결과적으로 순서가 바뀌지 않았다면 여기서 바로 함수를 종료합니다! (API 요청 안 함)
|
||||
if (fromIndex === insertIndex) {
|
||||
console.log("순서가 변경되지 않아서 통신을 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// 3. 화면 즉시 업데이트
|
||||
const newQueue = [...queue];
|
||||
const draggedItem = newQueue.splice(fromIndex, 1)[0];
|
||||
newQueue.splice(insertIndex, 0, draggedItem);
|
||||
setQueue(newQueue);
|
||||
|
||||
// 4. 서버(API)에 변경된 배열 전송
|
||||
try {
|
||||
const res = await fetch('/api/queue/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
newQueue: newQueue
|
||||
})
|
||||
});
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
|
||||
<h2 className="text-lg font-bold mb-6">현재 재생 목록</h2>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-neutral-500 h-full gap-2">
|
||||
<Music size={32} className="opacity-50" />
|
||||
<p className="text-sm">대기열에 노래가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col overflow-y-auto pr-2 pb-10">
|
||||
{queue.map((item, index) => {
|
||||
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragItem.current = index;
|
||||
// 🌟 마우스 잔상(고스트)이 까맣게 캡처되는 것을 막기 위한 0초 딜레이 트릭
|
||||
setTimeout(() => setDraggingIndex(index), 0);
|
||||
}}
|
||||
onDragEnter={() => {
|
||||
dragOverItem.current = index;
|
||||
setDragOverIndex(index);
|
||||
}}
|
||||
onDragEnd={handleSort}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className={`relative flex items-center gap-3 group bg-neutral-900/50 p-2 rounded-md transition-all duration-200 ease-in-out cursor-grab active:cursor-grabbing border border-transparent hover:border-neutral-700 my-1
|
||||
${draggingIndex === index ? 'opacity-30 scale-95 shadow-inner' : 'opacity-100'}
|
||||
${isDropTarget ? 'mt-10' : 'mt-1'}
|
||||
`}
|
||||
>
|
||||
{/* 초록색 드롭 인디케이터 선 */}
|
||||
{isDropTarget && (
|
||||
<div className="absolute -top-5 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
|
||||
)}
|
||||
|
||||
<div className="text-neutral-600 group-hover:text-neutral-400">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
<div className="w-10 h-10 bg-neutral-700 rounded flex-shrink-0 overflow-hidden shadow-md">
|
||||
{item.info?.artworkUrl ? (
|
||||
<img src={item.info.artworkUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-hidden flex-1 pointer-events-none">
|
||||
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
|
||||
{item.info?.title || "제목 없음"}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 truncate">
|
||||
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(index);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-500 transition-all p-1"
|
||||
title="대기열에서 삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🌟 [신규 추가] 맨 아래로 드래그하기 위한 전용 투명 드롭존 */}
|
||||
{draggingIndex !== null && (
|
||||
<div
|
||||
onDragEnter={() => {
|
||||
dragOverItem.current = queue.length;
|
||||
setDragOverIndex(queue.length);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className={`relative h-12 flex items-center justify-center transition-all ${dragOverIndex === queue.length ? 'mt-4' : 'mt-0'
|
||||
}`}
|
||||
>
|
||||
{dragOverIndex === queue.length && (
|
||||
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
"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", "") ?? "";
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"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>;
|
||||
@@ -1,107 +0,0 @@
|
||||
"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 "멤버";
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/** 최근 검색어(브라우저에만 저장). 상단 검색창 목록과 서버 홈의 "최근 검색" 칩이 같이 쓴다. */
|
||||
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";
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 검색창에 붙여넣은 값이 봇이 바로 불러올 수 있는 음악 주소인지 판단한다.
|
||||
* (유튜브·유튜브 뮤직·스포티파이 — 곡/재생목록/앨범 모두 봇의 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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,50 +0,0 @@
|
||||
# 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 포트를 열지 말 것.
|
||||
@@ -1,552 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,17 +0,0 @@
|
||||
# .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
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user