From 7021f2b7767a29c9a06822926a0ed528c46e2714 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Sat, 3 Oct 2026 14:08:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=84=A0=ED=83=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B6=84=EB=A6=AC=20+=20=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80(?= =?UTF-8?q?=EC=84=9E=EA=B8=B0=C2=B7=EC=9E=90=EB=8F=99=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=C2=B7=EC=A3=BC=EC=86=8C=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?=20=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page - /login(디스코드 로그인 전용), /servers(서버 선택 전용), /servers/[id](유튜브 뮤직 플레이어)로 분리 · 서버 컴포넌트에서 로그인 검사, callbackUrl 은 같은 사이트 경로만 허용 · NextAuth 로그인/오류 화면도 /login 으로 - 서버 선택: 최근 사용 서버 맨 앞 "최근" 표시, 새로고침, 8개 초과 시 이름 검색, 오류 표시 - 플레이어: 검색어를 ?q= 로 남겨 뒤로가기 지원, 탭 제목에 지금 곡, 내 서버가 아니면 안내 - 검색창: 최근 검색어 목록, 유튜브·스포티파이 주소를 붙여넣으면 곡/재생목록 바로 불러오기 - 다음 트랙: 섞기, 모두 지우기(두 번 눌러 확인), 자동재생 스위치, 곡 수·총 길이 - 재생바: 처음부터 다시(⏮), 섞기 버튼 / 서버 홈: 음성채널 표시, 사용 안내, 최근 검색 칩 - 곡 추가 중 스피너 + 중복 추가 방지, 검색 실패 시 다시 시도, play/playlist 응답 대기 15초 - 계정 전환형 프로필 메뉴와 사이드바 서버 목록 제거(프로필은 서버 변경·로그아웃만) bot - Redis 액션 queue_shuffle, player_recommend 추가 - player_now 응답에 recommend(자동재생), voiceChannelName 추가 Co-Authored-By: Claude Opus 5.5 --- bot/README.md | 4 +- bot/src/classes/RedisClient.ts | 44 ++- bot/src/utils/music/Button.ts | 2 +- page/README.md | 108 +++--- page/src/app/api/auth/[...nextauth]/route.ts | 5 + page/src/app/api/player/autoplay/route.ts | 32 ++ page/src/app/api/player/play/route.ts | 2 + page/src/app/api/player/playlist/route.ts | 2 + page/src/app/api/queue/shuffle/route.ts | 32 ++ page/src/app/login/page.tsx | 17 + page/src/app/page.tsx | 137 +------- page/src/app/servers/[serverId]/page.tsx | 16 + page/src/app/servers/page.tsx | 9 + page/src/components/ToastProvider.tsx | 2 +- page/src/components/layout/AccountMenu.tsx | 85 +++++ page/src/components/layout/LeftSidebar.tsx | 167 ++++----- page/src/components/layout/TopNav.tsx | 243 +++++++------ page/src/components/pages/LoginView.tsx | 89 +++++ page/src/components/pages/PlayerApp.tsx | 164 +++++++++ page/src/components/pages/ServerPicker.tsx | 133 +++++++ page/src/components/player/MainContent.tsx | 346 ++++++++++--------- page/src/components/player/PlayerBar.tsx | 28 +- page/src/components/player/PlayerPage.tsx | 14 +- page/src/components/player/QueueList.tsx | 84 ++++- page/src/hooks/usePlayer.ts | 46 +++ page/src/hooks/useQueue.ts | 33 +- page/src/hooks/useServers.ts | 84 +++-- page/src/lib/auth.ts | 21 ++ page/src/lib/recent.ts | 24 ++ page/src/lib/url.ts | 36 ++ 30 files changed, 1384 insertions(+), 625 deletions(-) create mode 100644 page/src/app/api/player/autoplay/route.ts create mode 100644 page/src/app/api/queue/shuffle/route.ts create mode 100644 page/src/app/login/page.tsx create mode 100644 page/src/app/servers/[serverId]/page.tsx create mode 100644 page/src/app/servers/page.tsx create mode 100644 page/src/components/layout/AccountMenu.tsx create mode 100644 page/src/components/pages/LoginView.tsx create mode 100644 page/src/components/pages/PlayerApp.tsx create mode 100644 page/src/components/pages/ServerPicker.tsx create mode 100644 page/src/lib/auth.ts create mode 100644 page/src/lib/recent.ts create mode 100644 page/src/lib/url.ts diff --git a/bot/README.md b/bot/README.md index d245f92..ac80fad 100644 --- a/bot/README.md +++ b/bot/README.md @@ -159,7 +159,9 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사. 봇은 처리 후 결과를 `":"` 키에 `setex`(예: `player:play:`, `queue:list:`). 사이트는 그 키를 짧게 폴링해 결과를 받는다. - action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`, - `player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`. + `player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`, + `queue_shuffle`(다음 트랙 섞기), `player_recommend`(자동재생 토글 — 재생 중이 아니어도 바꿀 수 있음). + - `player_now` 응답에는 대시보드 표시용 `recommend`(자동재생 상태)와 `voiceChannelName`(봇이 있는 음성채널)도 들어 있다. - **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish (`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다. - 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`. diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 72030fb..0f34519 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -9,6 +9,8 @@ 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"; type SubAction = "search" | @@ -21,7 +23,9 @@ type SubAction = "player_volume" | "queue_list" | "queue_set" | - "queue_remove"; + "queue_remove" | + "queue_shuffle" | + "player_recommend"; export const RedisClient = () => { if (Config.redis.state) return new RedisClientClass(); @@ -141,7 +145,10 @@ class RedisClientClass { isPaused: player?.isPaused, position: player?.position, volume: player?.volume, - track: player?.nowTrack ?? null + track: player?.nowTrack ?? null, + // 웹 대시보드 표시용: 자동재생 상태, 봇이 들어가 있는 음성채널 이름 + recommend: player ? player.isRecommend : (DB.guild.get(data.serverId)?.options.recommend ?? false), + voiceChannelName: player ? (nowGuild.channels.cache.get(player.voiceChannelId)?.name ?? null) : null, })); } if (data.action === "queue_list") { @@ -194,6 +201,39 @@ 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를 찾을수 없습니다." })); diff --git a/bot/src/utils/music/Button.ts b/bot/src/utils/music/Button.ts index 5db3d05..f74c049 100644 --- a/bot/src/utils/music/Button.ts +++ b/bot/src/utils/music/Button.ts @@ -22,7 +22,7 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[] }); } -const buttonRecommend = async (guild: Guild) => { +export const buttonRecommend = async (guild: Guild) => { const gdb = DB.guild.get(guild.id); const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false; if (!change) return; diff --git a/page/README.md b/page/README.md index f151381..bb73b3d 100644 --- a/page/README.md +++ b/page/README.md @@ -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`. + `player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`, `queue_shuffle`, `player_recommend`. ### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream` ``` @@ -66,8 +66,9 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브 모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다. -- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `events`(SSE) -- **queue**: `list`, `set`, `remove`, `events`(SSE) +- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `autoplay`(자동재생 토글), `events`(SSE) +- **queue**: `list`, `set`, `remove`, `shuffle`(다음 트랙 섞기), `events`(SSE) +- `play`/`playlist`는 음성채널 접속 + 곡·재생목록 해석까지 기다려야 해서 응답 대기 시간을 15초로 둔다. - **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링) - **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과) - **auth**: `[...nextauth]`(Discord OAuth) @@ -78,65 +79,66 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브 ## 5. UI 구성 (`src/`) -디자인은 **유튜브 뮤직(웹) 다크 테마**를 그대로 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`, +디자인은 **유튜브 뮤직(웹) 다크 테마**를 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`, 보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`). -- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지(상단 바 · 왼쪽 메뉴 · 본문 · 하단 재생바 배치). `app/icon.svg` 파비콘. -- `components/layout/` — `TopNav`(☰·로고·검색·프로필 메뉴), `LeftSidebar`(왼쪽 메뉴: 홈/서버 목록 + 내 서버 목록). -- `components/player/` — `MainContent`(홈·서버 페이지·검색 결과), `PlayerBar`(하단 재생바), `PlayerPage`(펼친 재생 화면), - `QueueList`("다음 트랙" 목록). -- `components/ui.tsx` — 로고, 썸네일, 서버 아이콘, 로딩 표시, 재생 중 이퀄라이저. -- `components/ToastProvider.tsx` — 왼쪽 아래 스낵바 알림. -- `hooks/` — 데이터·조작 로직. 화면 여러 곳이 같은 상태를 쓰므로 `app/page.tsx`에서 한 번만 구독해 내려준다. - - `usePlayer` 현재 곡·진행·볼륨 + 재생/일시정지/다음/탐색/볼륨/단축키(SSE `player/events`) - - `useQueue` 대기열 + 순서 변경/삭제/특정 곡부터 재생(SSE `queue/events`) - - `useServers` 봇이 있는 내 서버 목록(세션 캐시) -- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입. -- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등). +### 5-1. 페이지(주소) -### 5-1. 화면 구성 +| 주소 | 내용 | 접근 규칙 | +|------|------|-----------| +| `/` | 바로 이동만 한다 | 로그인 안 했으면 `/login`, 했으면 `/servers` | +| `/login` | 로그인 전용 페이지(Discord 로그인 버튼 + 사용 3단계 안내, 로그인 오류 표시) | 이미 로그인했으면 `callbackUrl`(없으면 `/servers`)로 이동 | +| `/servers` | 서버 선택 전용 페이지(봇이 있는 내 서버, 최근 사용 서버를 맨 앞에 "최근" 표시, 새로고침, 8개 넘으면 이름 검색) | 로그인 필수 | +| `/servers/[serverId]` | 유튜브 뮤직 형태의 봇 플레이어. 검색하면 `?q=검색어`가 붙어 뒤로가기로 홈↔검색을 오간다 | 로그인 필수. 내 서버 목록에 없는 서버면 "열 수 없어요" 안내 후 서버 선택으로 | -화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지). +- 로그인 검사는 서버 컴포넌트에서 한다(`lib/auth.ts`의 `requirePageSession`). 돌아올 주소(`callbackUrl`)는 같은 사이트 경로만 허용한다. +- NextAuth 로그인/오류 화면도 `/login`으로 돌린다(`authOptions.pages`). +- 쓰다가 세션이 끊기면 클라이언트에서 `/login`으로 보낸다. -| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 | -|------|-----------|------------------|----------------------| -| 로그인 전 | 첫 방문 | 가운데 로그인 안내, 우상단 "로그인" | 디스코드 로그인. 검색·재생바는 잠김/숨김 | -| `SERVER_LIST` (홈) | 로그인 직후, 왼쪽 메뉴 "서버 목록" | 봇이 있는 내 서버를 원형 카드로(유튜브 뮤직 아티스트 줄 형태) | 서버 선택 | -| `SERVER_DETAIL` (서버 페이지) | 서버 카드/왼쪽 메뉴의 서버, 로고·"홈" | 유튜브 뮤직 앨범 페이지 형태: 왼쪽 서버 아이콘·이름·권한·사용 안내, 오른쪽 "재생 목록"(지금 재생 중 + 다음 트랙) | 검색 시작, 대기열 조작 | -| `SEARCH_RESULT` | 검색창 Enter | 필터 칩(전체/노래/동영상/Spotify), 상위 검색결과 카드, 구역별 곡 목록(전체 탭은 5곡씩 + "모두 표시") | 곡을 눌러 대기열에 추가 | -| 재생 화면 | 재생바의 곡 정보·⌃ 버튼, 서버 페이지의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + "다음 트랙" 탭 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 | 대기열 조작, (좁은 화면) 재생 조작 | +### 5-2. 파일 구성 -- 재생바는 유튜브 뮤직처럼 **재생 중인 곡이 있을 때만** 나타난다. -- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침(240px)/접힘(아이콘만 72px), 좁은 화면은 ☰ 로 서랍을 연다. -- 좁은 화면 상단 바는 검색창 대신 돋보기 버튼 → 누르면 바 전체가 검색창이 된다. +- `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-2. 버튼·조작 동작 정리 +### 5-3. 플레이어 화면 구성 -| 위치 | 요소 | 동작 | 비활성 조건 | -|------|------|------|-------------| -| 상단 | ☰ | 왼쪽 메뉴 펼침/접힘(좁은 화면은 서랍) | — | -| 상단 | 로고 | 서버를 고른 상태면 서버 페이지로, 아니면 홈으로 | — | -| 상단 | 검색창 | Enter 로 검색 → `SEARCH_RESULT`. ✕ 로 검색어 지우기 | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) | -| 상단 | 프로필 사진 | 계정 메뉴(이름 + 로그아웃). 바깥 클릭/Esc 로 닫힘 | — | -| 왼쪽 메뉴 | 홈 / 서버 목록 / 내 서버 | 서버 페이지 / 서버 고르기 / 해당 서버로 바로 전환 | — | -| 서버 페이지 | 노래 검색 | 상단 검색창에 포커스(좁은 화면은 검색 모드로 전환) | — | -| 서버 페이지 | 목록 아이콘 | 재생 화면 열기 | — | -| 검색 | 필터 칩 | 해당 출처 결과만 전부 보기 | 결과 0건인 칩 | -| 검색 | 곡 행(행 전체가 버튼) / 상위 검색결과 "재생" | `POST /api/player/play` → 대기열 추가(재생 중이 아니면 바로 재생). 스낵바로 결과 알림. 썸네일 위 재생 아이콘은 마우스면 hover 때, 터치 화면이면 항상 | 서버 미선택·미로그인 | -| 재생바 | 진행바(위쪽 가장자리 빨간 선) | hover 시 굵어지고 손잡이 표시. 드래그/클릭/키보드로 탐색 → `POST /api/player/seek` | 봇 세션 없음/재생 중 아님 | -| 재생바 | 재생/일시정지 · 다음 곡 | `POST /api/player/pause`(낙관적 UI, 실패 시 롤백) · `POST /api/player/skip` | 〃 | -| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 | -| 재생바 | 곡 정보 / ⌃ | 재생 화면 열기·닫기 | — | -| 재생바 | 스피커 아이콘 | 클릭 = 음소거/해제(해제 시 직전 볼륨 복원). hover 시 왼쪽에 볼륨 슬라이더가 펼쳐짐 | 봇 세션 없음/재생 중 아님 | -| 재생바·재생 화면 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 | -| 다음 트랙 | 곡 | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다(첫 곡이면 다음 곡으로 넘기기만) | — | -| 다음 트랙 | 손잡이 드래그 | 순서 변경(마우스 전용, hover 시 표시) | — | -| 다음 트랙 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (터치 화면 전용, 드래그 대체) | 맨 위/맨 아래 | -| 다음 트랙 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지). 마우스면 hover 때, 터치 화면이면 항상 표시 | — | -| 재생 화면 | ⌄ / Esc | 재생 화면 닫기 | — | -| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 | +| 화면 | 진입 | 보이는 것 | +|------|------|-----------| +| 서버 홈 | 서버 선택 직후, 로고·"홈" | 왼쪽: 서버 아이콘·이름·권한·봇이 있는 음성채널, 노래 검색 버튼, (재생 전) 사용 안내, 최근 검색 칩 / 오른쪽: 재생 목록(자동재생 스위치, 지금 곡, 다음 트랙) | +| 검색 결과 | 검색창 Enter, 최근 검색어 | 필터 칩(전체/노래/동영상/Spotify, 개수 표시), 상위 검색결과, 구역별 목록(5곡 + "모두 표시"), 실패 시 "다시 시도" | +| 주소 불러오기 | 유튜브·유튜브 뮤직·스포티파이 주소를 검색창에 붙여넣고 Enter | 곡/재생목록·앨범 구분, "대기열에 추가" → `POST /api/player/playlist` | +| 재생 화면 | 재생바 곡 정보·⌃, 서버 홈의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + 다음 트랙 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 | -미지원(봇 API 한계): 이전 곡, 반복 재생, 셔플, 좋아요/가사. 유튜브 뮤직에 있는 이 버튼들은 동작하지 않는 가짜 버튼이 되므로 넣지 않았다. +- 재생바는 재생 중인 곡이 있을 때만 보인다. 브라우저 탭 제목에 지금 곡(▶/⏸)이 표시된다. +- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침/접힘, 좁은 화면은 ☰ 로 서랍. + +### 5-4. 버튼·조작 동작 정리 + +| 위치 | 요소 | 동작 | +|------|------|------| +| 상단 | 검색창 | Enter 로 검색(주소면 불러오기 화면). 누르면 최근 검색어 8개(✕로 개별 삭제) | +| 상단 | 프로필 | 서버 변경 · 로그아웃 | +| 왼쪽 메뉴 | 홈 / 검색 / 서버 변경 | 서버 홈 / 검색창 포커스 / `/servers` | +| 검색 | 곡 행·상위 검색결과 "재생" | `POST /api/player/play`. 요청 중에는 스피너를 띄우고 다른 곡 추가를 막아 중복 추가를 방지 | +| 재생바 | ⏮ | 지금 곡을 처음부터 다시(유튜브 뮤직 동작. 봇에 이전 곡 기록이 없다) | +| 재생바 | ⏯ / ⏭ / 진행바 | 일시정지·재개 / 다음 곡 / 탐색 | +| 재생바 | 스피커 | 클릭 = 음소거/해제, hover 시 볼륨 슬라이더 | +| 재생바 | 섞기 | 다음 트랙 섞기(2곡 이상일 때) | +| 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) | +| 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) | +| 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 | +| 재생 화면 | ⌄ / Esc | 닫기 | +| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | + +미지원(봇 기능 없음): 이전 곡으로 돌아가기, 반복 재생, 좋아요/가사. 동작하지 않는 버튼이 되므로 넣지 않았다. --- diff --git a/page/src/app/api/auth/[...nextauth]/route.ts b/page/src/app/api/auth/[...nextauth]/route.ts index 667f4d2..549e1f4 100644 --- a/page/src/app/api/auth/[...nextauth]/route.ts +++ b/page/src/app/api/auth/[...nextauth]/route.ts @@ -32,6 +32,11 @@ export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, + // 로그인/로그인 오류는 NextAuth 기본 화면 대신 우리 로그인 페이지로 보낸다. + pages: { + signIn: "/login", + error: "/login", + }, callbacks: { // 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직 async jwt({ token, account, profile }) { diff --git a/page/src/app/api/player/autoplay/route.ts b/page/src/app/api/player/autoplay/route.ts new file mode 100644 index 0000000..0fc4b03 --- /dev/null +++ b/page/src/app/api/player/autoplay/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface AutoplayBody { + serverId?: unknown; +} + +/** 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) 켜기/끄기 토글 */ +export async function POST(request: Request) { + try { + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; + + const bodyResult = await readJsonBody(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); + } +} diff --git a/page/src/app/api/player/play/route.ts b/page/src/app/api/player/play/route.ts index e92f68b..f99d05c 100644 --- a/page/src/app/api/player/play/route.ts +++ b/page/src/app/api/player/play/route.ts @@ -30,6 +30,8 @@ export async function POST(request: Request) { userId, track, }, + // 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다. + timeoutMs: 15000, }); return NextResponse.json(body, { status }); } catch (error) { diff --git a/page/src/app/api/player/playlist/route.ts b/page/src/app/api/player/playlist/route.ts index f3f5a3b..465f7a6 100644 --- a/page/src/app/api/player/playlist/route.ts +++ b/page/src/app/api/player/playlist/route.ts @@ -30,6 +30,8 @@ export async function POST(request: Request) { userId, playlistUrl: urlResult.value, }, + // 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다. + timeoutMs: 15000, }); return NextResponse.json(body, { status }); } catch (error) { diff --git a/page/src/app/api/queue/shuffle/route.ts b/page/src/app/api/queue/shuffle/route.ts new file mode 100644 index 0000000..98f497b --- /dev/null +++ b/page/src/app/api/queue/shuffle/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface ShuffleBody { + serverId?: unknown; +} + +/** 대기열 섞기(현재 곡은 그대로, 다음 트랙만 섞는다) */ +export async function POST(request: Request) { + try { + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; + + const bodyResult = await readJsonBody(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); + } +} diff --git a/page/src/app/login/page.tsx b/page/src/app/login/page.tsx new file mode 100644 index 0000000..65940d4 --- /dev/null +++ b/page/src/app/login/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation"; +import LoginView from "@/components/pages/LoginView"; +import { getPageSession, safeCallbackUrl } from "@/lib/auth"; + +export const metadata = { title: "로그인 | MusicBot" }; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ callbackUrl?: string; error?: string }>; +}) { + const { callbackUrl, error } = await searchParams; + const target = safeCallbackUrl(callbackUrl); + const session = await getPageSession(); + if (session?.user?.id) redirect(target); + return ; +} diff --git a/page/src/app/page.tsx b/page/src/app/page.tsx index e99083f..744e8a3 100644 --- a/page/src/app/page.tsx +++ b/page/src/app/page.tsx @@ -1,133 +1,8 @@ -"use client"; +import { redirect } from "next/navigation"; +import { getPageSession } from "@/lib/auth"; -import { useCallback, useState } from "react"; -import { useSession } from "next-auth/react"; -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 { usePlayer } from "@/hooks/usePlayer"; -import { useQueue } from "@/hooks/useQueue"; -import { useServers } from "@/hooks/useServers"; -import type { DiscordServer } from "@/types/music"; - -// 화면 모드 타입 정의 -export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; - -/** - * 유튜브 뮤직(웹) 구조: - * 상단 바(☰ 로고 · 검색 · 프로필) - * [왼쪽 메뉴] [본문] ← 재생 화면(⌃)은 본문 위를 덮는다 - * 하단 재생바 - */ -export default function MusicPlayerLayout() { - const { status } = useSession(); - const [viewMode, setViewMode] = useState("SERVER_LIST"); - const [selectedServer, setSelectedServer] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - // 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍 - const [guideExpanded, setGuideExpanded] = useState(true); - const [drawerOpen, setDrawerOpen] = useState(false); - const [isPlayerOpen, setIsPlayerOpen] = useState(false); - - const { servers, isFetching } = useServers(); - // 재생 상태·대기열은 재생바, 재생 화면, 서버 페이지가 같이 쓰므로 여기서 한 번만 구독한다. - const player = usePlayer(selectedServer); - const queue = useQueue(selectedServer); - - const isAuthed = status === "authenticated"; - // 서버를 고르기 전에는 재생이 의미가 없고, 유튜브 뮤직처럼 곡이 있을 때만 재생바를 띄운다. - const showPlayback = isAuthed && !!selectedServer; - const showPlayerBar = showPlayback && !!player.track; - - const handleHome = () => { - setIsPlayerOpen(false); - if (selectedServer) { - setViewMode("SERVER_DETAIL"); - } else { - setViewMode("SERVER_LIST"); - setSearchQuery(""); - } - }; - - const handleSearch = (query: string) => { - setIsPlayerOpen(false); - setSearchQuery(query); - setViewMode("SEARCH_RESULT"); - }; - - const handleSelectServer = (server: DiscordServer) => { - setIsPlayerOpen(false); - setSelectedServer(server); - setViewMode("SERVER_DETAIL"); - }; - - const handleBackToServers = () => { - setIsPlayerOpen(false); - setViewMode("SERVER_LIST"); - setSelectedServer(null); - setSearchQuery(""); - }; - - const handleToggleGuide = () => { - // 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기 - if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v); - else setDrawerOpen(true); - }; - - const closePlayer = useCallback(() => setIsPlayerOpen(false), []); - - return ( -
- - -
- setDrawerOpen(false)} - isAuthed={isAuthed} - viewMode={viewMode} - servers={servers} - selectedServer={selectedServer} - onHome={handleHome} - onServers={handleBackToServers} - onSelectServer={handleSelectServer} - /> - -
- setIsPlayerOpen(true)} - showQueue={!isPlayerOpen} - player={player} - queue={queue} - /> - - {showPlayback && isPlayerOpen && ( - - )} -
-
- - {showPlayerBar && ( - setIsPlayerOpen((v) => !v)} - /> - )} -
- ); +/** 첫 화면: 로그인 안 했으면 로그인 페이지, 했으면 서버 선택 페이지로 보낸다. */ +export default async function Home() { + const session = await getPageSession(); + redirect(session?.user?.id ? "/servers" : "/login"); } diff --git a/page/src/app/servers/[serverId]/page.tsx b/page/src/app/servers/[serverId]/page.tsx new file mode 100644 index 0000000..b781692 --- /dev/null +++ b/page/src/app/servers/[serverId]/page.tsx @@ -0,0 +1,16 @@ +import PlayerApp from "@/components/pages/PlayerApp"; +import { requirePageSession } from "@/lib/auth"; + +export default async function ServerPlayerPage({ + params, + searchParams, +}: { + params: Promise<{ serverId: string }>; + searchParams: Promise<{ q?: string }>; +}) { + const { serverId } = await params; + const { q } = await searchParams; + await requirePageSession(`/servers/${serverId}`); + // key: 서버를 바꾸면 재생 상태·검색 결과를 깨끗하게 새로 시작한다. + return ; +} diff --git a/page/src/app/servers/page.tsx b/page/src/app/servers/page.tsx new file mode 100644 index 0000000..63e37dc --- /dev/null +++ b/page/src/app/servers/page.tsx @@ -0,0 +1,9 @@ +import ServerPicker from "@/components/pages/ServerPicker"; +import { requirePageSession } from "@/lib/auth"; + +export const metadata = { title: "서버 선택 | MusicBot" }; + +export default async function ServersPage() { + await requirePageSession("/servers"); + return ; +} diff --git a/page/src/components/ToastProvider.tsx b/page/src/components/ToastProvider.tsx index c02ce9e..29c0280 100644 --- a/page/src/components/ToastProvider.tsx +++ b/page/src/components/ToastProvider.tsx @@ -33,7 +33,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { {toast && (
{toast.type === "error" && } {toast.message} diff --git a/page/src/components/layout/AccountMenu.tsx b/page/src/components/layout/AccountMenu.tsx new file mode 100644 index 0000000..e30e782 --- /dev/null +++ b/page/src/components/layout/AccountMenu.tsx @@ -0,0 +1,85 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { signOut, useSession } from "next-auth/react"; +import { LogOut, Server } from "lucide-react"; + +/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */ +export function Avatar({ user, className = "h-8 w-8 text-sm" }: { user: { name?: string | null; image?: string | null }; className?: string }) { + if (user.image) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + return ( + + {(user.name ?? "?").trim().charAt(0).toUpperCase()} + + ); +} + +/** + * 프로필 버튼 → 작은 메뉴(서버 변경 · 로그아웃). + * 계정 전환·설정 같은 유튜브 계정 메뉴 항목은 이 봇에 필요 없어 넣지 않는다. + */ +export default function AccountMenu({ showServerSwitch = true }: { showServerSwitch?: boolean }) { + const { data: session, status } = useSession(); + const [open, setOpen] = useState(false); + const ref = useRef(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 ; + } + + return ( +
+ + {open && ( +
+

{session.user.name}

+ {showServerSwitch && ( + + + 서버 변경 + + )} + +
+ )} +
+ ); +} diff --git a/page/src/components/layout/LeftSidebar.tsx b/page/src/components/layout/LeftSidebar.tsx index 35cbba2..8f8cfcf 100644 --- a/page/src/components/layout/LeftSidebar.tsx +++ b/page/src/components/layout/LeftSidebar.tsx @@ -1,9 +1,10 @@ "use client"; -import { Home, Library, Menu } from "lucide-react"; +import Link from "next/link"; +import { ArrowLeftRight, Home, Menu, Search, Volume2 } from "lucide-react"; import type { DiscordServer } from "@/types/music"; -import type { ViewMode } from "@/app/page"; +import type { ViewMode } from "@/components/pages/PlayerApp"; import { Logo, ServerAvatar } from "@/components/ui"; -import { permissionLabel } from "@/hooks/useServers"; +import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav"; interface LeftSidebarProps { /** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */ @@ -11,97 +12,95 @@ interface LeftSidebarProps { /** 좁은 화면에서 서랍처럼 열린 상태 */ drawerOpen: boolean; onCloseDrawer: () => void; - isAuthed: boolean; viewMode: ViewMode; - servers: DiscordServer[]; - selectedServer: DiscordServer | null; + server: DiscordServer | null; + /** 봇이 들어가 있는 음성채널 이름(재생 중일 때) */ + voiceChannelName: string | null; onHome: () => void; - onServers: () => void; - onSelectServer: (server: DiscordServer) => void; } /** - * 유튜브 뮤직 왼쪽 메뉴(가이드). - * - 위: 홈 / 서버 목록(유튜브 뮤직의 "보관함" 자리) - * - 아래: 재생목록 자리에 "봇이 있는 내 서버" 목록 → 바로 서버 전환 + * 유튜브 뮤직 왼쪽 메뉴(가이드): 홈 / 검색 / 서버 변경 + 아래에 지금 조작 중인 서버. * 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다. */ export default function LeftSidebar({ expanded, drawerOpen, onCloseDrawer, - isAuthed, viewMode, - servers, - selectedServer, + server, + voiceChannelName, onHome, - onServers, - onSelectServer, }: LeftSidebarProps) { - const nav = [ - { key: "home", label: "홈", icon: Home, active: viewMode === "SERVER_DETAIL", onClick: onHome }, - { key: "servers", label: "서버 목록", icon: Library, active: viewMode === "SERVER_LIST", onClick: onServers }, + 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 = ( + <> + + {item.label} + + ); + return item.href ? ( + + {content} + + ) : ( + + ); + }; + /** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */ const full = (afterClick?: () => void) => ( -
+
    - {nav.map(({ key, label, icon: Icon, active, onClick }) => ( -
  • - -
  • + {nav.map((item) => ( +
  • {renderItem(item, itemClass(item.active), afterClick)}
  • ))}
- {isAuthed && ( - <> -
-

내 서버

-
    - {servers.length === 0 ? ( -
  • - 음악 봇이 있는 서버가 여기에 표시됩니다. -
  • - ) : ( - servers.map((server) => { - const active = selectedServer?.id === server.id; - return ( -
  • - -
  • - ); - }) - )} -
- + {server && ( +
+
+ + + {server.name} + 조작 중인 서버 + +
+

+ + + {voiceChannelName ? `${voiceChannelName}에서 재생 중` : "봇이 음성 채널에 없어요"} + +

+
)}
); @@ -117,19 +116,8 @@ export default function LeftSidebar({ full() ) : (
    - {nav.map(({ key, label, icon: Icon, active, onClick }) => ( -
  • - -
  • + {nav.map((item) => ( +
  • {renderItem(item, miniClass(item.active))}
  • ))}
)} @@ -138,13 +126,8 @@ export default function LeftSidebar({ {/* 좁은 화면: 서랍 메뉴 */} {drawerOpen && (
- -
- {/* 검색창: 유튜브 뮤직과 같은 반투명 상자 + 왼쪽 돋보기 + 지우기 버튼 */} + {/* 검색창 */}
{ e.preventDefault(); - submitSearch(); + run(term); }} - className="group relative w-full max-w-[480px]" + className="relative w-full max-w-[480px]" > - + setTerm(e.target.value)} - disabled={!canSearch} - placeholder={searchPlaceholder} + onFocus={() => setFocused(true)} + onKeyDown={(e) => e.key === "Escape" && setFocused(false)} + placeholder="노래·아티스트 검색 또는 유튜브·스포티파이 주소 붙여넣기" aria-label="노래 검색" - className="h-10 w-full rounded-lg 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 disabled:cursor-not-allowed disabled:opacity-60 [&::-webkit-search-cancel-button]:hidden" + 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 && ( )} + + {/* 최근 검색어 / 주소 안내 */} + {showDropdown && ( +
+ {isMusicUrl(term) ? ( + + ) : ( + recent.map((r) => ( +
+ + +
+ )) + )} +
+ )}
@@ -172,62 +205,18 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer {/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
- {canSearch && ( - - )} - - {status === "loading" ? ( - - ) : isAuthed && session?.user ? ( -
- - {menuOpen && ( -
-
- - - {session.user.name} - Discord 계정 - -
- -
- )} -
- ) : ( - - )} + +
diff --git a/page/src/components/pages/LoginView.tsx b/page/src/components/pages/LoginView.tsx new file mode 100644 index 0000000..6055def --- /dev/null +++ b/page/src/components/pages/LoginView.tsx @@ -0,0 +1,89 @@ +"use client"; +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { Headphones, ListMusic, Server } from "lucide-react"; +import { Logo, Spinner } from "@/components/ui"; + +/** 디스코드 로고(공식 색상 버튼 안에 넣는 흰색 마크) */ +function DiscordMark() { + return ( + + + + ); +} + +const ERROR_TEXT: Record = { + 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 ( +
+ {/* 은은한 빨간 빛 배경 */} +
+ +
+
+ +
+ +
+

로그인

+

+ 디스코드 계정으로 로그인하면 브라우저에서 음악 봇을 조작할 수 있어요. +

+ + {errorText && ( +

+ {errorText} +

+ )} + + + +
    + {STEPS.map(({ icon: Icon, title, desc }, i) => ( +
  1. + + + + + + {i + 1}. {title} + + {desc} + +
  2. + ))} +
+
+
+
+ ); +} diff --git a/page/src/components/pages/PlayerApp.tsx b/page/src/components/pages/PlayerApp.tsx new file mode 100644 index 0000000..7e4cf6b --- /dev/null +++ b/page/src/components/pages/PlayerApp.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import TopNav from "@/components/layout/TopNav"; +import LeftSidebar from "@/components/layout/LeftSidebar"; +import MainContent from "@/components/player/MainContent"; +import PlayerBar from "@/components/player/PlayerBar"; +import PlayerPage from "@/components/player/PlayerPage"; +import { Spinner } from "@/components/ui"; +import { cleanArtist, usePlayer } from "@/hooks/usePlayer"; +import { useQueue } from "@/hooks/useQueue"; +import { setLastServerId, useServers } from "@/hooks/useServers"; +import type { DiscordServer } from "@/types/music"; + +/** 플레이어 안의 화면: 서버 홈 / 검색 결과 */ +export type ViewMode = "HOME" | "SEARCH"; + +/** 주소창의 ?q= 를 읽는다(뒤로가기로 검색 화면을 오갈 수 있게 검색어를 주소에 남긴다) */ +function readQuery() { + return new URLSearchParams(window.location.search).get("q") ?? ""; +} + +/** + * 유튜브 뮤직 구조의 봇 플레이어(서버를 고른 뒤 들어오는 화면): + * 상단 바(☰ 로고 · 검색 · 프로필) + * [왼쪽 메뉴] [본문: 서버 홈 / 검색 결과] ← 재생 화면(⌃)은 본문 위를 덮는다 + * 하단 재생바 + */ +export default function PlayerApp({ serverId, initialQuery }: { serverId: string; initialQuery: string }) { + const router = useRouter(); + const { status } = useSession(); + const [searchQuery, setSearchQuery] = useState(initialQuery); + const viewMode: ViewMode = searchQuery ? "SEARCH" : "HOME"; + // 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍 + const [guideExpanded, setGuideExpanded] = useState(true); + const [drawerOpen, setDrawerOpen] = useState(false); + const [isPlayerOpen, setIsPlayerOpen] = useState(false); + + const { servers, loaded, isFetching, error } = useServers(); + const found = servers.find((s) => s.id === serverId) ?? null; + // 목록을 받기 전에도 재생 상태 구독은 바로 시작한다(봇이 서버 멤버 여부를 다시 확인한다). + const server: DiscordServer = useMemo( + () => found ?? { id: serverId, name: "", icon: null, owner: false, permissions: "0" }, + [found, serverId], + ); + // 재생 상태·대기열은 재생바, 재생 화면, 서버 홈이 같이 쓰므로 여기서 한 번만 구독한다. + const player = usePlayer(server); + const queue = useQueue(server); + + // 세션이 만료되면 로그인 페이지로 + useEffect(() => { + if (status === "unauthenticated") router.replace(`/login?callbackUrl=${encodeURIComponent(`/servers/${serverId}`)}`); + }, [status, router, serverId]); + + useEffect(() => { + if (found) setLastServerId(found.id); + }, [found]); + + // 뒤로/앞으로 가기로 검색 화면을 오간다. + useEffect(() => { + const onPop = () => setSearchQuery(readQuery()); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + // 탭 제목에 지금 곡을 보여준다(다른 탭에 있어도 무슨 곡인지 알 수 있게). + useEffect(() => { + const t = player.track?.info; + const base = found?.name ? `${found.name} | MusicBot` : "MusicBot"; + document.title = t?.title ? `${player.isPaused ? "⏸" : "▶"} ${t.title} · ${cleanArtist(t.author)} | MusicBot` : base; + }, [player.track, player.isPaused, found]); + + const goHome = () => { + setIsPlayerOpen(false); + if (searchQuery) { + setSearchQuery(""); + window.history.pushState(null, "", `/servers/${serverId}`); + } + }; + + const handleSearch = (query: string) => { + setIsPlayerOpen(false); + setSearchQuery(query); + window.history.pushState(null, "", `/servers/${serverId}?q=${encodeURIComponent(query)}`); + }; + + const handleToggleGuide = () => { + // 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기 + if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v); + else setDrawerOpen(true); + }; + + const closePlayer = useCallback(() => setIsPlayerOpen(false), []); + + // 목록을 받았는데 이 서버가 없으면(봇이 없거나 내가 멤버가 아님) 서버 선택으로 안내 + if (loaded && !isFetching && !found) { + return ( +
+

이 서버를 열 수 없어요

+

+ {error ?? "음악 봇이 없는 서버이거나, 내가 들어가 있지 않은 서버예요."} +

+ + 서버 선택으로 + +
+ ); + } + + if (status !== "authenticated") { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ setDrawerOpen(false)} + viewMode={viewMode} + server={found} + voiceChannelName={player.voiceChannelName} + onHome={goHome} + /> + +
+ setIsPlayerOpen(true)} + showQueue={!isPlayerOpen} + player={player} + queue={queue} + /> + + {isPlayerOpen && } +
+
+ + {/* 유튜브 뮤직처럼 재생 중인 곡이 있을 때만 재생바를 띄운다 */} + {player.track && ( + = 2} + isPlayerOpen={isPlayerOpen} + onTogglePlayer={() => setIsPlayerOpen((v) => !v)} + /> + )} +
+ ); +} diff --git a/page/src/components/pages/ServerPicker.tsx b/page/src/components/pages/ServerPicker.tsx new file mode 100644 index 0000000..4837659 --- /dev/null +++ b/page/src/components/pages/ServerPicker.tsx @@ -0,0 +1,133 @@ +"use client"; +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { RefreshCw, Search } from "lucide-react"; +import AccountMenu from "@/components/layout/AccountMenu"; +import { Logo, ServerAvatar, Spinner } from "@/components/ui"; +import { getLastServerId, permissionLabel, setLastServerId, useServers } from "@/hooks/useServers"; + +/** 서버가 이만큼 넘으면 이름 검색 칸을 보여준다 */ +const FILTER_THRESHOLD = 8; + +/** 서버 선택 전용 페이지. 여기서 서버를 골라야 플레이어로 들어간다. */ +export default function ServerPicker() { + const router = useRouter(); + const { data: session, status } = useSession(); + const { servers, isFetching, error, loaded, refresh } = useServers(); + const [filter, setFilter] = useState(""); + const [lastId, setLastId] = useState(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 ( +
+
+
+ + +
+
+ +
+
+
+

{session?.user?.name ? `${session.user.name}님, 안녕하세요` : " "}

+

어느 서버에서 들을까요?

+

음악 봇이 들어가 있는 내 서버만 보여요.

+
+ +
+ + {servers.length > FILTER_THRESHOLD && ( +
+ + 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" + /> +
+ )} + + {error && ( +

+ {error} 잠시 뒤 새로고침을 눌러 주세요. +

+ )} + +
+ {!loaded ? ( +
+ +
+ ) : servers.length === 0 ? ( +
+

표시할 서버가 없어요

+

+ 내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있어요. + 봇을 서버에 초대한 뒤 새로고침을 눌러 주세요. +

+
+ ) : ordered.length === 0 ? ( +

“{filter}”과(와) 일치하는 서버가 없어요.

+ ) : ( +
    + {ordered.map((server) => ( +
  • + setLastServerId(server.id)} + className="group block text-center focus:outline-none" + > + + + + {server.id === lastId && ( + + 최근 + + )} + + {server.name} + {permissionLabel(server)} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index 13dbdfa..1051973 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -1,15 +1,17 @@ "use client"; import { useEffect, useState } from "react"; -import { signIn, useSession } from "next-auth/react"; -import { ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react"; -import type { ViewMode } from "@/app/page"; +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 { useToast } from "@/components/ToastProvider"; import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav"; import QueueList from "@/components/player/QueueList"; -import { Logo, ServerAvatar, Spinner, Thumb } from "@/components/ui"; +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; @@ -25,15 +27,15 @@ const SOURCES: { key: SourceKey; title: string; kind: string; wide: boolean }[] /** "전체" 탭에서 구역마다 먼저 보여줄 곡 수(나머지는 "모두 표시") */ const PREVIEW_COUNT = 5; +const EMPTY_RESULTS: SearchResults = { spotify: [], youtubeMusic: [], youtubeVideo: [] }; + interface MainContentProps { viewMode: ViewMode; - selectedServer: DiscordServer | null; + server: DiscordServer; searchQuery: string; - servers: DiscordServer[]; - isFetchingServers: boolean; - onSelectServer: (server: DiscordServer) => void; + onSearch: (query: string) => void; onOpenPlayer: () => void; - /** 재생 화면이 열려 있으면 서버 페이지의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */ + /** 재생 화면이 열려 있으면 서버 홈의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */ showQueue: boolean; player: PlayerState; queue: QueueState; @@ -41,145 +43,128 @@ interface MainContentProps { export default function MainContent({ viewMode, - selectedServer, + server, searchQuery, - servers, - isFetchingServers, - onSelectServer, + onSearch, onOpenPlayer, showQueue, player, queue, }: MainContentProps) { - const { data: session, status } = useSession(); + const { data: session } = useSession(); const { showToast } = useToast(); - const [searchResults, setSearchResults] = useState({ - spotify: [], - youtubeMusic: [], - youtubeVideo: [] - }); + const [searchResults, setSearchResults] = useState(EMPTY_RESULTS); const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(null); const [filter, setFilter] = useState("all"); + // 같은 곡을 연달아 눌러 중복으로 쌓이지 않게, 요청 중인 항목을 표시한다. + const [pendingKey, setPendingKey] = useState(null); + const [recent, setRecent] = useState([]); + const [retry, setRetry] = useState(0); - const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => { - if (!selectedServer) { - showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error"); - return; - } + const isUrlQuery = isMusicUrl(searchQuery); - const userId = session?.user?.id; - if (!userId) { + 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, endpoint: string, okText: string) => { + if (!session?.user?.id) { showToast("로그인이 필요합니다.", "error"); return; } - - let endpoint = ""; - const bodyData: Record = { 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; - } - + if (pendingKey) return; + setPendingKey(key); try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(bodyData), + body: JSON.stringify({ serverId: server.id, ...body }), }); - - const data = await res.json(); - + const data = await res.json().catch(() => ({})); if (res.ok && data.success) { - showToast("재생목록에 곡을 추가했습니다!", "success"); + showToast(okText, "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) ? "재생목록을 대기열에 추가했어요. 나머지 곡은 이어서 채워져요." : "대기열에 추가했어요.", + ); + useEffect(() => { - if (viewMode === "SEARCH_RESULT" && searchQuery) { - // 새 검색마다 결과·탭을 초기화하는 의도적 패턴 - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsSearching(true); - setFilter("all"); - setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] }); + 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(); - 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 ( -
- -
-

디스코드로 로그인하고 음악을 들어 보세요

-

- 로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고, - 브라우저에서 바로 노래를 검색·재생할 수 있습니다. -

-
- {/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */} - -
- ); - } - - if (status === "loading") { - return ( -
- -
- ); - } + 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)) || "검색하지 못했습니다."); + } + }) + .catch(() => !cancelled && setSearchError("서버와 통신할 수 없습니다.")) + .finally(() => !cancelled && setIsSearching(false)); + // 결과가 늦게 와도 이미 다른 검색어로 넘어갔으면 버린다. + return () => { + cancelled = true; + }; + }, [viewMode, searchQuery, retry]); /** * 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이". * 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다. */ - const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean) => { + 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(" • "); return ( -
  • +
  • -
  • - ))} - - )} - - )} - - {/* 화면 2: 서버 페이지 — 유튜브 뮤직 앨범/재생목록 페이지 형태(왼쪽 커버, 오른쪽 트랙 목록) */} - {viewMode === "SERVER_DETAIL" && selectedServer && ( + {/* 서버 홈 — 유튜브 뮤직 앨범 페이지 형태(왼쪽 서버 정보, 오른쪽 재생 목록) */} + {viewMode === "HOME" && (
    -

    {selectedServer.name}

    -

    - 디스코드 서버 • 내 권한: {permissionLabel(selectedServer)} -

    -

    - 먼저 디스코드에서 음성 채널에 입장한 뒤, 노래를 검색해 누르면 봇이 재생합니다. +

    {server.name || "서버"}

    +

    디스코드 서버 • 내 권한: {permissionLabel(server)}

    +

    + + {player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "봇이 아직 음성 채널에 없어요"}

    +
    -

    ID {selectedServer.id}

    + + {/* 처음 쓰는 사람을 위한 안내: 아무것도 재생 중이 아닐 때만 */} + {!player.track && ( +
      +
    1. 1. 디스코드에서 음성 채널에 먼저 입장해 주세요.
    2. +
    3. 2. 위 검색창에서 노래를 찾거나 유튜브·스포티파이 주소를 붙여넣고 Enter.
    4. +
    5. 3. 곡을 누르면 봇이 들어와서 바로 재생해요.
    6. +
    + )} + + {/* 최근 검색: 다시 찾기 쉽게 칩으로 */} + {recent.length > 0 && ( +
    +

    + 최근 검색 +

    +
    + {recent.map((r) => ( + + ))} +
    +
    + )}
    @@ -307,10 +273,37 @@ export default function MainContent({
    )} - {/* 화면 3: 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */} - {viewMode === "SEARCH_RESULT" && ( + {/* 주소를 붙여넣은 경우: 검색 대신 바로 불러오기 카드 */} + {viewMode === "SEARCH" && isUrlQuery && ( +
    +

    주소에서 불러오기

    +
    +
    + + + +
    +

    {isCollectionUrl(searchQuery) ? "재생목록 · 앨범" : "곡 주소"}

    +

    {searchQuery}

    +
    +
    + +

    유튜브·유튜브 뮤직·스포티파이의 곡, 재생목록, 앨범 주소를 지원해요.

    +
    +
    + )} + + {/* 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */} + {viewMode === "SEARCH" && !isUrlQuery && (
    - {/* 필터 칩 */}
    {([{ key: "all", title: "전체" } as const, ...SOURCES]).map((chip) => { const active = filter === chip.key; @@ -326,6 +319,7 @@ export default function MainContent({ ${active ? "bg-white text-black" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`} > {chip.title} + {chip.key !== "all" && counts[chip.key] > 0 && {counts[chip.key]}} ); })} @@ -336,6 +330,18 @@ export default function MainContent({

    “{searchQuery}” 검색 중...

    + ) : searchError ? ( +
    + +

    {searchError}

    + +
    ) : !hasAnyResults ? (
    @@ -344,7 +350,6 @@ export default function MainContent({
    ) : (
    - {/* 상위 검색결과 카드 */} {filter === "all" && topTrack && topSource && (

    상위 검색결과

    @@ -368,10 +373,11 @@ export default function MainContent({
    @@ -396,7 +402,7 @@ export default function MainContent({ )}
    -
      {shown.map((t) => renderTrackRow(t, s.kind, s.wide))}
    +
      {shown.map((t, i) => renderTrackRow(t, s.kind, s.wide, s.key, i))}
    ); })} diff --git a/page/src/components/player/PlayerBar.tsx b/page/src/components/player/PlayerBar.tsx index 529e3e8..cfcaa15 100644 --- a/page/src/components/player/PlayerBar.tsx +++ b/page/src/components/player/PlayerBar.tsx @@ -1,10 +1,12 @@ "use client"; -import { ChevronDown, ChevronUp, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react"; +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"; interface PlayerBarProps { player: PlayerState; + onShuffle: () => void; + canShuffle: boolean; isPlayerOpen: boolean; onTogglePlayer: () => void; } @@ -49,10 +51,10 @@ export function SeekBar({ player, className = "", thick = false }: { player: Pla * 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃] * 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다. */ -export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: PlayerBarProps) { +export default function PlayerBar({ player, onShuffle, canShuffle, isPlayerOpen, onTogglePlayer }: PlayerBarProps) { const { track, isPaused, volume, position, duration, canControl, isVolumeDragging, - togglePause, skip, beginVolumeDrag, dragVolume, commitVolume, toggleMute, + togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute, } = player; const title = track?.info?.title || "재생 중인 곡 없음"; @@ -94,10 +96,11 @@ export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: Play
    @@ -169,6 +172,17 @@ export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: Play
    + + - 지금 재생 중 + + {player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "지금 재생 중"} +
    @@ -107,10 +109,10 @@ export default function PlayerPage({ player, queue, onClose }: PlayerPageProps)
    diff --git a/page/src/components/player/QueueList.tsx b/page/src/components/player/QueueList.tsx index 2f58f1d..700eda9 100644 --- a/page/src/components/player/QueueList.tsx +++ b/page/src/components/player/QueueList.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; -import { ChevronDown, ChevronUp, GripVertical, Play, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, ChevronUp, GripVertical, ListX, Play, Shuffle, Trash2 } from "lucide-react"; import { Equalizer, Thumb } from "@/components/ui"; import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer"; import type { QueueState } from "@/hooks/useQueue"; @@ -20,8 +20,19 @@ interface QueueListProps { * - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다 */ export default function QueueList({ player, queue, variant }: QueueListProps) { - const { track: nowPlaying, isPaused } = player; - const { queue: items, moveTo, moveBy, playFrom, remove } = queue; + const { track: nowPlaying, isPaused, autoplay, toggleAutoplay } = player; + const { queue: items, moveTo, moveBy, playFrom, remove, shuffle, clear } = queue; + + // "모두 지우기"는 되돌릴 수 없어서 한 번 더 눌러야 실행한다(3초 안에). + const [confirmClear, setConfirmClear] = useState(false); + useEffect(() => { + if (!confirmClear) return; + const t = setTimeout(() => setConfirmClear(false), 3000); + return () => clearTimeout(t); + }, [confirmClear]); + + const totalMs = items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0); + const totalMin = Math.round(totalMs / 60000); const [draggingIndex, setDraggingIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -46,14 +57,65 @@ export default function QueueList({ player, queue, variant }: QueueListProps) { return (
    - {isPage && ( -
    -

    재생 목록

    - - {items.length > 0 ? `다음 트랙 ${items.length}곡` : ""} - + {/* 머리말: 곡 수·총 길이 + 섞기 / 모두 지우기 */} +
    +
    + {isPage &&

    재생 목록

    } +

    + {items.length > 0 + ? `다음 트랙 ${items.length}곡${totalMin > 0 ? ` · 약 ${totalMin}분` : ""}` + : "다음 트랙 없음"} +

    - )} +
    + + +
    +
    + + {/* 자동재생(유튜브 뮤직 "다음 트랙" 위의 스위치와 같은 자리) */} +
    + + 자동재생 + 대기열이 끝나면 비슷한 곡을 이어서 틀어요 + + +
    {/* 지금 재생 중 */} {nowPlaying && ( diff --git a/page/src/hooks/usePlayer.ts b/page/src/hooks/usePlayer.ts index aba42a6..dd2cb79 100644 --- a/page/src/hooks/usePlayer.ts +++ b/page/src/hooks/usePlayer.ts @@ -1,6 +1,7 @@ "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"; /** @@ -9,6 +10,7 @@ import type { DiscordServer, Track } from "@/types/music"; */ export function usePlayer(selectedServer: DiscordServer | null) { const { data: session } = useSession(); + const { showToast } = useToast(); // 재생 상태 const [track, setTrack] = useState(null); @@ -16,6 +18,9 @@ export function usePlayer(selectedServer: DiscordServer | null) { const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const [volume, setVolume] = useState(50); + // 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) · 봇이 들어가 있는 음성채널 이름 + const [autoplay, setAutoplay] = useState(false); + const [voiceChannelName, setVoiceChannelName] = useState(null); // 시간 및 진행도 const [position, setPosition] = useState(0); @@ -46,6 +51,12 @@ export function usePlayer(selectedServer: DiscordServer | null) { }); 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)); @@ -178,6 +189,37 @@ export function usePlayer(selectedServer: DiscordServer | null) { } }; + /** 이전 곡 버튼: 유튜브 뮤직처럼 지금 곡을 처음부터 다시 재생(봇에 이전 곡 기록이 없어서) */ + 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; @@ -301,7 +343,11 @@ export function usePlayer(selectedServer: DiscordServer | null) { duration, canControl, isVolumeDragging, + autoplay, + voiceChannelName, togglePause, + restart, + toggleAutoplay, skip, dragSeek, commitSeek, diff --git a/page/src/hooks/useQueue.ts b/page/src/hooks/useQueue.ts index 678ed2b..6b10fa5 100644 --- a/page/src/hooks/useQueue.ts +++ b/page/src/hooks/useQueue.ts @@ -198,7 +198,38 @@ export function useQueue(selectedServer: DiscordServer | null) { } }; - return { queue, moveTo, moveBy, playFrom, remove }; + /** 다음 트랙 섞기(봇이 섞고 queue_update 로 새 순서를 알려준다) */ + const shuffle = async () => { + if (!selectedServer) return; + if (queue.length < 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 (queue.length === 0) return; + await applyReorder([]); + }; + + return { queue, moveTo, moveBy, playFrom, remove, shuffle, clear }; } export type QueueState = ReturnType; diff --git a/page/src/hooks/useServers.ts b/page/src/hooks/useServers.ts index fe8f55c..f566b8b 100644 --- a/page/src/hooks/useServers.ts +++ b/page/src/hooks/useServers.ts @@ -1,45 +1,85 @@ "use client"; -import { useEffect, useState } from "react"; +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([]); const [isFetching, setIsFetching] = useState(false); + const [error, setError] = useState(null); + /** 캐시든 새로 받은 것이든 목록을 한 번이라도 손에 쥐었는지 */ + const [loaded, setLoaded] = useState(false); - useEffect(() => { - if (status !== "authenticated") return; - // eslint-disable-next-line react-hooks/set-state-in-effect + const load = useCallback((useCache: boolean) => { setIsFetching(true); - const cached = sessionStorage.getItem("filtered_servers"); - if (cached) { - try { - const parsed = JSON.parse(cached); - if (Array.isArray(parsed)) { - setServers(parsed); - setIsFetching(false); + 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); } - } catch { - // 캐시 손상 시 무시하고 아래 fetch로 새로 받는다. - sessionStorage.removeItem("filtered_servers"); } } fetch("/api/servers") - .then((res) => res.json()) - .then((data) => { + .then(async (res) => { + const data = await res.json().catch(() => null); if (Array.isArray(data)) { setServers(data); - sessionStorage.setItem("filtered_servers", JSON.stringify(data)); + sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)); + } else { + setError((data && (data.error || data.message)) || "서버 목록을 가져오지 못했습니다."); } }) - .catch((err) => console.error("서버 목록 불러오기 실패:", err)) - .finally(() => setIsFetching(false)); - }, [status]); + .catch(() => setError("서버와 통신할 수 없습니다.")) + .finally(() => { + setIsFetching(false); + setLoaded(true); + }); + }, []); - return { servers, isFetching }; + 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) */ diff --git a/page/src/lib/auth.ts b/page/src/lib/auth.ts new file mode 100644 index 0000000..1886bac --- /dev/null +++ b/page/src/lib/auth.ts @@ -0,0 +1,21 @@ +import { getServerSession, type Session } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +/** 서버 컴포넌트용: 로그인 안 돼 있으면 로그인 페이지로 보낸다(돌아올 주소를 함께 넘긴다). */ +export async function requirePageSession(callbackUrl: string): Promise { + const session = (await getServerSession(authOptions)) as Session | null; + if (!session?.user?.id) redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`); + return session; +} + +/** 로그인 여부만 확인(로그인 페이지에서 이미 로그인된 사람을 넘길 때) */ +export async function getPageSession(): Promise { + 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; +} diff --git a/page/src/lib/recent.ts b/page/src/lib/recent.ts new file mode 100644 index 0000000..9070beb --- /dev/null +++ b/page/src/lib/recent.ts @@ -0,0 +1,24 @@ +/** 최근 검색어(브라우저에만 저장). 상단 검색창 목록과 서버 홈의 "최근 검색" 칩이 같이 쓴다. */ +const RECENT_KEY = "musicbot:recent-searches"; +export const RECENT_MAX = 8; + +export function readRecent(): string[] { + try { + const v = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").slice(0, RECENT_MAX) : []; + } catch { + return []; + } +} + +export function writeRecent(list: string[]) { + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX))); + window.dispatchEvent(new Event(RECENT_EVENT)); + } catch { + // 저장 불가면 무시 + } +} + +/** 최근 검색어가 바뀌었음을 같은 탭의 다른 컴포넌트에 알린다 */ +export const RECENT_EVENT = "musicbot:recent-changed"; diff --git a/page/src/lib/url.ts b/page/src/lib/url.ts new file mode 100644 index 0000000..5534302 --- /dev/null +++ b/page/src/lib/url.ts @@ -0,0 +1,36 @@ +/** + * 검색창에 붙여넣은 값이 봇이 바로 불러올 수 있는 음악 주소인지 판단한다. + * (유튜브·유튜브 뮤직·스포티파이 — 곡/재생목록/앨범 모두 봇의 resolveQuery 가 처리) + */ +const MUSIC_HOSTS = [ + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "music.youtube.com", + "youtu.be", + "open.spotify.com", +]; + +export function isMusicUrl(value: string): boolean { + const v = value.trim(); + if (!/^https?:\/\//i.test(v)) return false; + try { + return MUSIC_HOSTS.includes(new URL(v).hostname.toLowerCase()); + } catch { + return false; + } +} + +/** 재생목록·앨범처럼 여러 곡을 담은 주소인지(안내 문구용) */ +export function isCollectionUrl(value: string): boolean { + try { + const u = new URL(value.trim()); + return ( + u.searchParams.has("list") || + /\/(playlist|album|artist)\//.test(u.pathname) || + u.pathname === "/playlist" + ); + } catch { + return false; + } +}