feat(page): 대시보드 디자인을 유튜브 뮤직(웹) 다크 테마로 전면 교체
- 상단 바: ☰ + 로고 + 반투명 검색창 + 프로필 메뉴(좁은 화면은 돋보기 → 전체 폭 검색) - 왼쪽 메뉴: 홈/서버 목록 + 봇이 있는 내 서버 목록, 넓은 화면 펼침(240)/접힘(72), 좁은 화면 서랍 - 홈: 서버를 원형 카드로, 서버 페이지: 앨범 페이지 형태(왼쪽 아이콘·정보, 오른쪽 재생 목록) - 검색: 필터 칩(전체/노래/동영상/Spotify) + 상위 검색결과 카드 + 구역별 목록(모두 표시) - 하단 재생바: 위쪽 빨간 진행선, 조작·시간 / 곡 정보 / hover 볼륨·재생 화면(⌃) - 재생 화면: 큰 앨범아트 + 다음 트랙 탭, 좁은 화면은 전체 화면 플레이어 - 재생/대기열/서버 로직을 hooks(usePlayer/useQueue/useServers)로 분리해 구독을 한 번만 연다 - 스낵바 알림, 파비콘, README 화면·버튼 문서 갱신 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,65 +78,65 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
|||||||
|
|
||||||
## 5. UI 구성 (`src/`)
|
## 5. UI 구성 (`src/`)
|
||||||
|
|
||||||
- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지. `components/Providers.tsx`(세션 등), `ToastProvider.tsx`.
|
디자인은 **유튜브 뮤직(웹) 다크 테마**를 그대로 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`,
|
||||||
- `components/layout/` — `TopNav`(상단/서버선택·로그인), `LeftSidebar`(탐색).
|
보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`).
|
||||||
- `components/player/` — `PlayerBar`(재생바/볼륨/탐색), `MainContent`(검색·결과), `QueueSidebar`(대기열).
|
|
||||||
|
- `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` 등 공용 도메인 타입.
|
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
|
||||||
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
|
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
|
||||||
|
|
||||||
### 5-1. 화면(모드)별 사용 흐름
|
### 5-1. 화면 구성
|
||||||
|
|
||||||
화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지).
|
화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지).
|
||||||
|
|
||||||
| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 |
|
| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 |
|
||||||
|------|-----------|------------------|----------------------|
|
|------|-----------|------------------|----------------------|
|
||||||
| 로그인 전 | 첫 방문 | 중앙 로그인 카드 | 디스코드 로그인. 사이드바/대기열/재생바는 렌더링하지 않는다 |
|
| 로그인 전 | 첫 방문 | 가운데 로그인 안내, 우상단 "로그인" | 디스코드 로그인. 검색·재생바는 잠김/숨김 |
|
||||||
| `SERVER_LIST` | 로그인 직후, 홈 버튼, "다른 서버 선택" | 봇이 있는 내 서버 카드 목록 | 서버 선택. 서버를 고르기 전에는 검색·재생이 잠겨 있다 |
|
| `SERVER_LIST` (홈) | 로그인 직후, 왼쪽 메뉴 "서버 목록" | 봇이 있는 내 서버를 원형 카드로(유튜브 뮤직 아티스트 줄 형태) | 서버 선택 |
|
||||||
| `SERVER_DETAIL` | 서버 카드 클릭 | 서버 이름/아이콘, 사용 안내 배너, 서버 ID·내 권한 | 검색 시작점. 좌측에 조작 중인 서버 표시 |
|
| `SERVER_DETAIL` (서버 페이지) | 서버 카드/왼쪽 메뉴의 서버, 로고·"홈" | 유튜브 뮤직 앨범 페이지 형태: 왼쪽 서버 아이콘·이름·권한·사용 안내, 오른쪽 "재생 목록"(지금 재생 중 + 다음 트랙) | 검색 시작, 대기열 조작 |
|
||||||
| `SEARCH_RESULT` | 검색창에 입력 후 Enter | Spotify / YouTube Music / YouTube 영상 3개 구역의 곡 리스트 행(번호·썸네일·제목/아티스트·재생시간) | 행을 눌러 대기열에 추가 |
|
| `SEARCH_RESULT` | 검색창 Enter | 필터 칩(전체/노래/동영상/Spotify), 상위 검색결과 카드, 구역별 곡 목록(전체 탭은 5곡씩 + "모두 표시") | 곡을 눌러 대기열에 추가 |
|
||||||
| 대기열 패널 | (넓은 화면) 기본 펼침, 재생바 우측 대기열 버튼으로 접기/펴기 · (좁은 화면) 기본 접힘, 우하단 초록 버튼·재생바 목록 버튼으로 열기 | "지금 재생 중" + "다음 재생 목록" | 순서 변경, 삭제, 특정 곡부터 재생 |
|
| 재생 화면 | 재생바의 곡 정보·⌃ 버튼, 서버 페이지의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + "다음 트랙" 탭 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 | 대기열 조작, (좁은 화면) 재생 조작 |
|
||||||
|
|
||||||
|
- 재생바는 유튜브 뮤직처럼 **재생 중인 곡이 있을 때만** 나타난다.
|
||||||
|
- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침(240px)/접힘(아이콘만 72px), 좁은 화면은 ☰ 로 서랍을 연다.
|
||||||
|
- 좁은 화면 상단 바는 검색창 대신 돋보기 버튼 → 누르면 바 전체가 검색창이 된다.
|
||||||
|
|
||||||
### 5-2. 버튼·조작 동작 정리
|
### 5-2. 버튼·조작 동작 정리
|
||||||
|
|
||||||
| 위치 | 요소 | 동작 | 비활성 조건 |
|
| 위치 | 요소 | 동작 | 비활성 조건 |
|
||||||
|------|------|------|-------------|
|
|------|------|------|-------------|
|
||||||
| 상단 | 홈 | 서버를 고른 상태면 서버 상세로, 아니면 서버 목록으로. 검색어 초기화 | 로그아웃 상태에선 숨김 |
|
| 상단 | ☰ | 왼쪽 메뉴 펼침/접힘(좁은 화면은 서랍) | — |
|
||||||
| 상단 | 검색창 | Enter 로 검색 실행 → `SEARCH_RESULT` | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) |
|
| 상단 | 로고 | 서버를 고른 상태면 서버 페이지로, 아니면 홈으로 | — |
|
||||||
| 상단 | 프로필/로그아웃 | 세션 종료 | — |
|
| 상단 | 검색창 | Enter 로 검색 → `SEARCH_RESULT`. ✕ 로 검색어 지우기 | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) |
|
||||||
| 본문 | 서버 카드 | 서버 선택 → 서버 상세 | — |
|
| 상단 | 프로필 사진 | 계정 메뉴(이름 + 로그아웃). 바깥 클릭/Esc 로 닫힘 | — |
|
||||||
| 본문 | 곡 행(행 전체가 버튼) | `POST /api/player/play` → 대기열 추가. 성공/실패를 토스트로 알림. 넓은 화면은 hover 시 번호 자리에 재생 아이콘이, 좁은 화면은 썸네일에 재생 아이콘이 항상 보인다 | 서버 미선택·미로그인 |
|
| 왼쪽 메뉴 | 홈 / 서버 목록 / 내 서버 | 서버 페이지 / 서버 고르기 / 해당 서버로 바로 전환 | — |
|
||||||
| 재생바 | 재생/일시정지 | `POST /api/player/pause` (낙관적 UI, 실패 시 롤백) | 봇 세션 없음/재생 중 아님 |
|
| 서버 페이지 | 노래 검색 | 상단 검색창에 포커스(좁은 화면은 검색 모드로 전환) | — |
|
||||||
| 재생바 | 다음 곡 | `POST /api/player/skip` | 〃 |
|
| 서버 페이지 | 목록 아이콘 | 재생 화면 열기 | — |
|
||||||
|
| 검색 | 필터 칩 | 해당 출처 결과만 전부 보기 | 결과 0건인 칩 |
|
||||||
|
| 검색 | 곡 행(행 전체가 버튼) / 상위 검색결과 "재생" | `POST /api/player/play` → 대기열 추가(재생 중이 아니면 바로 재생). 스낵바로 결과 알림. 썸네일 위 재생 아이콘은 마우스면 hover 때, 터치 화면이면 항상 | 서버 미선택·미로그인 |
|
||||||
|
| 재생바 | 진행바(위쪽 가장자리 빨간 선) | hover 시 굵어지고 손잡이 표시. 드래그/클릭/키보드로 탐색 → `POST /api/player/seek` | 봇 세션 없음/재생 중 아님 |
|
||||||
|
| 재생바 | 재생/일시정지 · 다음 곡 | `POST /api/player/pause`(낙관적 UI, 실패 시 롤백) · `POST /api/player/skip` | 〃 |
|
||||||
| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 |
|
| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 |
|
||||||
| 재생바 | 진행바 | 드래그/클릭으로 탐색 → `POST /api/player/seek`. 좁은 화면에서는 재생바 상단에 전체 폭으로 배치 | 〃 |
|
| 재생바 | 곡 정보 / ⌃ | 재생 화면 열기·닫기 | — |
|
||||||
| 재생바 | 스피커 아이콘 | 음소거/해제 토글(해제 시 직전 볼륨 복원) | 〃 |
|
| 재생바 | 스피커 아이콘 | 클릭 = 음소거/해제(해제 시 직전 볼륨 복원). hover 시 왼쪽에 볼륨 슬라이더가 펼쳐짐 | 봇 세션 없음/재생 중 아님 |
|
||||||
| 재생바 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 |
|
| 재생바·재생 화면 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 |
|
||||||
| 재생바 | 목록 아이콘(컨트롤 옆, 좁은 화면) | 대기열 슬라이드 패널 열기/닫기 | — |
|
| 다음 트랙 | 곡 | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다(첫 곡이면 다음 곡으로 넘기기만) | — |
|
||||||
| 재생바 | 목록 아이콘(우측 볼륨 옆, 넓은 화면) | 우측 대기열 컬럼 접기/펴기. 펼쳐져 있으면 초록색으로 표시 | — |
|
| 다음 트랙 | 손잡이 드래그 | 순서 변경(마우스 전용, hover 시 표시) | — |
|
||||||
| 대기열 | 곡(제목 영역) | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다 | 첫 곡은 이미 다음 곡이라 동작 없음 |
|
| 다음 트랙 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (터치 화면 전용, 드래그 대체) | 맨 위/맨 아래 |
|
||||||
| 대기열 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (좁은 화면 전용, 드래그 대체) | 맨 위/맨 아래 |
|
| 다음 트랙 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지). 마우스면 hover 때, 터치 화면이면 항상 표시 | — |
|
||||||
| 대기열 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지) | — |
|
| 재생 화면 | ⌄ / Esc | 재생 화면 닫기 | — |
|
||||||
| 대기열 | 드래그 핸들 | 드래그로 순서 변경(넓은 화면 전용) | — |
|
|
||||||
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 |
|
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 |
|
||||||
|
|
||||||
### 5-3. 다른 음악 서비스와 비교해 맞춘 점
|
미지원(봇 API 한계): 이전 곡, 반복 재생, 셔플, 좋아요/가사. 유튜브 뮤직에 있는 이 버튼들은 동작하지 않는 가짜 버튼이 되므로 넣지 않았다.
|
||||||
|
|
||||||
스포티파이 웹 플레이어 / 유튜브 뮤직 / 애플 뮤직에서 공통으로 쓰이는 관행을 기준으로 삼았다.
|
|
||||||
|
|
||||||
| 관행 | 예전 | 현재 |
|
|
||||||
|------|------|------|
|
|
||||||
| 곡 검색 결과 표시 | 그리드 카드 | 리스트 행(번호·썸네일·제목/아티스트·재생시간). 그리드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태이고, 개별 곡은 두 서비스 모두 행 형식이다 |
|
|
||||||
| 대기열 접기/펴기 | 넓은 화면에서 항상 고정 | 재생바 우측 대기열 버튼으로 접고 펼 수 있음(스포티파이와 같은 위치) |
|
|
||||||
| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 |
|
|
||||||
| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 |
|
|
||||||
| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) |
|
|
||||||
| 스피커 아이콘 클릭 = 음소거 | 장식 아이콘 | 토글 버튼 |
|
|
||||||
| 키보드 단축키(Space/M/화살표) | 없음 | 지원 |
|
|
||||||
| 재생시간 표기 | 없음 | 검색 결과 행 오른쪽에 표시 |
|
|
||||||
| 터치 조작 크기(44px 내외) | 재생 32px·삭제 24px·진행바 1px | 44~48px, 진행바 히트영역 확대 |
|
|
||||||
| hover 없이도 조작 가능 | 삭제·재생 버튼이 hover 시에만 노출 | 상시 노출 |
|
|
||||||
|
|
||||||
미지원(봇 API 한계): 이전 곡, 반복 재생, 특정 위치로의 셔플 재생. 필요하면 봇 쪽 액션 추가가 선행돼야 한다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */
|
/*
|
||||||
|
* 유튜브 뮤직(웹) 다크 테마 색상.
|
||||||
|
* - 배경 #030303, 재생바/메뉴 #212121, 강조 빨강 #ff0000
|
||||||
|
* - 보조 글자는 흰색 70%/50% 투명도(유튜브 뮤직과 같은 방식)
|
||||||
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #171717; /* neutral-900 */
|
--color-background: #030303;
|
||||||
--color-foreground: #ffffff;
|
--color-foreground: #ffffff;
|
||||||
|
--color-yt-bg: #030303;
|
||||||
|
--color-yt-raised: #212121;
|
||||||
|
--color-yt-menu: #282828;
|
||||||
|
--color-yt-red: #ff0000;
|
||||||
|
--color-yt-chip: rgb(255 255 255 / 0.1);
|
||||||
|
--color-yt-chip-hover: rgb(255 255 255 / 0.2);
|
||||||
|
--color-yt-line: rgb(255 255 255 / 0.1);
|
||||||
|
--font-sans: Roboto, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -12,19 +27,54 @@ body {
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 스크롤바 예쁘게 숨기기 (선택 사항) */
|
/* 유튜브 뮤직처럼 얇고 어두운 스크롤바 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(255 255 255 / 0.2) transparent;
|
||||||
|
}
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #3f3f46;
|
background: rgb(255 255 255 / 0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #52525b;
|
background: rgb(255 255 255 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 가로 스크롤 칩 줄은 스크롤바를 숨긴다 */
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 재생 중 표시(이퀄라이저 막대) */
|
||||||
|
@keyframes yt-eq {
|
||||||
|
0%, 100% { transform: scaleY(0.3); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
.yt-eq > span {
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: currentColor;
|
||||||
|
transform-origin: bottom;
|
||||||
|
animation: yt-eq 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.yt-eq > span:nth-child(2) { animation-delay: -0.3s; }
|
||||||
|
.yt-eq > span:nth-child(3) { animation-delay: -0.6s; }
|
||||||
|
.yt-eq.paused > span { animation-play-state: paused; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.yt-eq > span { animation: none; transform: scaleY(0.6); }
|
||||||
|
}
|
||||||
|
|||||||
1
page/src/app/icon.svg
Normal file
1
page/src/app/icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#f00"/><circle cx="12" cy="12" r="6.6" fill="none" stroke="#fff" stroke-width="1.1"/><path d="M10 9.2v5.6l4.9-2.8z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 233 B |
@@ -2,151 +2,132 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { ListMusic, X } from "lucide-react";
|
|
||||||
import TopNav from "@/components/layout/TopNav";
|
import TopNav from "@/components/layout/TopNav";
|
||||||
import LeftSidebar from "@/components/layout/LeftSidebar";
|
import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||||
import MainContent from "@/components/player/MainContent";
|
import MainContent from "@/components/player/MainContent";
|
||||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
|
||||||
import PlayerBar from "@/components/player/PlayerBar";
|
import PlayerBar from "@/components/player/PlayerBar";
|
||||||
import type { DiscordServer, Track } from "@/types/music";
|
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 type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유튜브 뮤직(웹) 구조:
|
||||||
|
* 상단 바(☰ 로고 · 검색 · 프로필)
|
||||||
|
* [왼쪽 메뉴] [본문] ← 재생 화면(⌃)은 본문 위를 덮는다
|
||||||
|
* 하단 재생바
|
||||||
|
*/
|
||||||
export default function MusicPlayerLayout() {
|
export default function MusicPlayerLayout() {
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
// 대기열 표시 상태는 화면 크기별로 기본값이 달라서 두 개로 나눠 둔다.
|
// 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍
|
||||||
// (창 크기를 읽어 한 상태로 합치면 SSR·하이드레이션이 어긋난다)
|
const [guideExpanded, setGuideExpanded] = useState(true);
|
||||||
// · 좁은 화면: 기본 접힘 → 버튼으로 슬라이드 패널을 연다
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
// · 넓은 화면: 기본 펼침 → 재생바의 대기열 버튼으로 접을 수 있다
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
|
||||||
const [isQueueDocked, setIsQueueDocked] = useState(true);
|
const { servers, isFetching } = useServers();
|
||||||
// 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함).
|
// 재생 상태·대기열은 재생바, 재생 화면, 서버 페이지가 같이 쓰므로 여기서 한 번만 구독한다.
|
||||||
const [nowPlaying, setNowPlaying] = useState<Track | null>(null);
|
const player = usePlayer(selectedServer);
|
||||||
const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []);
|
const queue = useQueue(selectedServer);
|
||||||
|
|
||||||
const isAuthed = status === "authenticated";
|
const isAuthed = status === "authenticated";
|
||||||
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
|
// 서버를 고르기 전에는 재생이 의미가 없고, 유튜브 뮤직처럼 곡이 있을 때만 재생바를 띄운다.
|
||||||
const showPlayback = isAuthed && !!selectedServer;
|
const showPlayback = isAuthed && !!selectedServer;
|
||||||
|
const showPlayerBar = showPlayback && !!player.track;
|
||||||
|
|
||||||
const handleHome = () => {
|
const handleHome = () => {
|
||||||
|
setIsPlayerOpen(false);
|
||||||
if (selectedServer) {
|
if (selectedServer) {
|
||||||
setViewMode("SERVER_DETAIL");
|
setViewMode("SERVER_DETAIL");
|
||||||
} else {
|
} else {
|
||||||
setViewMode("SERVER_LIST");
|
setViewMode("SERVER_LIST");
|
||||||
setSelectedServer(null);
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (query: string) => {
|
const handleSearch = (query: string) => {
|
||||||
|
setIsPlayerOpen(false);
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
setViewMode("SEARCH_RESULT");
|
setViewMode("SEARCH_RESULT");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectServer = (server: DiscordServer) => {
|
const handleSelectServer = (server: DiscordServer) => {
|
||||||
|
setIsPlayerOpen(false);
|
||||||
setSelectedServer(server);
|
setSelectedServer(server);
|
||||||
setViewMode("SERVER_DETAIL");
|
setViewMode("SERVER_DETAIL");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackToServers = () => {
|
const handleBackToServers = () => {
|
||||||
|
setIsPlayerOpen(false);
|
||||||
setViewMode("SERVER_LIST");
|
setViewMode("SERVER_LIST");
|
||||||
setSelectedServer(null);
|
setSelectedServer(null);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setIsQueueOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleGuide = () => {
|
||||||
|
// 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기
|
||||||
|
if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v);
|
||||||
|
else setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlayer = useCallback(() => setIsPlayerOpen(false), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col overflow-hidden bg-neutral-950 font-sans text-white">
|
<div className="flex h-dvh flex-col overflow-hidden bg-yt-bg font-sans text-white">
|
||||||
<TopNav
|
<TopNav
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onHome={handleHome}
|
onHome={handleHome}
|
||||||
|
onToggleGuide={handleToggleGuide}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex flex-1 overflow-hidden">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
{/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */}
|
<LeftSidebar
|
||||||
{showPlayback && (
|
expanded={guideExpanded}
|
||||||
<LeftSidebar selectedServer={selectedServer} onBackToServers={handleBackToServers} />
|
drawerOpen={drawerOpen}
|
||||||
)}
|
onCloseDrawer={() => setDrawerOpen(false)}
|
||||||
|
isAuthed={isAuthed}
|
||||||
<MainContent
|
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
setViewMode={setViewMode}
|
servers={servers}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
setSelectedServer={setSelectedServer}
|
onHome={handleHome}
|
||||||
searchQuery={searchQuery}
|
onServers={handleBackToServers}
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
onSelectServer={handleSelectServer}
|
onSelectServer={handleSelectServer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showPlayback && (
|
<div className="relative flex min-w-0 flex-1">
|
||||||
<>
|
<MainContent
|
||||||
{/* 모바일에서 대기열이 열렸을 때 뒤쪽을 덮는 배경(탭하면 닫힘) */}
|
viewMode={viewMode}
|
||||||
{isQueueOpen && (
|
selectedServer={selectedServer}
|
||||||
<button
|
searchQuery={searchQuery}
|
||||||
type="button"
|
servers={servers}
|
||||||
aria-label="대기열 닫기"
|
isFetchingServers={isFetching}
|
||||||
onClick={() => setIsQueueOpen(false)}
|
onSelectServer={handleSelectServer}
|
||||||
className="absolute inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
|
onOpenPlayer={() => setIsPlayerOpen(true)}
|
||||||
/>
|
showQueue={!isPlayerOpen}
|
||||||
)}
|
player={player}
|
||||||
|
queue={queue}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 대기열: 데스크톱은 고정 컬럼, 모바일은 슬라이드 패널 (인스턴스는 하나만 유지) */}
|
{showPlayback && isPlayerOpen && (
|
||||||
<div
|
<PlayerPage player={player} queue={queue} onClose={closePlayer} />
|
||||||
className={`absolute inset-y-0 right-0 z-40 w-[22rem] max-w-[88%] transform border-l border-neutral-800 bg-neutral-950 shadow-2xl transition-transform duration-300 ease-out
|
)}
|
||||||
${isQueueOpen ? "translate-x-0" : "translate-x-full"}
|
</div>
|
||||||
${isQueueDocked
|
|
||||||
? "lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none"
|
|
||||||
: "lg:hidden"}`}
|
|
||||||
>
|
|
||||||
<QueueSidebar
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
nowPlaying={nowPlaying}
|
|
||||||
onClose={() => setIsQueueOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPlayback && (
|
{showPlayerBar && (
|
||||||
<PlayerBar
|
<PlayerBar
|
||||||
selectedServer={selectedServer}
|
player={player}
|
||||||
onToggleQueue={() => setIsQueueOpen((v) => !v)}
|
isPlayerOpen={isPlayerOpen}
|
||||||
isQueueOpen={isQueueOpen}
|
onTogglePlayer={() => setIsPlayerOpen((v) => !v)}
|
||||||
onToggleDock={() => setIsQueueDocked((v) => !v)}
|
|
||||||
isQueueDocked={isQueueDocked}
|
|
||||||
onNowPlayingChange={handleNowPlayingChange}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 모바일 전용 대기열 열기 버튼: 플레이어바에도 있지만, 재생 전에도 접근할 수 있게 둔다. */}
|
|
||||||
{showPlayback && !isQueueOpen && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsQueueOpen(true)}
|
|
||||||
className="fixed bottom-24 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-black shadow-xl transition hover:bg-green-400 active:scale-95 lg:hidden"
|
|
||||||
aria-label="대기열 열기"
|
|
||||||
>
|
|
||||||
<ListMusic size={24} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 모바일 패널 닫기 버튼(패널 위에 떠 있음) */}
|
|
||||||
{showPlayback && isQueueOpen && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsQueueOpen(false)}
|
|
||||||
className="fixed right-4 top-20 z-50 flex h-11 w-11 items-center justify-center rounded-full bg-neutral-800 text-white shadow-lg transition hover:bg-neutral-700 lg:hidden"
|
|
||||||
aria-label="대기열 닫기"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
<ToastContext.Provider value={{ showToast }}>
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* 화면 최상단에 떠있는 실제 토스트 UI */}
|
{/* 유튜브 뮤직 스낵바: 왼쪽 아래(재생바 위)에 뜨는 어두운 알림 */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-10 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-full shadow-2xl flex items-center gap-3 z-[9999] animate-in fade-in slide-in-from-bottom-5 duration-300 font-medium text-sm text-white ${
|
role={toast.type === "error" ? "alert" : "status"}
|
||||||
toast.type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
className="fixed bottom-24 left-4 right-4 z-[9999] flex items-center gap-3 rounded bg-yt-menu px-4 py-3 text-sm text-white shadow-2xl ring-1 ring-white/10 sm:left-6 sm:right-auto sm:max-w-md"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
{toast.type === "error" && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-yt-red" aria-hidden />}
|
||||||
{toast.message}
|
{toast.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,61 +1,165 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ChevronLeft, Info, Server } from "lucide-react";
|
import { Home, Library, Menu } from "lucide-react";
|
||||||
import type { DiscordServer } from "@/types/music";
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
import type { ViewMode } from "@/app/page";
|
||||||
|
import { Logo, ServerAvatar } from "@/components/ui";
|
||||||
|
import { permissionLabel } from "@/hooks/useServers";
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
|
/** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */
|
||||||
|
expanded: boolean;
|
||||||
|
/** 좁은 화면에서 서랍처럼 열린 상태 */
|
||||||
|
drawerOpen: boolean;
|
||||||
|
onCloseDrawer: () => void;
|
||||||
|
isAuthed: boolean;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
servers: DiscordServer[];
|
||||||
selectedServer: DiscordServer | null;
|
selectedServer: DiscordServer | null;
|
||||||
onBackToServers: () => void;
|
onHome: () => void;
|
||||||
|
onServers: () => void;
|
||||||
|
onSelectServer: (server: DiscordServer) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 좌측 보조 패널. 예전에는 안내 문구만 있는 죽은 공간이었어서,
|
* 유튜브 뮤직 왼쪽 메뉴(가이드).
|
||||||
* "지금 어느 서버를 조작 중인지"와 서버 전환 동선을 넣어 실제로 쓰이게 했다.
|
* - 위: 홈 / 서버 목록(유튜브 뮤직의 "보관함" 자리)
|
||||||
* 좁은 화면에서는 숨긴다(같은 정보가 본문/플레이어에 이미 있다).
|
* - 아래: 재생목록 자리에 "봇이 있는 내 서버" 목록 → 바로 서버 전환
|
||||||
|
* 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다.
|
||||||
*/
|
*/
|
||||||
export default function LeftSidebar({ selectedServer, onBackToServers }: LeftSidebarProps) {
|
export default function LeftSidebar({
|
||||||
|
expanded,
|
||||||
|
drawerOpen,
|
||||||
|
onCloseDrawer,
|
||||||
|
isAuthed,
|
||||||
|
viewMode,
|
||||||
|
servers,
|
||||||
|
selectedServer,
|
||||||
|
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 full = (afterClick?: () => void) => (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<ul className="flex flex-col gap-1 px-2 pt-2">
|
||||||
|
{nav.map(({ key, label, icon: Icon, active, onClick }) => (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onClick();
|
||||||
|
afterClick?.();
|
||||||
|
}}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`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"}`}
|
||||||
|
>
|
||||||
|
<Icon size={24} strokeWidth={active ? 2.2 : 1.6} aria-hidden />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{isAuthed && (
|
||||||
|
<>
|
||||||
|
<hr className="mx-4 my-4 border-yt-line" />
|
||||||
|
<p className="px-6 pb-2 text-sm font-medium text-white">내 서버</p>
|
||||||
|
<ul className="flex-1 overflow-y-auto px-2 pb-4">
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<li className="px-4 py-2 text-xs leading-relaxed text-white/50">
|
||||||
|
음악 봇이 있는 서버가 여기에 표시됩니다.
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
servers.map((server) => {
|
||||||
|
const active = selectedServer?.id === server.id;
|
||||||
|
return (
|
||||||
|
<li key={server.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectServer(server);
|
||||||
|
afterClick?.();
|
||||||
|
}}
|
||||||
|
aria-current={active ? "true" : undefined}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-4 py-2 text-left transition-colors
|
||||||
|
${active ? "bg-white/10" : "hover:bg-white/10"}`}
|
||||||
|
>
|
||||||
|
<ServerAvatar server={server} className="h-8 w-8" iconSize={14} size={64} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm text-white">{server.name}</span>
|
||||||
|
<span className="block truncate text-xs text-white/70">{permissionLabel(server)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-60 flex-shrink-0 flex-col gap-4 border-r border-neutral-800 bg-neutral-950 p-4 lg:flex">
|
<>
|
||||||
<p className="px-1 text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
{/* 넓은 화면: 고정 메뉴(펼침 240px / 접힘 72px) */}
|
||||||
조작 중인 서버
|
<aside
|
||||||
</p>
|
className={`hidden flex-shrink-0 border-r border-yt-line bg-yt-bg lg:block ${expanded ? "w-60" : "w-[72px]"}`}
|
||||||
|
aria-label="메뉴"
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
|
||||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-800">
|
|
||||||
{selectedServer?.icon ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Server size={20} className="text-neutral-500" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-semibold">
|
|
||||||
{selectedServer?.name ?? "선택 안 됨"}
|
|
||||||
</span>
|
|
||||||
<span className="block text-xs text-neutral-500">음악 봇 연결됨</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onBackToServers}
|
|
||||||
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm text-neutral-300 transition-colors hover:bg-neutral-900 hover:text-white"
|
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} aria-hidden />
|
{expanded ? (
|
||||||
다른 서버 선택
|
full()
|
||||||
</button>
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 px-1 pt-1">
|
||||||
|
{nav.map(({ key, label, icon: Icon, active, onClick }) => (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`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"}`}
|
||||||
|
>
|
||||||
|
<Icon size={24} strokeWidth={active ? 2.2 : 1.6} aria-hidden />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="mt-auto rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
|
{/* 좁은 화면: 서랍 메뉴 */}
|
||||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-neutral-300">
|
{drawerOpen && (
|
||||||
<Info size={14} aria-hidden /> 사용 방법
|
<div className="fixed inset-0 z-[60] lg:hidden">
|
||||||
</p>
|
<button
|
||||||
<p className="text-xs leading-relaxed text-neutral-500">
|
type="button"
|
||||||
디스코드 음성 채널에 먼저 들어간 뒤, 위 검색창에서 노래를 찾아 재생 버튼을 누르세요.
|
aria-label="메뉴 닫기"
|
||||||
</p>
|
onClick={onCloseDrawer}
|
||||||
</div>
|
className="absolute inset-0 bg-black/60"
|
||||||
</aside>
|
/>
|
||||||
|
<aside className="absolute inset-y-0 left-0 flex w-60 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
import { ArrowLeft, LogOut, Menu, Search, X } from "lucide-react";
|
||||||
import { signIn, signOut, useSession } from "next-auth/react";
|
import { signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { DiscordServer } from "@/types/music";
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
import { Logo } from "@/components/ui";
|
||||||
|
|
||||||
|
/** 다른 화면(서버 페이지의 "노래 검색" 버튼 등)에서 검색창을 열 때 쓰는 이벤트 이름 */
|
||||||
|
export const FOCUS_SEARCH_EVENT = "musicbot:focus-search";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
|
onToggleGuide: () => void;
|
||||||
selectedServer: DiscordServer | null;
|
selectedServer: DiscordServer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
|
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
|
||||||
function Avatar({
|
function Avatar({ user, className = "h-8 w-8 text-sm" }: { user: { name?: string | null; image?: string | null }; className?: string }) {
|
||||||
user,
|
|
||||||
size,
|
|
||||||
}: {
|
|
||||||
user: { name?: string | null; image?: string | null };
|
|
||||||
size: "sm" | "md";
|
|
||||||
}) {
|
|
||||||
const box = size === "sm" ? "h-7 w-7 text-xs" : "h-9 w-9 text-sm";
|
|
||||||
if (user.image) {
|
if (user.image) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={user.image} alt="" className={`${box} rounded-full object-cover`} />
|
<img src={user.image} alt="" className={`${className} rounded-full object-cover`} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`${box} flex items-center justify-center rounded-full bg-neutral-700 font-semibold text-white`}
|
className={`${className} flex items-center justify-center rounded-full bg-[#7b1fa2] font-medium text-white`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
|
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
|
||||||
@@ -35,22 +33,55 @@ function Avatar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
/**
|
||||||
|
* 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
|
||||||
|
* 좁은 화면에서는 검색창 대신 돋보기 버튼을 두고, 누르면 바 전체가 검색창으로 바뀐다(유튜브 뮤직 모바일과 같은 방식).
|
||||||
|
*/
|
||||||
|
export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer }: TopNavProps) {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [term, setTerm] = useState("");
|
const [term, setTerm] = useState("");
|
||||||
|
const [mobileSearch, setMobileSearch] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isAuthed = status === "authenticated";
|
const isAuthed = status === "authenticated";
|
||||||
const canSearch = isAuthed && !!selectedServer;
|
const canSearch = isAuthed && !!selectedServer;
|
||||||
|
|
||||||
const submitSearch = () => {
|
const submitSearch = () => {
|
||||||
if (canSearch && term.trim()) onSearch(term.trim());
|
if (canSearch && term.trim()) {
|
||||||
|
onSearch(term.trim());
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setMobileSearch(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
// 서버 페이지의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
|
||||||
if (e.key === "Enter") submitSearch();
|
useEffect(() => {
|
||||||
};
|
const focus = () => {
|
||||||
|
setMobileSearch(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
};
|
||||||
|
window.addEventListener(FOCUS_SEARCH_EVENT, focus);
|
||||||
|
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
// 프로필 메뉴: 바깥을 누르거나 Esc 로 닫는다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false);
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const handleLogoClick = () => {
|
||||||
setTerm("");
|
setTerm("");
|
||||||
onHome();
|
onHome();
|
||||||
};
|
};
|
||||||
@@ -60,76 +91,141 @@ export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps
|
|||||||
? "로그인하면 검색할 수 있어요"
|
? "로그인하면 검색할 수 있어요"
|
||||||
: !selectedServer
|
: !selectedServer
|
||||||
? "먼저 서버를 선택해주세요"
|
? "먼저 서버를 선택해주세요"
|
||||||
: "노래 제목이나 유튜브·스포티파이 주소";
|
: "노래, 아티스트, 유튜브·스포티파이 주소 검색";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="z-20 flex-shrink-0 border-b border-neutral-800 bg-neutral-950">
|
<header className="z-40 flex-shrink-0 border-b border-yt-line bg-yt-bg">
|
||||||
<nav className="flex h-16 items-center gap-2 px-3 sm:gap-4 sm:px-6">
|
<nav className="relative flex h-16 items-center gap-2 px-2 sm:px-4">
|
||||||
{/* 로고: 좁은 화면에서는 아이콘만 남겨 검색창 폭을 확보한다. */}
|
{/* 좁은 화면 검색 모드: 뒤로 + 검색창이 바 전체를 차지한다 */}
|
||||||
<div className="flex flex-shrink-0 items-center gap-2 text-lg font-bold">
|
{mobileSearch && (
|
||||||
<ListMusic className="text-green-500" aria-hidden />
|
<div className="absolute inset-0 z-10 flex items-center gap-2 bg-yt-bg px-2 md:hidden">
|
||||||
<span className="hidden sm:inline">MusicBot</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => setMobileSearch(false)}
|
||||||
{isAuthed && (
|
aria-label="검색 닫기"
|
||||||
<button
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white hover:bg-yt-chip"
|
||||||
onClick={handleHomeClick}
|
>
|
||||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
|
<ArrowLeft size={22} aria-hidden />
|
||||||
aria-label="처음 화면으로"
|
</button>
|
||||||
>
|
</div>
|
||||||
<Home size={20} aria-hidden />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */}
|
{/* 왼쪽: 메뉴 + 로고 (넓은 화면에서는 왼쪽 메뉴 폭에 맞춘다) */}
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="flex flex-shrink-0 items-center gap-1 lg:w-[232px]">
|
||||||
<Search
|
<button
|
||||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
type="button"
|
||||||
size={18}
|
onClick={onToggleGuide}
|
||||||
aria-hidden
|
aria-label="메뉴"
|
||||||
/>
|
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-yt-chip"
|
||||||
<input
|
>
|
||||||
type="search"
|
<Menu size={22} aria-hidden />
|
||||||
value={term}
|
</button>
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
<button type="button" onClick={handleLogoClick} aria-label="홈으로" className="rounded px-1 py-1">
|
||||||
onKeyDown={handleKeyDown}
|
<Logo />
|
||||||
disabled={!canSearch}
|
</button>
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
aria-label="노래 검색"
|
|
||||||
className="h-11 w-full rounded-full border border-neutral-700 bg-neutral-900 pl-10 pr-4 text-sm text-white placeholder:text-neutral-500 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 계정 영역 */}
|
{/* 검색창: 유튜브 뮤직과 같은 반투명 상자 + 왼쪽 돋보기 + 지우기 버튼 */}
|
||||||
<div className="flex flex-shrink-0 items-center">
|
<div
|
||||||
{status === "loading" ? (
|
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"}`}
|
||||||
<div className="h-11 w-11 animate-pulse rounded-full bg-neutral-800" aria-label="로그인 상태 확인 중" />
|
>
|
||||||
) : isAuthed && session?.user ? (
|
<form
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
role="search"
|
||||||
<span className="hidden items-center gap-2 rounded-full border border-neutral-700 bg-neutral-900 py-1 pl-1 pr-3 md:flex">
|
onSubmit={(e) => {
|
||||||
<Avatar user={session.user} size="sm" />
|
e.preventDefault();
|
||||||
<span className="max-w-[10rem] truncate text-sm font-medium">{session.user.name}</span>
|
submitSearch();
|
||||||
</span>
|
}}
|
||||||
<span className="md:hidden">
|
className="group relative w-full max-w-[480px]"
|
||||||
<Avatar user={session.user} size="md" />
|
>
|
||||||
</span>
|
<Search
|
||||||
|
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/70"
|
||||||
|
size={20}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
disabled={!canSearch}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
{term && (
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut()}
|
type="button"
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-red-400"
|
onClick={() => {
|
||||||
aria-label="로그아웃"
|
setTerm("");
|
||||||
title="로그아웃"
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
aria-label="검색어 지우기"
|
||||||
|
className="absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white/70 hover:text-white"
|
||||||
>
|
>
|
||||||
<LogOut size={18} aria-hidden />
|
<X size={18} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 md:hidden" />
|
||||||
|
|
||||||
|
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
|
{canSearch && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<span className="h-8 w-8 animate-pulse rounded-full bg-yt-chip" aria-label="로그인 상태 확인 중" />
|
||||||
|
) : isAuthed && session?.user ? (
|
||||||
|
<div ref={menuRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
aria-label="계정 메뉴"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Avatar user={session.user} />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 top-12 z-50 w-72 overflow-hidden rounded-lg bg-yt-menu py-2 shadow-2xl ring-1 ring-white/10">
|
||||||
|
<div className="flex items-center gap-4 border-b border-yt-line px-4 pb-4 pt-2">
|
||||||
|
<Avatar user={session.user} className="h-10 w-10 text-base" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-base text-white">{session.user.name}</span>
|
||||||
|
<span className="block truncate text-sm text-white/70">Discord 계정</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut()}
|
||||||
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => signIn("discord")}
|
onClick={() => signIn("discord")}
|
||||||
className="flex h-11 items-center gap-2 rounded-full bg-[#5865F2] px-4 text-sm font-medium text-white shadow-md transition-colors hover:bg-[#4752C4] sm:px-5"
|
className="h-9 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85"
|
||||||
>
|
>
|
||||||
<LogIn size={16} aria-hidden />
|
로그인
|
||||||
<span className="hidden sm:inline">Discord 로그인</span>
|
|
||||||
<span className="sm:hidden">로그인</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,69 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { signIn, useSession } from "next-auth/react";
|
import { signIn, useSession } from "next-auth/react";
|
||||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc, ListMusic, LogIn } from "lucide-react";
|
import { ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react";
|
||||||
import { ViewMode } from "@/app/page";
|
import type { ViewMode } from "@/app/page";
|
||||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
|
||||||
import { useToast } from "@/components/ToastProvider";
|
import { useToast } from "@/components/ToastProvider";
|
||||||
|
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
|
||||||
|
import QueueList from "@/components/player/QueueList";
|
||||||
|
import { Logo, 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 type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
||||||
|
|
||||||
/** ms -> m:ss (검색 결과 재생시간 표기용) */
|
type SourceKey = keyof SearchResults;
|
||||||
function formatDuration(ms?: number) {
|
type Filter = "all" | SourceKey;
|
||||||
if (!ms || Number.isNaN(ms)) return "";
|
|
||||||
const total = Math.floor(ms / 1000);
|
/** 검색 결과 구역(유튜브 뮤직 검색 화면의 "노래 / 동영상" 구성 + 스포티파이) */
|
||||||
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`;
|
const SOURCES: { key: SourceKey; title: string; kind: string; wide: boolean }[] = [
|
||||||
}
|
{ key: "youtubeMusic", title: "노래", kind: "노래", wide: false },
|
||||||
|
{ key: "youtubeVideo", title: "동영상", kind: "동영상", wide: true },
|
||||||
|
{ key: "spotify", title: "Spotify", kind: "Spotify", wide: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** "전체" 탭에서 구역마다 먼저 보여줄 곡 수(나머지는 "모두 표시") */
|
||||||
|
const PREVIEW_COUNT = 5;
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: (mode: ViewMode) => void;
|
|
||||||
selectedServer: DiscordServer | null;
|
selectedServer: DiscordServer | null;
|
||||||
setSelectedServer: (server: DiscordServer | null) => void;
|
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (query: string) => void;
|
servers: DiscordServer[];
|
||||||
|
isFetchingServers: boolean;
|
||||||
onSelectServer: (server: DiscordServer) => void;
|
onSelectServer: (server: DiscordServer) => void;
|
||||||
|
onOpenPlayer: () => void;
|
||||||
|
/** 재생 화면이 열려 있으면 서버 페이지의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */
|
||||||
|
showQueue: boolean;
|
||||||
|
player: PlayerState;
|
||||||
|
queue: QueueState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MainContent({
|
export default function MainContent({
|
||||||
viewMode,
|
viewMode,
|
||||||
setViewMode,
|
|
||||||
selectedServer,
|
selectedServer,
|
||||||
setSelectedServer,
|
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
servers,
|
||||||
|
isFetchingServers,
|
||||||
onSelectServer,
|
onSelectServer,
|
||||||
|
onOpenPlayer,
|
||||||
|
showQueue,
|
||||||
|
player,
|
||||||
|
queue,
|
||||||
}: MainContentProps) {
|
}: MainContentProps) {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const [servers, setServers] = useState<DiscordServer[]>([]);
|
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
|
||||||
|
|
||||||
const [searchResults, setSearchResults] = useState<SearchResults>({
|
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||||
spotify: [],
|
spotify: [],
|
||||||
youtubeMusic: [],
|
youtubeMusic: [],
|
||||||
youtubeVideo: []
|
youtubeVideo: []
|
||||||
});
|
});
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
|
||||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
// 🌟 alert -> showToast 교체
|
|
||||||
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,67 +94,22 @@ export default function MainContent({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
// 🌟 성공 시 초록색 알림
|
|
||||||
showToast("재생목록에 곡을 추가했습니다!", "success");
|
showToast("재생목록에 곡을 추가했습니다!", "success");
|
||||||
} else {
|
} else {
|
||||||
// 🌟 에러 시 빨간색 알림
|
|
||||||
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Music Action Error:", error);
|
console.error("Music Action Error:", error);
|
||||||
// 🌟 네트워크 에러 시 알림
|
|
||||||
showToast('서버와 통신할 수 없습니다.', "error");
|
showToast('서버와 통신할 수 없습니다.', "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (viewMode === "SEARCH_RESULT" && searchQuery) {
|
if (viewMode === "SEARCH_RESULT" && searchQuery) {
|
||||||
|
// 새 검색마다 결과·탭을 초기화하는 의도적 패턴
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
|
setFilter("all");
|
||||||
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
|
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
|
||||||
|
|
||||||
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
||||||
@@ -164,24 +132,22 @@ export default function MainContent({
|
|||||||
|
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 flex-col items-center justify-center gap-5 bg-neutral-900 px-6 text-center">
|
<main className="flex flex-1 flex-col items-center justify-center gap-6 bg-yt-bg px-6 text-center">
|
||||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-neutral-800">
|
<Logo className="scale-150" />
|
||||||
<ListMusic size={32} className="text-green-500" aria-hidden />
|
<div className="mt-4">
|
||||||
</span>
|
<h2 className="text-2xl font-bold sm:text-3xl">디스코드로 로그인하고 음악을 들어 보세요</h2>
|
||||||
<div>
|
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-white/70">
|
||||||
<h2 className="text-2xl font-bold">디스코드로 시작하기</h2>
|
|
||||||
<p className="mt-2 max-w-sm text-sm leading-relaxed text-neutral-400">
|
|
||||||
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
|
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
|
||||||
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
|
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
|
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => signIn("discord")}
|
onClick={() => signIn("discord")}
|
||||||
className="flex h-12 items-center gap-2 rounded-full bg-[#5865F2] px-6 font-medium text-white shadow-lg transition hover:bg-[#4752C4] active:scale-95"
|
className="h-10 rounded-full bg-white px-6 text-sm font-medium text-black transition-colors hover:bg-white/85"
|
||||||
>
|
>
|
||||||
<LogIn size={18} aria-hidden />
|
Discord로 로그인
|
||||||
Discord 로그인
|
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -189,226 +155,256 @@ export default function MainContent({
|
|||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 items-center justify-center bg-neutral-900">
|
<main className="flex flex-1 items-center justify-center bg-yt-bg">
|
||||||
<Loader2 className="animate-spin text-neutral-600" size={32} aria-label="불러오는 중" />
|
<Spinner />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 곡 하나는 '리스트 행'으로 보여준다.
|
* 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이".
|
||||||
* 그리드 카드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태고,
|
* 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다.
|
||||||
* 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(제목·아티스트·재생시간) 형식이라
|
|
||||||
* 사용자에게 익숙한 쪽으로 맞춘다. 행 전체가 버튼이라 터치로도 바로 누를 수 있다.
|
|
||||||
*/
|
*/
|
||||||
const renderTrackRow = (track: SearchTrack, index: number) => (
|
const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean) => {
|
||||||
<button
|
const meta = [kind, track.artist, track.duration ? formatTime(track.duration) : null].filter(Boolean).join(" • ");
|
||||||
key={track.videoId || track.id || track.url || track.title}
|
return (
|
||||||
type="button"
|
<li key={track.videoId || track.id || track.url || track.title} className="border-b border-yt-line last:border-b-0">
|
||||||
onClick={() => handleMusicAction('player_play', track)}
|
<button
|
||||||
aria-label={`${track.title} 재생목록에 추가`}
|
type="button"
|
||||||
className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:bg-neutral-700/60"
|
onClick={() => handleMusicAction('player_play', track)}
|
||||||
>
|
aria-label={`${track.title} 재생목록에 추가`}
|
||||||
{/* 번호 자리에 hover 시 재생 아이콘이 겹치는 건 스포티파이 곡 목록과 같은 방식 */}
|
className="group flex w-full items-center gap-4 py-2 text-left focus:outline-none focus-visible:bg-white/10"
|
||||||
<span className="hidden w-5 flex-shrink-0 text-center text-sm tabular-nums text-neutral-500 group-hover:hidden sm:block">
|
>
|
||||||
{index + 1}
|
<span className={`relative flex-shrink-0 ${wide ? "h-14 w-[100px]" : "h-14 w-14"}`}>
|
||||||
</span>
|
<Thumb src={track.thumbnail} wide={wide} className="h-full w-full" />
|
||||||
<span className="hidden w-5 flex-shrink-0 items-center justify-center text-white sm:group-hover:flex">
|
{/* 썸네일 위 재생 아이콘: 마우스면 hover 때, 터치 화면이면 항상 */}
|
||||||
<Play fill="currentColor" size={14} aria-hidden />
|
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:opacity-100 pointer-coarse:bg-black/30">
|
||||||
</span>
|
<Play fill="white" stroke="white" size={20} aria-hidden />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-base text-white">{track.title}</span>
|
||||||
|
<span className="block truncate text-sm text-white/70">{meta}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white/70 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ListPlus size={22} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<span className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded bg-neutral-700">
|
const counts: Record<SourceKey, number> = {
|
||||||
{track.thumbnail && (
|
youtubeMusic: searchResults.youtubeMusic.length,
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
youtubeVideo: searchResults.youtubeVideo.length,
|
||||||
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" />
|
spotify: searchResults.spotify.length,
|
||||||
)}
|
};
|
||||||
{/* 좁은 화면은 hover 가 없으니 재생 아이콘을 항상 얹어 눌러야 할 곳을 알려준다. */}
|
const hasAnyResults = counts.youtubeMusic + counts.youtubeVideo + counts.spotify > 0;
|
||||||
<span className="absolute inset-0 flex items-center justify-center bg-black/40 sm:hidden">
|
|
||||||
<Play fill="white" stroke="white" size={16} aria-hidden />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
// 상위 검색결과: 유튜브 뮤직 → 스포티파이 → 영상 순으로 첫 곡
|
||||||
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
|
const topKey = (["youtubeMusic", "spotify", "youtubeVideo"] as const).find((k) => counts[k] > 0);
|
||||||
<span className="block truncate text-xs text-neutral-400">{track.artist}</span>
|
const topSource = SOURCES.find((s) => s.key === topKey);
|
||||||
</span>
|
const topTrack = topSource ? searchResults[topSource.key][0] : undefined;
|
||||||
|
|
||||||
{track.duration ? (
|
|
||||||
<span className="flex-shrink-0 text-xs tabular-nums text-neutral-500">
|
|
||||||
{formatDuration(track.duration)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 flex-col overflow-y-auto bg-gradient-to-b from-neutral-800/50 to-neutral-900 p-4 pb-28 sm:p-6 lg:p-8">
|
<main className="relative flex-1 overflow-y-auto bg-yt-bg">
|
||||||
|
<div className="mx-auto w-full max-w-[1200px] px-4 pb-32 pt-6 sm:px-8 lg:px-12">
|
||||||
|
|
||||||
{/* 화면 1: 서버 목록 */}
|
{/* 화면 1: 홈(서버 고르기) — 유튜브 뮤직 홈의 아티스트 줄 형태 */}
|
||||||
{viewMode === "SERVER_LIST" && (
|
{viewMode === "SERVER_LIST" && (
|
||||||
<>
|
<section>
|
||||||
<h2 className="mb-1 text-xl font-bold sm:text-2xl">어느 서버에서 들을까요?</h2>
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<p className="mb-5 text-sm text-neutral-400">음악 봇이 들어가 있는 서버만 보입니다.</p>
|
{session?.user?.image ? (
|
||||||
{isFetching && servers.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-20 text-neutral-400">
|
|
||||||
<Loader2 className="animate-spin text-green-500" size={36} aria-hidden />
|
|
||||||
<p>서버 목록을 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
) : servers.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-2 py-20 text-center text-neutral-500">
|
|
||||||
<Server size={40} className="opacity-50" aria-hidden />
|
|
||||||
<p className="text-sm">표시할 서버가 없습니다.</p>
|
|
||||||
<p className="max-w-sm text-xs text-neutral-600">
|
|
||||||
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
|
||||||
{servers.map((server) => (
|
|
||||||
<button
|
|
||||||
key={server.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectServer(server)}
|
|
||||||
className="w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<span className="mb-2 flex aspect-square items-center justify-center overflow-hidden rounded-full bg-neutral-700 shadow-lg">
|
|
||||||
{server.icon ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Server size={24} className="text-neutral-500" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="block truncate text-center text-sm font-medium">{server.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 화면 2: 서버 상세 대시보드 */}
|
|
||||||
{viewMode === "SERVER_DETAIL" && selectedServer && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
<button onClick={() => {
|
|
||||||
setViewMode("SERVER_LIST");
|
|
||||||
setSelectedServer(null);
|
|
||||||
setSearchQuery("");
|
|
||||||
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
|
|
||||||
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
|
||||||
서버 목록으로 돌아가기
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mb-6 flex flex-col items-center gap-4 text-center sm:flex-row sm:items-end sm:gap-6 sm:text-left">
|
|
||||||
<span className="flex h-28 w-28 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800 shadow-2xl sm:h-40 sm:w-40">
|
|
||||||
{selectedServer.icon ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img src={session.user.image} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||||
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
|
) : null}
|
||||||
className="h-full w-full object-cover"
|
<div>
|
||||||
alt=""
|
<p className="text-sm uppercase tracking-wide text-white/70">{session?.user?.name}</p>
|
||||||
/>
|
<h2 className="text-2xl font-bold sm:text-[32px] sm:leading-tight">어느 서버에서 들을까요?</h2>
|
||||||
) : <Server size={56} className="text-neutral-500" aria-hidden />}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-neutral-400">서버 대시보드</span>
|
{isFetchingServers && servers.length === 0 ? (
|
||||||
<h1 className="mt-1 break-words text-2xl font-black sm:text-4xl lg:text-5xl">{selectedServer.name}</h1>
|
<div className="flex justify-center py-24">
|
||||||
<p className="mt-2 text-sm text-neutral-400">
|
<Spinner label="서버 목록을 불러오는 중" />
|
||||||
위 검색창에서 노래를 찾아 바로 재생할 수 있습니다.
|
</div>
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-24 text-center">
|
||||||
|
<p className="text-base text-white">표시할 서버가 없습니다.</p>
|
||||||
|
<p className="max-w-sm text-sm text-white/50">
|
||||||
|
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<li key={server.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectServer(server)}
|
||||||
|
className="group w-full 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 bg-black/0 transition-colors group-hover:bg-black/30 group-focus-visible:ring-2 group-focus-visible:ring-white" />
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-base font-medium text-white">{server.name}</span>
|
||||||
|
<span className="block truncate text-sm text-white/70">{permissionLabel(server)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면 2: 서버 페이지 — 유튜브 뮤직 앨범/재생목록 페이지 형태(왼쪽 커버, 오른쪽 트랙 목록) */}
|
||||||
|
{viewMode === "SERVER_DETAIL" && selectedServer && (
|
||||||
|
<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={selectedServer}
|
||||||
|
rounded="rounded-lg"
|
||||||
|
className="h-52 w-52 shadow-2xl sm:h-64 sm:w-64"
|
||||||
|
iconSize={64}
|
||||||
|
size={512}
|
||||||
|
/>
|
||||||
|
<h1 className="mt-6 break-words text-[28px] font-bold leading-tight sm:text-[32px]">{selectedServer.name}</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/70">
|
||||||
|
디스코드 서버 • 내 권한: {permissionLabel(selectedServer)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="mt-4 max-w-sm text-sm leading-relaxed text-white/70">
|
||||||
</div>
|
먼저 디스코드에서 음성 채널에 입장한 뒤, 노래를 검색해 누르면 봇이 재생합니다.
|
||||||
|
</p>
|
||||||
{/* 무엇을 해야 하는지 알려주는 안내 — 서버만 고르면 다음 행동이 막막했던 문제 보완 */}
|
<div className="mt-6 flex items-center gap-3">
|
||||||
<div className="mb-6 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
|
<button
|
||||||
<p className="text-sm text-green-200">
|
type="button"
|
||||||
먼저 디스코드에서 <strong>음성 채널에 입장</strong>한 뒤, 노래를 검색해 재생 버튼을 누르세요.
|
onClick={() => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT))}
|
||||||
</p>
|
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"
|
||||||
</div>
|
>
|
||||||
|
<Search size={18} aria-hidden />
|
||||||
<div className="rounded-xl border border-neutral-800 bg-black/20 p-4 sm:p-6">
|
노래 검색
|
||||||
<h3 className="mb-4 flex items-center gap-2 text-base font-bold sm:text-lg">
|
</button>
|
||||||
<Music size={20} aria-hidden /> 상세 정보
|
<button
|
||||||
</h3>
|
type="button"
|
||||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 sm:gap-4">
|
onClick={onOpenPlayer}
|
||||||
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
|
aria-label="재생 화면 열기"
|
||||||
<p className="mb-1 text-neutral-500">서버 고유 ID</p>
|
title="재생 화면"
|
||||||
<p className="break-all font-mono text-white">{selectedServer.id}</p>
|
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>
|
</div>
|
||||||
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
|
<p className="mt-4 break-all font-mono text-[11px] text-white/30">ID {selectedServer.id}</p>
|
||||||
<p className="mb-1 text-neutral-500">나의 서버 권한</p>
|
</section>
|
||||||
<p className="font-bold text-green-400">{getPermissionLabel(selectedServer)}</p>
|
|
||||||
</div>
|
<section className="min-w-0 flex-1">
|
||||||
</div>
|
{showQueue && <QueueList player={player} queue={queue} variant="page" />}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
{/* 화면 3: 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */}
|
||||||
{viewMode === "SEARCH_RESULT" && (
|
{viewMode === "SEARCH_RESULT" && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-1 break-words text-xl font-bold sm:text-2xl">“{searchQuery}” 검색 결과</h2>
|
{/* 필터 칩 */}
|
||||||
<p className="mb-6 text-sm text-neutral-400">곡을 누르면 대기열에 추가됩니다.</p>
|
<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) => {
|
||||||
{isSearching ? (
|
const active = filter === chip.key;
|
||||||
<div className="flex flex-col items-center py-20">
|
const disabled = chip.key !== "all" && counts[chip.key] === 0;
|
||||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
return (
|
||||||
<p>노래를 찾는 중입니다...</p>
|
<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}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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 */}
|
{isSearching ? (
|
||||||
{searchResults.spotify.length > 0 && (
|
<div className="flex flex-col items-center gap-4 py-24 text-white/70">
|
||||||
<section>
|
<Spinner label="노래를 찾는 중" />
|
||||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
|
<p className="text-sm">“{searchQuery}” 검색 중...</p>
|
||||||
<Disc size={24} /> Spotify 결과
|
</div>
|
||||||
</h3>
|
) : !hasAnyResults ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col items-center gap-3 py-24 text-center">
|
||||||
{searchResults.spotify.map(renderTrackRow)}
|
<SearchX size={48} className="text-white/50" aria-hidden />
|
||||||
</div>
|
<p className="text-base text-white">“{searchQuery}”에 대한 검색 결과가 없습니다.</p>
|
||||||
</section>
|
<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={() => handleMusicAction('player_play', topTrack)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Play size={18} fill="currentColor" aria-hidden />
|
||||||
|
재생
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 카테고리 2: YouTube Music */}
|
{SOURCES.filter((s) => (filter === "all" || filter === s.key) && counts[s.key] > 0).map((s) => {
|
||||||
{searchResults.youtubeMusic.length > 0 && (
|
const list = searchResults[s.key];
|
||||||
<section>
|
const shown = filter === "all" ? list.slice(0, PREVIEW_COUNT) : list;
|
||||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
|
return (
|
||||||
<Music size={24} /> YouTube Music
|
<section key={s.key}>
|
||||||
</h3>
|
<div className="mb-2 flex items-end justify-between gap-4">
|
||||||
<div className="flex flex-col">
|
<h2 className="text-2xl font-bold">{s.title}</h2>
|
||||||
{searchResults.youtubeMusic.map(renderTrackRow)}
|
{filter === "all" && list.length > PREVIEW_COUNT && (
|
||||||
</div>
|
<button
|
||||||
</section>
|
type="button"
|
||||||
)}
|
onClick={() => setFilter(s.key)}
|
||||||
|
className="h-8 flex-shrink-0 rounded-full border border-white/20 px-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
|
||||||
{/* 카테고리 3: YouTube Video */}
|
>
|
||||||
{searchResults.youtubeVideo.length > 0 && (
|
모두 표시
|
||||||
<section>
|
</button>
|
||||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
|
)}
|
||||||
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상
|
</div>
|
||||||
</h3>
|
<ul>{shown.map((t) => renderTrackRow(t, s.kind, s.wide))}</ul>
|
||||||
<div className="flex flex-col">
|
</section>
|
||||||
{searchResults.youtubeVideo.map(renderTrackRow)}
|
);
|
||||||
</div>
|
})}
|
||||||
</section>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,535 +1,208 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { ChevronDown, ChevronUp, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
|
||||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play, ListMusic } from "lucide-react";
|
import { Thumb } from "@/components/ui";
|
||||||
import { useSession } from "next-auth/react";
|
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
||||||
import type { DiscordServer, Track } from "@/types/music";
|
|
||||||
|
|
||||||
interface PlayerBarProps {
|
interface PlayerBarProps {
|
||||||
selectedServer: DiscordServer | null;
|
player: PlayerState;
|
||||||
onToggleQueue?: () => void;
|
isPlayerOpen: boolean;
|
||||||
isQueueOpen?: boolean;
|
onTogglePlayer: () => void;
|
||||||
/** 넓은 화면에서 우측 대기열 컬럼을 접었다 펴는 토글 */
|
|
||||||
onToggleDock?: () => void;
|
|
||||||
isQueueDocked?: boolean;
|
|
||||||
/** 대기열 패널에도 "지금 재생 중"을 보여주기 위해 현재 곡을 위로 올려준다(중복 폴링 방지). */
|
|
||||||
onNowPlayingChange?: (track: Track | null) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onToggleDock, isQueueDocked, onNowPlayingChange }: PlayerBarProps) {
|
/** 진행바(유튜브 뮤직: 재생바 위쪽 가장자리의 얇은 빨간 선, 마우스를 올리면 굵어지고 손잡이가 보인다) */
|
||||||
const { data: session } = useSession();
|
export function SeekBar({ player, className = "", thick = false }: { player: PlayerState; className?: string; thick?: boolean }) {
|
||||||
|
const { position, duration, canControl, dragSeek, commitSeek } = player;
|
||||||
|
const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className={`group relative flex h-4 items-center ${className}`}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
value={position}
|
||||||
|
disabled={!canControl}
|
||||||
|
aria-label="재생 위치"
|
||||||
|
aria-valuetext={`${formatTime(position)} / ${formatTime(duration)}`}
|
||||||
|
onChange={(e) => dragSeek(Number(e.target.value))}
|
||||||
|
onMouseUp={(e) => commitSeek(Number(e.currentTarget.value))}
|
||||||
|
onTouchEnd={(e) => commitSeek(Number(e.currentTarget.value))}
|
||||||
|
onKeyUp={(e) => commitSeek(Number(e.currentTarget.value))}
|
||||||
|
className="absolute inset-0 z-20 h-full w-full cursor-pointer opacity-0 disabled:cursor-default"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute inset-x-0 z-10 bg-white/20 transition-[height] ${thick ? "h-1 rounded-full" : "h-0.5 group-hover:h-1"}`}
|
||||||
|
>
|
||||||
|
<div className={`h-full bg-yt-red ${thick ? "rounded-full" : ""}`} style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
{canControl && (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-yt-red transition-opacity ${thick ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||||
|
style={{ left: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 재생 상태 관리
|
/**
|
||||||
const [track, setTrack] = useState<Track | null>(null);
|
* 유튜브 뮤직 하단 재생바.
|
||||||
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
* 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃]
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
* 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다.
|
||||||
const [isPaused, setIsPaused] = useState<boolean>(false);
|
*/
|
||||||
const [volume, setVolume] = useState<number>(50);
|
export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: PlayerBarProps) {
|
||||||
|
const {
|
||||||
|
track, isPaused, volume, position, duration, canControl, isVolumeDragging,
|
||||||
|
togglePause, skip, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
|
||||||
|
} = player;
|
||||||
|
|
||||||
// 시간 및 진행도 관리
|
const title = track?.info?.title || "재생 중인 곡 없음";
|
||||||
const [position, setPosition] = useState<number>(0);
|
const artist = cleanArtist(track?.info?.author);
|
||||||
const [duration, setDuration] = useState<number>(0);
|
const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
|
||||||
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
|
|
||||||
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
|
|
||||||
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
|
|
||||||
// eslint-disable-next-line react-hooks/purity
|
|
||||||
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
|
|
||||||
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
|
||||||
const mutedVolumeRef = useRef<number>(50); // 음소거 해제 시 복원할 볼륨
|
|
||||||
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
|
||||||
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// 1. 현재 재생 상태 불러오기 (player_now)
|
const playButton = (size: number, className: string) => (
|
||||||
const fetchNowPlaying = useCallback(async () => {
|
<button
|
||||||
if (!selectedServer) return;
|
type="button"
|
||||||
|
onClick={togglePause}
|
||||||
|
disabled={!canControl}
|
||||||
|
aria-label={isPaused ? "재생" : "일시정지"}
|
||||||
|
title={isPaused ? "재생 (Space)" : "일시정지 (Space)"}
|
||||||
|
className={`flex items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30 ${className}`}
|
||||||
|
>
|
||||||
|
{isPaused ? <Play size={size} fill="currentColor" aria-hidden /> : <Pause size={size} fill="currentColor" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
const userId = session?.user?.id;
|
const nextButton = (className: string) => (
|
||||||
if (!userId) return;
|
<button
|
||||||
|
type="button"
|
||||||
try {
|
onClick={skip}
|
||||||
const res = await fetch('/api/player/now', {
|
disabled={!canControl}
|
||||||
method: 'POST',
|
aria-label="다음 곡"
|
||||||
headers: { 'Content-Type': 'application/json' },
|
title="다음 곡 (Ctrl+→)"
|
||||||
body: JSON.stringify({
|
className={`flex items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30 ${className}`}
|
||||||
serverId: selectedServer.id,
|
>
|
||||||
})
|
<SkipForward size={24} fill="currentColor" aria-hidden />
|
||||||
});
|
</button>
|
||||||
const data = await res.json();
|
);
|
||||||
|
|
||||||
if (res.ok && data.success && data.track) {
|
|
||||||
setBotPlayer(Boolean(data.botPlayer));
|
|
||||||
setIsPlaying(Boolean(data.isPlaying));
|
|
||||||
setIsPaused(Boolean(data.isPaused));
|
|
||||||
setTrack(data.track as Track);
|
|
||||||
setDuration(Number(data.track?.info?.length ?? 0) || 0);
|
|
||||||
setVolume(typeof data.volume === "number" ? data.volume : 50);
|
|
||||||
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
|
||||||
if (!isDragging.current) {
|
|
||||||
const p = data.position || 0;
|
|
||||||
setPosition(p);
|
|
||||||
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTrack(null);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setPosition(0);
|
|
||||||
setDuration(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("재생 정보 불러오기 실패:", error);
|
|
||||||
}
|
|
||||||
}, [selectedServer, session]);
|
|
||||||
|
|
||||||
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
fetchNowPlaying();
|
|
||||||
|
|
||||||
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
|
|
||||||
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data?.type === "player_update") {
|
|
||||||
fetchNowPlaying();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("SSE JSON 파싱 실패:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
|
|
||||||
};
|
|
||||||
return () => eventSource.close();
|
|
||||||
}, [selectedServer, fetchNowPlaying]);
|
|
||||||
|
|
||||||
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
|
|
||||||
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
|
|
||||||
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
|
|
||||||
const isPausedRef = useRef(isPaused);
|
|
||||||
useEffect(() => {
|
|
||||||
isPausedRef.current = isPaused;
|
|
||||||
}, [isPaused]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying) return;
|
|
||||||
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
|
|
||||||
const tick = setInterval(() => {
|
|
||||||
if (isPausedRef.current || isDragging.current) return;
|
|
||||||
const a = anchorRef.current;
|
|
||||||
const next = a.pos + (Date.now() - a.at);
|
|
||||||
setPosition(duration > 0 ? Math.min(duration, next) : next);
|
|
||||||
}, 250);
|
|
||||||
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
|
|
||||||
const syncInterval = setInterval(() => {
|
|
||||||
if (isPausedRef.current || isDragging.current) return;
|
|
||||||
fetchNowPlaying();
|
|
||||||
}, 15000);
|
|
||||||
return () => {
|
|
||||||
clearInterval(tick);
|
|
||||||
clearInterval(syncInterval);
|
|
||||||
};
|
|
||||||
}, [isPlaying, duration, fetchNowPlaying]);
|
|
||||||
|
|
||||||
|
|
||||||
// ================= [ 컨트롤러 액션 ] =================
|
|
||||||
|
|
||||||
// 재생/일시정지 토글 (player_pause)
|
|
||||||
const handleTogglePause = async () => {
|
|
||||||
if (!selectedServer || !track) return;
|
|
||||||
if (!isPlaying) return;
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) return;
|
|
||||||
const nextPaused = !isPaused;
|
|
||||||
// UI 즉각 반영 (Optimistic UI)
|
|
||||||
setIsPaused(nextPaused);
|
|
||||||
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/player/pause', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
isPaused: nextPaused, // boolean 그대로 전송
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
|
|
||||||
if (typeof data.paused === "boolean") setIsPaused(data.paused);
|
|
||||||
} else {
|
|
||||||
// 실패 시 롤백
|
|
||||||
setIsPaused(!nextPaused);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("일시정지 에러:", error);
|
|
||||||
setIsPaused(!nextPaused); // 실패 시 롤백
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 다음 곡 스킵 (player_skip)
|
|
||||||
const handleSkip = async () => {
|
|
||||||
if (!selectedServer || !track) return;
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) return;
|
|
||||||
try {
|
|
||||||
await fetch('/api/player/skip', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
|
|
||||||
setTimeout(fetchNowPlaying, 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("스킵 에러:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
|
|
||||||
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
|
||||||
if (!selectedServer || !track) return;
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) return;
|
|
||||||
isDragging.current = false;
|
|
||||||
|
|
||||||
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
|
||||||
const newPosition = Number(e.currentTarget.value);
|
|
||||||
setPosition(newPosition);
|
|
||||||
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/player/seek', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
seek: newPosition,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("시간 이동 에러:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 볼륨 조절 (player_volume)
|
|
||||||
const handleVolumeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
setVolume(Number(e.target.value)); // UI 즉시 반영
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🌟 [신규 추가] 드래그가 끝났을 때 (마우스를 뗐을 때) 딱 한 번 API 전송
|
|
||||||
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
|
||||||
setIsVolumeDragging(false);
|
|
||||||
if (!selectedServer) return;
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) return;
|
|
||||||
const finalVolume = Number(e.currentTarget.value);
|
|
||||||
setVolume(finalVolume); // UI 즉시 반영
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/player/volume', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
volume: finalVolume,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
|
|
||||||
} catch (error) {
|
|
||||||
console.error("볼륨 조절 에러:", error);
|
|
||||||
setVolume(prevVolumeRef.current); // 실패 시 롤백
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 볼륨 값을 봇에 전송(실패하면 이전 값으로 되돌린다). */
|
|
||||||
const pushVolume = async (next: number, rollbackTo: number) => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/player/volume', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ serverId: selectedServer.id, volume: next }),
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok || !data.success) setVolume(rollbackTo);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("볼륨 조절 에러:", error);
|
|
||||||
setVolume(rollbackTo);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스피커 아이콘 클릭 = 음소거/해제.
|
|
||||||
* 스포티파이·유튜브뮤직·애플뮤직 모두 같은 동작이라, 아이콘을 장식으로 두지 않고 버튼으로 만든다.
|
|
||||||
*/
|
|
||||||
const handleToggleMute = async () => {
|
|
||||||
if (!canControl) return;
|
|
||||||
if (volume > 0) {
|
|
||||||
mutedVolumeRef.current = volume; // 해제할 때 돌아갈 값 기억
|
|
||||||
setVolume(0);
|
|
||||||
await pushVolume(0, mutedVolumeRef.current);
|
|
||||||
} else {
|
|
||||||
const restore = mutedVolumeRef.current || 50;
|
|
||||||
setVolume(restore);
|
|
||||||
await pushVolume(restore, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 시간 포맷 변환 함수 (ms -> mm:ss)
|
|
||||||
const formatTime = (ms: number) => {
|
|
||||||
if (!ms || isNaN(ms)) return "00:00";
|
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 진행도 퍼센트 계산 (0 ~ 100)
|
|
||||||
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
|
|
||||||
|
|
||||||
const canControl = !!botPlayer && !!isPlaying && !!track;
|
|
||||||
|
|
||||||
// 현재 곡이 바뀌면 상위로 알려 대기열 패널의 "지금 재생 중"과 동기화한다.
|
|
||||||
useEffect(() => {
|
|
||||||
onNowPlayingChange?.(track);
|
|
||||||
}, [track, onNowPlayingChange]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 웹 음악 플레이어 표준 단축키(스포티파이/유튜브뮤직 공통):
|
|
||||||
* Space 재생·일시정지 / M 음소거 / ← → 5초 탐색 / Ctrl+→ 다음 곡
|
|
||||||
* 검색창처럼 글자를 입력하는 곳에 포커스가 있으면 가로채지 않는다.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
const el = e.target as HTMLElement | null;
|
|
||||||
const typing =
|
|
||||||
el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable;
|
|
||||||
if (typing || !canControl) return;
|
|
||||||
|
|
||||||
if (e.code === "Space") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleTogglePause();
|
|
||||||
} else if (e.key === "m" || e.key === "M" || e.key === "ㅡ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleToggleMute();
|
|
||||||
} else if (e.key === "ArrowRight" && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSkip();
|
|
||||||
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
||||||
e.preventDefault();
|
|
||||||
const delta = e.key === "ArrowRight" ? 5000 : -5000;
|
|
||||||
const next = Math.min(Math.max(position + delta, 0), duration || 0);
|
|
||||||
setPosition(next);
|
|
||||||
anchorRef.current = { pos: next, at: Date.now() };
|
|
||||||
if (selectedServer) {
|
|
||||||
fetch('/api/player/seek', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ serverId: selectedServer.id, seek: next }),
|
|
||||||
}).catch((err) => console.error("시간 이동 에러:", err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="z-50 flex-shrink-0 border-t border-neutral-800 bg-neutral-950">
|
<footer className="relative z-50 flex-shrink-0 bg-yt-raised">
|
||||||
{/* 모바일: 진행바를 맨 위에 가로로 꽉 채워 터치 영역을 넓게 확보한다. */}
|
{/* ===== 넓은 화면 ===== */}
|
||||||
<div className="flex items-center gap-2 px-3 pt-2 text-[11px] tabular-nums text-neutral-400 sm:hidden">
|
<SeekBar player={player} className="absolute inset-x-0 -top-2 hidden md:flex" />
|
||||||
<span className="w-9 text-right">{formatTime(position)}</span>
|
<div className="hidden h-[72px] items-center gap-4 px-4 md:flex">
|
||||||
<div className="group relative flex h-6 flex-1 items-center">
|
{/* 1. 재생 조작 + 시간 */}
|
||||||
<input
|
<div className="flex flex-shrink-0 items-center gap-2 lg:w-[292px]">
|
||||||
type="range"
|
<button
|
||||||
min={0}
|
type="button"
|
||||||
max={duration || 100}
|
disabled
|
||||||
value={position}
|
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다"
|
||||||
disabled={!canControl}
|
aria-label="이전 곡 (지원 예정)"
|
||||||
aria-label="재생 위치"
|
className="flex h-10 w-10 cursor-not-allowed items-center justify-center rounded-full text-white/30"
|
||||||
onChange={(e) => {
|
>
|
||||||
isDragging.current = true;
|
<SkipBack size={24} fill="currentColor" aria-hidden />
|
||||||
setPosition(Number(e.target.value));
|
</button>
|
||||||
}}
|
{playButton(30, "h-12 w-12")}
|
||||||
onMouseUp={handleSeekEnd}
|
{nextButton("h-10 w-10")}
|
||||||
onTouchEnd={handleSeekEnd}
|
<span className="ml-2 hidden whitespace-nowrap text-xs tabular-nums text-white/70 lg:inline">
|
||||||
className="absolute z-20 h-6 w-full cursor-pointer opacity-0"
|
{formatTime(position)} / {formatTime(duration)}
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute z-10 h-1.5 w-full overflow-hidden rounded-full bg-neutral-700">
|
|
||||||
<div className="h-full bg-green-500" style={{ width: `${progressPercent}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="w-9">{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-20 items-center justify-between gap-3 px-3 sm:h-24 sm:px-6">
|
|
||||||
{/* 1. 현재 곡 */}
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3 sm:w-1/4 sm:flex-none">
|
|
||||||
<span className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-md bg-neutral-800 sm:h-14 sm:w-14">
|
|
||||||
{track?.info?.artworkUrl && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={track.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
{track ? (
|
|
||||||
<>
|
|
||||||
<span className="block truncate text-sm font-semibold text-white">{track.info?.title}</span>
|
|
||||||
<span className="block truncate text-xs text-neutral-400">
|
|
||||||
{track.info?.author?.replace(" - Topic", "")}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="block text-sm text-neutral-400">재생 중인 곡 없음</span>
|
|
||||||
<span className="hidden text-xs text-neutral-600 sm:block">검색해서 노래를 추가해보세요</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. 컨트롤 (모바일에서는 버튼만, 진행바는 위쪽에 따로) */}
|
{/* 2. 현재 곡(누르면 재생 화면) */}
|
||||||
<div className="flex flex-shrink-0 flex-col items-center gap-1 sm:w-2/4 sm:max-w-2xl sm:flex-1">
|
<button
|
||||||
<div className="flex items-center gap-1 sm:gap-4">
|
type="button"
|
||||||
<button
|
onClick={onTogglePlayer}
|
||||||
disabled
|
className="flex min-w-0 flex-1 items-center justify-center gap-4 text-left"
|
||||||
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다"
|
aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
|
||||||
aria-label="이전 곡 (지원 예정)"
|
>
|
||||||
className="hidden h-11 w-11 cursor-not-allowed items-center justify-center rounded-full text-neutral-700 sm:flex"
|
<Thumb src={track?.info?.artworkUrl} className="h-10 w-10" />
|
||||||
>
|
<span className="min-w-0 max-w-[420px] flex-1">
|
||||||
<SkipBack size={20} aria-hidden />
|
<span className="block truncate text-base text-white">{title}</span>
|
||||||
</button>
|
{artist && <span className="block truncate text-base text-white/70">{artist}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
{/* 3. 볼륨 + 재생 화면 열기 */}
|
||||||
onClick={handleTogglePause}
|
<div className="flex flex-shrink-0 items-center justify-end gap-1 lg:w-[292px]">
|
||||||
disabled={!canControl}
|
{/* 볼륨: 유튜브 뮤직처럼 마우스를 올리면 슬라이더가 옆으로 펼쳐진다 */}
|
||||||
aria-label={isPaused ? "재생" : "일시정지"}
|
<div className="group flex items-center">
|
||||||
className={`flex h-12 w-12 items-center justify-center rounded-full text-black transition
|
<div className="relative flex h-10 w-0 items-center overflow-hidden transition-[width] duration-200 group-focus-within:w-24 group-hover:w-24">
|
||||||
${canControl ? "bg-white hover:scale-105 active:scale-95" : "cursor-not-allowed bg-neutral-700 text-neutral-500"}`}
|
|
||||||
>
|
|
||||||
{!isPaused ? <Pause size={22} fill="currentColor" aria-hidden /> : <Play size={22} fill="currentColor" className="ml-0.5" aria-hidden />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={!canControl}
|
|
||||||
aria-label="다음 곡"
|
|
||||||
className={`flex h-11 w-11 items-center justify-center rounded-full transition-colors
|
|
||||||
${canControl ? "text-neutral-300 hover:bg-neutral-800 hover:text-white" : "cursor-not-allowed text-neutral-700"}`}
|
|
||||||
>
|
|
||||||
<SkipForward size={20} aria-hidden />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 좁은 화면에서 대기열 열기(넓은 화면에는 우측 하단에 같은 버튼을 따로 둔다) */}
|
|
||||||
{onToggleQueue && (
|
|
||||||
<button
|
|
||||||
onClick={onToggleQueue}
|
|
||||||
aria-label={isQueueOpen ? "대기열 닫기" : "대기열 열기"}
|
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800 hover:text-white lg:hidden"
|
|
||||||
>
|
|
||||||
<ListMusic size={20} aria-hidden />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 데스크톱 진행바 */}
|
|
||||||
<div className="hidden w-full items-center gap-2 text-xs tabular-nums text-neutral-400 sm:flex">
|
|
||||||
<span className="w-10 text-right">{formatTime(position)}</span>
|
|
||||||
<div className="group relative flex h-4 flex-1 items-center">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={duration || 100}
|
max={100}
|
||||||
value={position}
|
value={volume}
|
||||||
disabled={!canControl}
|
disabled={!canControl}
|
||||||
aria-label="재생 위치"
|
aria-label="볼륨"
|
||||||
onChange={(e) => {
|
aria-valuetext={`${volume}%`}
|
||||||
isDragging.current = true;
|
onChange={(e) => dragVolume(Number(e.target.value))}
|
||||||
setPosition(Number(e.target.value));
|
onMouseDown={beginVolumeDrag}
|
||||||
}}
|
onTouchStart={beginVolumeDrag}
|
||||||
onMouseUp={handleSeekEnd}
|
onKeyDown={(e) => { if (!e.repeat) beginVolumeDrag(); }}
|
||||||
onTouchEnd={handleSeekEnd}
|
onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
|
||||||
className="absolute z-20 h-4 w-full cursor-pointer opacity-0"
|
onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
|
||||||
|
onKeyUp={(e) => commitVolume(Number(e.currentTarget.value))}
|
||||||
|
className="absolute inset-x-2 z-20 h-full cursor-pointer opacity-0"
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700">
|
<div className="pointer-events-none absolute inset-x-2 h-1 rounded-full bg-white/30">
|
||||||
<div
|
<div className="h-full rounded-full bg-white" style={{ width: `${volume}%` }} />
|
||||||
className="h-full bg-white transition-colors group-hover:bg-green-500"
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100"
|
className="pointer-events-none absolute -ml-1.5 h-3 w-3 rounded-full bg-white"
|
||||||
style={{ left: `${progressPercent}%` }}
|
style={{ left: `calc(0.5rem + (100% - 1rem) * ${volume / 100})` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10">{formatTime(duration)}</span>
|
{isVolumeDragging && (
|
||||||
</div>
|
<span className="pointer-events-none absolute bottom-[76px] right-16 rounded bg-yt-menu px-2 py-1 text-xs text-white shadow-lg">
|
||||||
</div>
|
{volume}
|
||||||
|
</span>
|
||||||
{/* 3. 우측 보조 조작: 대기열 + 볼륨 (스포티파이도 이 자리에 대기열 버튼을 둔다) */}
|
)}
|
||||||
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
|
|
||||||
{onToggleDock && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggleDock}
|
type="button"
|
||||||
aria-label={isQueueDocked ? "대기열 접기" : "대기열 펼치기"}
|
onClick={toggleMute}
|
||||||
title="대기열"
|
|
||||||
className={`hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white lg:flex
|
|
||||||
${isQueueDocked ? "text-green-500" : ""}`}
|
|
||||||
>
|
|
||||||
<ListMusic size={20} aria-hidden />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleToggleMute}
|
|
||||||
disabled={!canControl}
|
|
||||||
aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
|
||||||
title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{volume === 0 ? <VolumeX size={20} aria-hidden /> : <Volume2 size={20} aria-hidden />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="group relative flex h-8 w-24 items-center">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={volume}
|
|
||||||
disabled={!canControl}
|
disabled={!canControl}
|
||||||
aria-label="볼륨"
|
aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
||||||
aria-valuetext={`${volume}%`}
|
title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
|
||||||
onChange={handleVolumeChange}
|
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"
|
||||||
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
|
||||||
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
|
||||||
onMouseUp={handleVolumeEnd}
|
|
||||||
onTouchEnd={handleVolumeEnd}
|
|
||||||
className="absolute z-20 h-8 w-full cursor-pointer opacity-0"
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700">
|
|
||||||
<div
|
|
||||||
className="h-full bg-white transition-colors group-hover:bg-green-500"
|
|
||||||
style={{ width: `${volume}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute z-10 -ml-1.5 flex h-3 w-3 justify-center rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100"
|
|
||||||
style={{ left: `${volume}%` }}
|
|
||||||
>
|
>
|
||||||
{isVolumeDragging && (
|
{volume === 0 ? <VolumeX size={24} aria-hidden /> : <Volume2 size={24} aria-hidden />}
|
||||||
<span className="absolute bottom-4 whitespace-nowrap rounded border border-neutral-700 bg-neutral-800 px-2 py-1 text-[10px] font-bold text-white shadow-lg">
|
</button>
|
||||||
{volume}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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="재생 화면 열기"
|
||||||
|
>
|
||||||
|
<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")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
181
page/src/components/player/PlayerPage.tsx
Normal file
181
page/src/components/player/PlayerPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"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, 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="flex-1 pr-11 text-center text-sm font-medium text-white/70">지금 재생 중</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"
|
||||||
|
disabled
|
||||||
|
aria-label="이전 곡 (지원 예정)"
|
||||||
|
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다"
|
||||||
|
className="flex h-12 w-12 cursor-not-allowed items-center justify-center 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
page/src/components/player/QueueList.tsx
Normal file
198
page/src/components/player/QueueList.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronUp, GripVertical, Play, 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 } = player;
|
||||||
|
const { queue: items, moveTo, moveBy, playFrom, remove } = queue;
|
||||||
|
|
||||||
|
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"}>
|
||||||
|
{isPage && (
|
||||||
|
<div className="mb-2 flex items-baseline justify-between gap-2 border-b border-yt-line pb-3">
|
||||||
|
<h2 className="text-2xl font-bold">재생 목록</h2>
|
||||||
|
<span className="text-sm text-white/50">
|
||||||
|
{items.length > 0 ? `다음 트랙 ${items.length}곡` : ""}
|
||||||
|
</span>
|
||||||
|
</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={(item.encoded as string) || `${index}-${title}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => {
|
||||||
|
dragItem.current = index;
|
||||||
|
setTimeout(() => setDraggingIndex(index), 0);
|
||||||
|
}}
|
||||||
|
onDragEnter={() => {
|
||||||
|
dragOverItem.current = index;
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}}
|
||||||
|
onDragEnd={handleSort}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
className={`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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
|
||||||
import { Trash2, GripVertical, Music, ChevronUp, ChevronDown } from "lucide-react";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useToast } from "@/components/ToastProvider";
|
|
||||||
import type { DiscordServer, Track } from "@/types/music";
|
|
||||||
|
|
||||||
interface QueueSidebarProps {
|
|
||||||
selectedServer: DiscordServer | null;
|
|
||||||
nowPlaying?: Track | null;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSidebarProps) {
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
|
|
||||||
const [queue, setQueue] = useState<Track[]>([]);
|
|
||||||
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const dragItem = useRef<number | null>(null);
|
|
||||||
const dragOverItem = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const fetchQueue = useCallback(async () => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/queue/list', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
userId: userId,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success && Array.isArray(data.queue)) {
|
|
||||||
setQueue(data.queue as Track[]);
|
|
||||||
} else {
|
|
||||||
setQueue([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("큐 불러오기 실패:", error);
|
|
||||||
setQueue([]);
|
|
||||||
}
|
|
||||||
}, [selectedServer, session]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "loading" || !selectedServer) return;
|
|
||||||
|
|
||||||
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
fetchQueue();
|
|
||||||
|
|
||||||
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data?.type === "queue_update") {
|
|
||||||
fetchQueue();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("SSE JSON 파싱 실패:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
}, [selectedServer, status, fetchQueue]);
|
|
||||||
|
|
||||||
/** 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다). */
|
|
||||||
const applyReorder = async (newQueue: Track[]) => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
const previous = queue;
|
|
||||||
setQueue(newQueue);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/queue/set', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
userId: session?.user?.id,
|
|
||||||
newQueue,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.success) {
|
|
||||||
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
|
||||||
setQueue(previous);
|
|
||||||
fetchQueue();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("순서 동기화 실패:", error);
|
|
||||||
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
|
|
||||||
setQueue(previous);
|
|
||||||
fetchQueue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSort = async () => {
|
|
||||||
const fromIndex = dragItem.current;
|
|
||||||
const toIndex = dragOverItem.current;
|
|
||||||
|
|
||||||
setDraggingIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
dragItem.current = null;
|
|
||||||
dragOverItem.current = null;
|
|
||||||
|
|
||||||
if (fromIndex === null || toIndex === null || !selectedServer) return;
|
|
||||||
|
|
||||||
let insertIndex = toIndex;
|
|
||||||
if (fromIndex < toIndex && toIndex !== queue.length) {
|
|
||||||
insertIndex = toIndex - 1;
|
|
||||||
}
|
|
||||||
if (fromIndex === insertIndex) return; // 순서가 그대로면 통신하지 않는다
|
|
||||||
|
|
||||||
const newQueue = [...queue];
|
|
||||||
const draggedItem = newQueue.splice(fromIndex, 1)[0];
|
|
||||||
newQueue.splice(insertIndex, 0, draggedItem);
|
|
||||||
await applyReorder(newQueue);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 터치 기기에서는 드래그앤드롭이 동작하지 않아, 한 칸씩 옮기는 버튼을 함께 제공한다. */
|
|
||||||
const moveItem = async (index: number, direction: -1 | 1) => {
|
|
||||||
const target = index + direction;
|
|
||||||
if (target < 0 || target >= queue.length) return;
|
|
||||||
const newQueue = [...queue];
|
|
||||||
[newQueue[index], newQueue[target]] = [newQueue[target], newQueue[index]];
|
|
||||||
await applyReorder(newQueue);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대기열의 곡을 누르면 그 곡부터 재생한다(스포티파이·유튜브뮤직 대기열과 같은 동작).
|
|
||||||
* 봇 API 에 "특정 곡으로 점프"가 없어서, 대기열을 대상 곡부터로 바꾼 뒤 다음 곡으로 넘긴다.
|
|
||||||
* 결과적으로 건너뛴 곡들은 대기열에서 빠진다 — 이것도 위 서비스들과 동일하다.
|
|
||||||
*/
|
|
||||||
const playFrom = async (index: number) => {
|
|
||||||
if (!selectedServer || index <= 0) return;
|
|
||||||
const rest = queue.slice(index);
|
|
||||||
const previous = queue;
|
|
||||||
setQueue(rest);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const setRes = await fetch('/api/queue/set', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
userId: session?.user?.id,
|
|
||||||
newQueue: rest,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const setData = await setRes.json();
|
|
||||||
if (!setRes.ok || !setData.success) {
|
|
||||||
showToast(setData.message || setData.error || "곡을 건너뛰지 못했습니다.", "error");
|
|
||||||
setQueue(previous);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetch('/api/player/skip', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ serverId: selectedServer.id }),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("곡 점프 실패:", error);
|
|
||||||
showToast("곡을 건너뛰지 못했습니다.", "error");
|
|
||||||
setQueue(previous);
|
|
||||||
fetchQueue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (indexToRemove: number) => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
|
|
||||||
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
|
|
||||||
const targetEncoded = queue[indexToRemove]?.encoded;
|
|
||||||
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
|
||||||
setQueue(newQueue);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/queue/remove', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverId: selectedServer.id,
|
|
||||||
index: indexToRemove,
|
|
||||||
encoded: targetEncoded,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.success) {
|
|
||||||
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
|
||||||
fetchQueue();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("삭제 실패:", error);
|
|
||||||
showToast("노래를 삭제하지 못했습니다.", "error");
|
|
||||||
fetchQueue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="flex h-full flex-col bg-neutral-950 p-4 lg:border-l lg:border-neutral-800">
|
|
||||||
{/* "지금 재생 중"을 대기열 맨 위에 두는 건 스포티파이·유튜브뮤직 대기열의 공통 구조다. */}
|
|
||||||
{nowPlaying && (
|
|
||||||
<div className="mb-4 pr-12 lg:pr-0">
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-green-500">
|
|
||||||
지금 재생 중
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-green-500/30 bg-green-500/10 p-2">
|
|
||||||
<span className="h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
|
|
||||||
{nowPlaying.info?.artworkUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={nowPlaying.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-medium text-white">
|
|
||||||
{nowPlaying.info?.title || "제목 없음"}
|
|
||||||
</span>
|
|
||||||
<span className="block truncate text-xs text-neutral-400">
|
|
||||||
{nowPlaying.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`mb-3 flex items-center justify-between gap-2 ${nowPlaying ? "" : "pr-12 lg:pr-0"}`}>
|
|
||||||
<h2 className="text-base font-bold">다음 재생 목록</h2>
|
|
||||||
{queue.length > 0 && (
|
|
||||||
<span className="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
|
|
||||||
{queue.length}곡
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{queue.length === 0 ? (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-neutral-500">
|
|
||||||
<Music size={32} className="opacity-50" aria-hidden />
|
|
||||||
<p className="text-sm">대기 중인 노래가 없습니다.</p>
|
|
||||||
<p className="text-xs text-neutral-600">
|
|
||||||
검색해서 나온 곡의 재생 버튼을 누르면 여기에 쌓입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-1 overflow-y-auto pb-24 pr-1">
|
|
||||||
{queue.map((item, index) => {
|
|
||||||
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
|
|
||||||
const title = item.info?.title || "제목 없음";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={(item.encoded as string) || `${index}-${title}`}
|
|
||||||
draggable
|
|
||||||
onDragStart={() => {
|
|
||||||
dragItem.current = index;
|
|
||||||
setTimeout(() => setDraggingIndex(index), 0);
|
|
||||||
}}
|
|
||||||
onDragEnter={() => {
|
|
||||||
dragOverItem.current = index;
|
|
||||||
setDragOverIndex(index);
|
|
||||||
}}
|
|
||||||
onDragEnd={handleSort}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
className={`relative flex items-center gap-2 rounded-lg border border-transparent bg-neutral-900/60 p-2 transition-colors hover:border-neutral-700
|
|
||||||
${draggingIndex === index ? "opacity-40" : "opacity-100"}
|
|
||||||
${isDropTarget ? "border-green-500/70" : ""}`}
|
|
||||||
>
|
|
||||||
{/* 드래그 핸들: 마우스 환경에서만 의미가 있어 큰 화면에서만 노출 */}
|
|
||||||
<span className="hidden cursor-grab text-neutral-600 active:cursor-grabbing lg:block" aria-hidden>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 곡을 누르면 그 곡부터 재생 — 다른 음악 서비스의 대기열과 같은 조작감 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => playFrom(index)}
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left"
|
|
||||||
aria-label={`${title} 부터 재생`}
|
|
||||||
title="이 곡부터 재생"
|
|
||||||
>
|
|
||||||
<span className="w-4 flex-shrink-0 text-center text-xs tabular-nums text-neutral-500">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="relative h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
|
|
||||||
{item.info?.artworkUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={item.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-medium text-white">{title}</span>
|
|
||||||
<span className="block truncate text-xs text-neutral-400">
|
|
||||||
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 조작 버튼: hover 로 숨기지 않는다(터치 기기에서는 hover 가 없어 아예 못 쓴다).
|
|
||||||
순서 이동 버튼은 드래그가 안 되는 좁은 화면에서만 띄워 제목 폭을 확보한다. */}
|
|
||||||
<span className="flex flex-shrink-0 items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => moveItem(index, -1)}
|
|
||||||
disabled={index === 0}
|
|
||||||
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
|
|
||||||
aria-label={`${title} 위로 옮기기`}
|
|
||||||
>
|
|
||||||
<ChevronUp size={16} aria-hidden />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => moveItem(index, 1)}
|
|
||||||
disabled={index === queue.length - 1}
|
|
||||||
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
|
|
||||||
aria-label={`${title} 아래로 옮기기`}
|
|
||||||
>
|
|
||||||
<ChevronDown size={16} aria-hidden />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(index)}
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-red-400"
|
|
||||||
aria-label={`${title} 대기열에서 빼기`}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} aria-hidden />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */}
|
|
||||||
{draggingIndex !== null && (
|
|
||||||
<li
|
|
||||||
onDragEnter={() => {
|
|
||||||
dragOverItem.current = queue.length;
|
|
||||||
setDragOverIndex(queue.length);
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
className="hidden h-10 items-center justify-center lg:flex"
|
|
||||||
>
|
|
||||||
{dragOverIndex === queue.length && (
|
|
||||||
<span className="h-0.5 w-full rounded-full bg-green-500" />
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
100
page/src/components/ui.tsx
Normal file
100
page/src/components/ui.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
import { Music, Server } from "lucide-react";
|
||||||
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
import { serverIconUrl } from "@/hooks/useServers";
|
||||||
|
|
||||||
|
/** 유튜브 뮤직 로고 형태(빨간 원 + 흰 링 + 재생 삼각형) */
|
||||||
|
export function Logo({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center gap-1 ${className}`}>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-6 w-6 flex-shrink-0" aria-hidden>
|
||||||
|
<circle cx="12" cy="12" r="12" fill="#ff0000" />
|
||||||
|
<circle cx="12" cy="12" r="6.6" fill="none" stroke="#fff" strokeWidth="1.1" />
|
||||||
|
<path d="M10 9.2v5.6l4.9-2.8z" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[20px] font-bold tracking-[-0.04em] text-white">MusicBot</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유튜브 기본 썸네일(hq/mq/sd/0.jpg)은 4:3 안에 16:9 영상이 위아래 검은 띠와 함께 들어 있다. */
|
||||||
|
const LETTERBOXED_YT = /i\.ytimg\.com\/vi\/[^/]+\/(hqdefault|mqdefault|sddefault|0)\.jpg/;
|
||||||
|
|
||||||
|
/** 앨범아트/썸네일(이미지가 없으면 음표 아이콘) */
|
||||||
|
export function Thumb({
|
||||||
|
src,
|
||||||
|
className = "",
|
||||||
|
rounded = "rounded-sm",
|
||||||
|
iconSize = 18,
|
||||||
|
wide = false,
|
||||||
|
}: {
|
||||||
|
src?: string | null;
|
||||||
|
className?: string;
|
||||||
|
rounded?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
/** 16:9 상자(동영상 행)면 true. 정사각형 상자에서만 검은 띠를 잘라낸다. */
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
// 정사각형 상자에 4:3 유튜브 썸네일을 넣으면 검은 띠가 보이므로 4/3 배 확대해 띠를 잘라낸다.
|
||||||
|
const cropBars = !wide && !!src && LETTERBOXED_YT.test(src);
|
||||||
|
return (
|
||||||
|
<span className={`relative flex flex-shrink-0 items-center justify-center overflow-hidden bg-yt-raised ${rounded} ${className}`}>
|
||||||
|
{src ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={src} alt="" className={`h-full w-full object-cover ${cropBars ? "scale-[1.34]" : ""}`} />
|
||||||
|
) : (
|
||||||
|
<Music size={iconSize} className="text-white/40" aria-hidden />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디스코드 서버 아이콘(원형, 없으면 서버 아이콘) */
|
||||||
|
export function ServerAvatar({
|
||||||
|
server,
|
||||||
|
className = "",
|
||||||
|
iconSize = 20,
|
||||||
|
size = 128,
|
||||||
|
rounded = "rounded-full",
|
||||||
|
}: {
|
||||||
|
server: DiscordServer;
|
||||||
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
size?: number;
|
||||||
|
rounded?: string;
|
||||||
|
}) {
|
||||||
|
const url = serverIconUrl(server, size);
|
||||||
|
return (
|
||||||
|
<span className={`flex flex-shrink-0 items-center justify-center overflow-hidden bg-yt-raised ${rounded} ${className}`}>
|
||||||
|
{url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Server size={iconSize} className="text-white/50" aria-hidden />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유튜브 뮤직 스타일 원형 로딩 표시 */
|
||||||
|
export function Spinner({ size = 32, label = "불러오는 중" }: { size?: number; label?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={label}
|
||||||
|
className="inline-block animate-spin rounded-full border-[3px] border-white/15 border-t-white"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 재생 중 이퀄라이저 표시 */
|
||||||
|
export function Equalizer({ paused = false, className = "" }: { paused?: boolean; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`yt-eq flex items-end gap-[2px] ${paused ? "paused" : ""} ${className}`} aria-hidden>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
page/src/hooks/usePlayer.ts
Normal file
330
page/src/hooks/usePlayer.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import type { DiscordServer, Track } from "@/types/music";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇의 재생 상태(현재 곡·진행·볼륨)와 조작 액션.
|
||||||
|
* 재생바와 펼친 재생 화면이 같은 상태를 써야 해서 컴포넌트 밖으로 뺐다(폴링/SSE 는 한 번만).
|
||||||
|
*/
|
||||||
|
export function usePlayer(selectedServer: DiscordServer | null) {
|
||||||
|
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 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 && 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 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,
|
||||||
|
togglePause,
|
||||||
|
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", "") ?? "";
|
||||||
|
}
|
||||||
204
page/src/hooks/useQueue.ts
Normal file
204
page/src/hooks/useQueue.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"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();
|
||||||
|
|
||||||
|
const [queue, setQueue] = useState<Track[]>([]);
|
||||||
|
|
||||||
|
const fetchQueue = useCallback(async () => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/queue/list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
userId: userId,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.success && Array.isArray(data.queue)) {
|
||||||
|
setQueue(data.queue as Track[]);
|
||||||
|
} else {
|
||||||
|
setQueue([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("큐 불러오기 실패:", error);
|
||||||
|
setQueue([]);
|
||||||
|
}
|
||||||
|
}, [selectedServer, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading" || !selectedServer) return;
|
||||||
|
|
||||||
|
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
fetchQueue();
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data?.type === "queue_update") {
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("SSE JSON 파싱 실패:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [selectedServer, status, fetchQueue]);
|
||||||
|
|
||||||
|
/** 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다). */
|
||||||
|
const applyReorder = async (newQueue: Track[]) => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
const previous = queue;
|
||||||
|
setQueue(newQueue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/queue/set', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
userId: session?.user?.id,
|
||||||
|
newQueue,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.success) {
|
||||||
|
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
||||||
|
setQueue(previous);
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("순서 동기화 실패:", error);
|
||||||
|
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
|
||||||
|
setQueue(previous);
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 드래그앤드롭 결과 반영. 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,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { queue, moveTo, moveBy, playFrom, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueState = ReturnType<typeof useQueue>;
|
||||||
67
page/src/hooks/useServers.ts
Normal file
67
page/src/hooks/useServers.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
|
||||||
|
/** 봇이 들어가 있는 내 서버 목록. 홈 화면과 왼쪽 메뉴가 함께 쓴다. */
|
||||||
|
export function useServers() {
|
||||||
|
const { status } = useSession();
|
||||||
|
const [servers, setServers] = useState<DiscordServer[]>([]);
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "authenticated") return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch("/api/servers")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setServers(data);
|
||||||
|
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("서버 목록 불러오기 실패:", err))
|
||||||
|
.finally(() => setIsFetching(false));
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
return { servers, isFetching };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디스코드 서버 아이콘 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 "멤버";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user