deploy: build Hugging Face Space from source
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +11 -0
- .env.example +44 -0
- .gitattributes +13 -0
- .github/workflows/build.yml +34 -0
- .gitignore +15 -0
- .prettierrc +7 -0
- Dockerfile +41 -1
- LICENSE +661 -0
- README.en.md +188 -0
- docs/PROJECT_ARCHITECTURE.md +44 -0
- docs/UPDATE_NOTES.md +137 -0
- docs/architecture/coding-standards.md +79 -0
- docs/architecture/data-flow.md +307 -0
- docs/architecture/dependencies.md +77 -0
- docs/architecture/deployment.md +64 -0
- docs/architecture/design-patterns.md +204 -0
- docs/architecture/dev-guide.md +74 -0
- docs/architecture/directory-structure.md +219 -0
- docs/architecture/ui-design.md +150 -0
- package.json +30 -0
- packages/client/.env.development +2 -0
- packages/client/.gitignore +24 -0
- packages/client/README.md +73 -0
- packages/client/components.json +23 -0
- packages/client/eslint.config.js +23 -0
- packages/client/index.html +14 -0
- packages/client/package.json +68 -0
- packages/client/public/logo.svg +6 -0
- packages/client/src/App.tsx +92 -0
- packages/client/src/components/Chat/ChatMessage.tsx +46 -0
- packages/client/src/components/Chat/ChatPanel.tsx +141 -0
- packages/client/src/components/GlobalBackground.tsx +121 -0
- packages/client/src/components/InteractionGate.tsx +113 -0
- packages/client/src/components/Lobby/ActionCards.tsx +71 -0
- packages/client/src/components/Lobby/CreateRoomDialog.tsx +120 -0
- packages/client/src/components/Lobby/HeroSection.tsx +23 -0
- packages/client/src/components/Lobby/NicknameDialog.tsx +75 -0
- packages/client/src/components/Lobby/PasswordDialog.tsx +81 -0
- packages/client/src/components/Lobby/RoomCard.tsx +87 -0
- packages/client/src/components/Lobby/RoomListSection.tsx +62 -0
- packages/client/src/components/Lobby/UserPopover.tsx +166 -0
- packages/client/src/components/Overlays/BilibiliCollectionDialog.tsx +127 -0
- packages/client/src/components/Overlays/BilibiliMetadataDialog.tsx +137 -0
- packages/client/src/components/Overlays/QueueDrawer.tsx +482 -0
- packages/client/src/components/Overlays/SearchDialog.tsx +720 -0
- packages/client/src/components/Overlays/Settings/AccountSection.tsx +273 -0
- packages/client/src/components/Overlays/Settings/AdminSection.tsx +602 -0
- packages/client/src/components/Overlays/Settings/AppearanceSection.tsx +69 -0
- packages/client/src/components/Overlays/Settings/LoginSection.tsx +113 -0
- packages/client/src/components/Overlays/Settings/LyricsSection.tsx +159 -0
.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
docs
|
| 4 |
+
.git
|
| 5 |
+
.cursor
|
| 6 |
+
.agent
|
| 7 |
+
.shared
|
| 8 |
+
.env
|
| 9 |
+
screenshots
|
| 10 |
+
packages/server/data
|
| 11 |
+
*.md
|
.env.example
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Music Together 服务端配置
|
| 3 |
+
# 复制此文件为 .env 并填写需要的值
|
| 4 |
+
# =============================================================================
|
| 5 |
+
#
|
| 6 |
+
# 所有变量都有合理默认值。
|
| 7 |
+
# Docker 同域部署时无需配置任何环境变量(前端由 Express 托管,自动同源)。
|
| 8 |
+
# 本地开发时也无需配置(CORS 自动放行所有来源)。
|
| 9 |
+
#
|
| 10 |
+
|
| 11 |
+
# 服务端端口(默认 3001)
|
| 12 |
+
# PORT=3001
|
| 13 |
+
|
| 14 |
+
# 客户端 URL(可选,用于 CORS 严格白名单模式)
|
| 15 |
+
# - 不设置:自动模式,允许所有来源访问(适用于单镜像部署、局域网、公网反代)
|
| 16 |
+
# - 设置为具体域名:严格白名单模式(适用于前后端分离跨域部署)
|
| 17 |
+
# CLIENT_URL=https://music.example.com
|
| 18 |
+
|
| 19 |
+
# 额外的 CORS origins(逗号分隔,可选)
|
| 20 |
+
# CORS_ORIGINS=https://other-domain.com,https://another.com
|
| 21 |
+
|
| 22 |
+
# Identity cookie policy. Defaults are suitable for normal same-origin deployments.
|
| 23 |
+
# For embedded Hugging Face Spaces, use the following values:
|
| 24 |
+
# IDENTITY_COOKIE_SAME_SITE=none
|
| 25 |
+
# IDENTITY_COOKIE_PARTITIONED=true
|
| 26 |
+
|
| 27 |
+
# Log level: debug | info | warn | error (default: info)
|
| 28 |
+
# LOG_LEVEL=info
|
| 29 |
+
|
| 30 |
+
# Log output: pretty (default, readable single-line output) | json
|
| 31 |
+
# Use json only when logs are collected by a structured logging platform.
|
| 32 |
+
# LOG_FORMAT=pretty
|
| 33 |
+
|
| 34 |
+
# QQ 音乐单曲搜索 API(仅用于 QQ 音乐的歌曲搜索;未配置时回退到 QQ 原生搜索)
|
| 35 |
+
# URL 可填写 Master 根地址或完整的 /api 地址
|
| 36 |
+
# QQ_MUSIC_API_URL=http://localhost:9000
|
| 37 |
+
# QQ_MUSIC_API_KEY=ag_xxxxxxxxxxxxxxxxxxxxxxxx
|
| 38 |
+
|
| 39 |
+
# SQLite database path. Keep this under /app/data in Docker and mount that directory.
|
| 40 |
+
# DATABASE_URL=file:./data/music-together.db
|
| 41 |
+
|
| 42 |
+
# 服务器管理员账号 ID,多个 ID 使用英文逗号分隔。
|
| 43 |
+
# 先在网页中为账号设置密码,再复制账号 ID 填到这里并重启服务器。
|
| 44 |
+
# SERVER_ADMIN_IDS=account-id-1,account-id-2
|
.gitattributes
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
| 3 |
+
|
| 4 |
+
# 源码文件强制使用 LF
|
| 5 |
+
*.ts text eol=lf
|
| 6 |
+
*.tsx text eol=lf
|
| 7 |
+
*.js text eol=lf
|
| 8 |
+
*.json text eol=lf
|
| 9 |
+
*.md text eol=lf
|
| 10 |
+
*.css text eol=lf
|
| 11 |
+
*.html text eol=lf
|
| 12 |
+
*.yaml text eol=lf
|
| 13 |
+
*.yml text eol=lf
|
.github/workflows/build.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build & Push
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
build:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
permissions:
|
| 12 |
+
contents: read
|
| 13 |
+
packages: write
|
| 14 |
+
steps:
|
| 15 |
+
- uses: actions/checkout@v4
|
| 16 |
+
|
| 17 |
+
- name: Set image name to lowercase
|
| 18 |
+
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
| 19 |
+
|
| 20 |
+
- uses: docker/login-action@v3
|
| 21 |
+
with:
|
| 22 |
+
registry: ghcr.io
|
| 23 |
+
username: ${{ github.actor }}
|
| 24 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 25 |
+
|
| 26 |
+
- uses: docker/setup-buildx-action@v3
|
| 27 |
+
|
| 28 |
+
- uses: docker/build-push-action@v6
|
| 29 |
+
with:
|
| 30 |
+
context: .
|
| 31 |
+
push: true
|
| 32 |
+
tags: ${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}
|
| 33 |
+
cache-from: type=gha
|
| 34 |
+
cache-to: type=gha,mode=max
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
*.tsbuildinfo
|
| 4 |
+
.env
|
| 5 |
+
.env.local
|
| 6 |
+
*.log
|
| 7 |
+
.DS_Store
|
| 8 |
+
Thumbs.db
|
| 9 |
+
.claude
|
| 10 |
+
.spec-workflow
|
| 11 |
+
.pnpm-store/
|
| 12 |
+
.omc
|
| 13 |
+
.trae
|
| 14 |
+
/.sanshu-memory
|
| 15 |
+
/packages/server/data/
|
.prettierrc
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"semi": false,
|
| 3 |
+
"singleQuote": true,
|
| 4 |
+
"trailingComma": "all",
|
| 5 |
+
"printWidth": 120,
|
| 6 |
+
"tabWidth": 2
|
| 7 |
+
}
|
Dockerfile
CHANGED
|
@@ -1 +1,41 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Install workspace dependencies.
|
| 2 |
+
FROM node:22-alpine AS deps
|
| 3 |
+
RUN corepack enable
|
| 4 |
+
RUN apk add --no-cache python3 make g++
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
| 7 |
+
COPY packages/shared/package.json packages/shared/
|
| 8 |
+
COPY packages/server/package.json packages/server/
|
| 9 |
+
COPY packages/client/package.json packages/client/
|
| 10 |
+
RUN pnpm install --frozen-lockfile
|
| 11 |
+
|
| 12 |
+
# Build all workspace packages.
|
| 13 |
+
FROM deps AS build
|
| 14 |
+
COPY packages/shared/ packages/shared/
|
| 15 |
+
COPY packages/server/ packages/server/
|
| 16 |
+
COPY packages/client/ packages/client/
|
| 17 |
+
RUN pnpm --filter @music-together/shared run build
|
| 18 |
+
RUN pnpm --filter @music-together/server run build
|
| 19 |
+
RUN pnpm --filter @music-together/client run build
|
| 20 |
+
|
| 21 |
+
# Run the production server, which also serves the client bundle.
|
| 22 |
+
FROM node:22-alpine AS production
|
| 23 |
+
RUN corepack enable
|
| 24 |
+
RUN apk add --no-cache python3 make g++ vips
|
| 25 |
+
WORKDIR /app
|
| 26 |
+
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
| 27 |
+
COPY packages/shared/package.json packages/shared/
|
| 28 |
+
COPY packages/server/package.json packages/server/
|
| 29 |
+
COPY packages/client/package.json packages/client/
|
| 30 |
+
RUN pnpm install --frozen-lockfile --prod --filter @music-together/server...
|
| 31 |
+
COPY --from=build /app/packages/server/dist packages/server/dist
|
| 32 |
+
COPY --from=build /app/packages/client/dist packages/client/dist
|
| 33 |
+
COPY --from=build /app/packages/shared/dist packages/shared/dist
|
| 34 |
+
RUN sed -i 's|./src/index.ts|./dist/index.js|g' packages/shared/package.json
|
| 35 |
+
RUN mkdir -p /data
|
| 36 |
+
|
| 37 |
+
EXPOSE 3001
|
| 38 |
+
ENV NODE_ENV=production
|
| 39 |
+
ENV DATABASE_URL=file:/data/music-together.db
|
| 40 |
+
VOLUME ["/data"]
|
| 41 |
+
CMD ["node", "packages/server/dist/index.js"]
|
LICENSE
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works, specifically designed to ensure
|
| 12 |
+
cooperation with the community in the case of network server software.
|
| 13 |
+
|
| 14 |
+
The licenses for most software and other practical works are designed
|
| 15 |
+
to take away your freedom to share and change the works. By contrast,
|
| 16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 17 |
+
share and change all versions of a program--to make sure it remains free
|
| 18 |
+
software for all its users.
|
| 19 |
+
|
| 20 |
+
When we speak of free software, we are referring to freedom, not
|
| 21 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 22 |
+
have the freedom to distribute copies of free software (and charge for
|
| 23 |
+
them if you wish), that you receive source code or can get it if you
|
| 24 |
+
want it, that you can change the software or use pieces of it in new
|
| 25 |
+
free programs, and that you know you can do these things.
|
| 26 |
+
|
| 27 |
+
Developers that use our General Public Licenses protect your rights
|
| 28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 29 |
+
you this License which gives you legal permission to copy, distribute
|
| 30 |
+
and/or modify the software.
|
| 31 |
+
|
| 32 |
+
A secondary benefit of defending all users' freedom is that
|
| 33 |
+
improvements made in alternate versions of the program, if they
|
| 34 |
+
receive widespread use, become available for other developers to
|
| 35 |
+
incorporate. Many developers of free software are heartened and
|
| 36 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 37 |
+
software used on network servers, this result may fail to come about.
|
| 38 |
+
The GNU General Public License permits making a modified version and
|
| 39 |
+
letting the public access it on a server without ever releasing its
|
| 40 |
+
source code to the public.
|
| 41 |
+
|
| 42 |
+
The GNU Affero General Public License is designed specifically to
|
| 43 |
+
ensure that, in such cases, the modified source code becomes available
|
| 44 |
+
to the community. It requires the operator of a network server to
|
| 45 |
+
provide the source code of the modified version running there to the
|
| 46 |
+
users of that server. Therefore, public use of a modified version, on
|
| 47 |
+
a publicly accessible server, gives the public access to the source
|
| 48 |
+
code of the modified version.
|
| 49 |
+
|
| 50 |
+
An older license, called the Affero General Public License and
|
| 51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 53 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 54 |
+
this license.
|
| 55 |
+
|
| 56 |
+
The precise terms and conditions for copying, distribution and
|
| 57 |
+
modification follow.
|
| 58 |
+
|
| 59 |
+
TERMS AND CONDITIONS
|
| 60 |
+
|
| 61 |
+
0. Definitions.
|
| 62 |
+
|
| 63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 64 |
+
|
| 65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 66 |
+
works, such as semiconductor masks.
|
| 67 |
+
|
| 68 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 70 |
+
"recipients" may be individuals or organizations.
|
| 71 |
+
|
| 72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 73 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 74 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 75 |
+
earlier work or a work "based on" the earlier work.
|
| 76 |
+
|
| 77 |
+
A "covered work" means either the unmodified Program or a work based
|
| 78 |
+
on the Program.
|
| 79 |
+
|
| 80 |
+
To "propagate" a work means to do anything with it that, without
|
| 81 |
+
permission, would make you directly or secondarily liable for
|
| 82 |
+
infringement under applicable copyright law, except executing it on a
|
| 83 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 84 |
+
distribution (with or without modification), making available to the
|
| 85 |
+
public, and in some countries other activities as well.
|
| 86 |
+
|
| 87 |
+
To "convey" a work means any kind of propagation that enables other
|
| 88 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 90 |
+
|
| 91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 92 |
+
to the extent that it includes a convenient and prominently visible
|
| 93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 94 |
+
tells the user that there is no warranty for the work (except to the
|
| 95 |
+
extent that warranties are provided), that licensees may convey the
|
| 96 |
+
work under this License, and how to view a copy of this License. If
|
| 97 |
+
the interface presents a list of user commands or options, such as a
|
| 98 |
+
menu, a prominent item in the list meets this criterion.
|
| 99 |
+
|
| 100 |
+
1. Source Code.
|
| 101 |
+
|
| 102 |
+
The "source code" for a work means the preferred form of the work
|
| 103 |
+
for making modifications to it. "Object code" means any non-source
|
| 104 |
+
form of a work.
|
| 105 |
+
|
| 106 |
+
A "Standard Interface" means an interface that either is an official
|
| 107 |
+
standard defined by a recognized standards body, or, in the case of
|
| 108 |
+
interfaces specified for a particular programming language, one that
|
| 109 |
+
is widely used among developers working in that language.
|
| 110 |
+
|
| 111 |
+
The "System Libraries" of an executable work include anything, other
|
| 112 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 113 |
+
packaging a Major Component, but which is not part of that Major
|
| 114 |
+
Component, and (b) serves only to enable use of the work with that
|
| 115 |
+
Major Component, or to implement a Standard Interface for which an
|
| 116 |
+
implementation is available to the public in source code form. A
|
| 117 |
+
"Major Component", in this context, means a major essential component
|
| 118 |
+
(kernel, window system, and so on) of the specific operating system
|
| 119 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 120 |
+
produce the work, or an object code interpreter used to run it.
|
| 121 |
+
|
| 122 |
+
The "Corresponding Source" for a work in object code form means all
|
| 123 |
+
the source code needed to generate, install, and (for an executable
|
| 124 |
+
work) run the object code and to modify the work, including scripts to
|
| 125 |
+
control those activities. However, it does not include the work's
|
| 126 |
+
System Libraries, or general-purpose tools or generally available free
|
| 127 |
+
programs which are used unmodified in performing those activities but
|
| 128 |
+
which are not part of the work. For example, Corresponding Source
|
| 129 |
+
includes interface definition files associated with source files for
|
| 130 |
+
the work, and the source code for shared libraries and dynamically
|
| 131 |
+
linked subprograms that the work is specifically designed to require,
|
| 132 |
+
such as by intimate data communication or control flow between those
|
| 133 |
+
subprograms and other parts of the work.
|
| 134 |
+
|
| 135 |
+
The Corresponding Source need not include anything that users
|
| 136 |
+
can regenerate automatically from other parts of the Corresponding
|
| 137 |
+
Source.
|
| 138 |
+
|
| 139 |
+
The Corresponding Source for a work in source code form is that
|
| 140 |
+
same work.
|
| 141 |
+
|
| 142 |
+
2. Basic Permissions.
|
| 143 |
+
|
| 144 |
+
All rights granted under this License are granted for the term of
|
| 145 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 146 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 147 |
+
permission to run the unmodified Program. The output from running a
|
| 148 |
+
covered work is covered by this License only if the output, given its
|
| 149 |
+
content, constitutes a covered work. This License acknowledges your
|
| 150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 151 |
+
|
| 152 |
+
You may make, run and propagate covered works that you do not
|
| 153 |
+
convey, without conditions so long as your license otherwise remains
|
| 154 |
+
in force. You may convey covered works to others for the sole purpose
|
| 155 |
+
of having them make modifications exclusively for you, or provide you
|
| 156 |
+
with facilities for running those works, provided that you comply with
|
| 157 |
+
the terms of this License in conveying all material for which you do
|
| 158 |
+
not control copyright. Those thus making or running the covered works
|
| 159 |
+
for you must do so exclusively on your behalf, under your direction
|
| 160 |
+
and control, on terms that prohibit them from making any copies of
|
| 161 |
+
your copyrighted material outside their relationship with you.
|
| 162 |
+
|
| 163 |
+
Conveying under any other circumstances is permitted solely under
|
| 164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 165 |
+
makes it unnecessary.
|
| 166 |
+
|
| 167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 168 |
+
|
| 169 |
+
No covered work shall be deemed part of an effective technological
|
| 170 |
+
measure under any applicable law fulfilling obligations under article
|
| 171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 172 |
+
similar laws prohibiting or restricting circumvention of such
|
| 173 |
+
measures.
|
| 174 |
+
|
| 175 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 176 |
+
circumvention of technological measures to the extent such circumvention
|
| 177 |
+
is effected by exercising rights under this License with respect to
|
| 178 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 179 |
+
modification of the work as a means of enforcing, against the work's
|
| 180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 181 |
+
technological measures.
|
| 182 |
+
|
| 183 |
+
4. Conveying Verbatim Copies.
|
| 184 |
+
|
| 185 |
+
You may convey verbatim copies of the Program's source code as you
|
| 186 |
+
receive it, in any medium, provided that you conspicuously and
|
| 187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 188 |
+
keep intact all notices stating that this License and any
|
| 189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 190 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 191 |
+
recipients a copy of this License along with the Program.
|
| 192 |
+
|
| 193 |
+
You may charge any price or no price for each copy that you convey,
|
| 194 |
+
and you may offer support or warranty protection for a fee.
|
| 195 |
+
|
| 196 |
+
5. Conveying Modified Source Versions.
|
| 197 |
+
|
| 198 |
+
You may convey a work based on the Program, or the modifications to
|
| 199 |
+
produce it from the Program, in the form of source code under the
|
| 200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 201 |
+
|
| 202 |
+
a) The work must carry prominent notices stating that you modified
|
| 203 |
+
it, and giving a relevant date.
|
| 204 |
+
|
| 205 |
+
b) The work must carry prominent notices stating that it is
|
| 206 |
+
released under this License and any conditions added under section
|
| 207 |
+
7. This requirement modifies the requirement in section 4 to
|
| 208 |
+
"keep intact all notices".
|
| 209 |
+
|
| 210 |
+
c) You must license the entire work, as a whole, under this
|
| 211 |
+
License to anyone who comes into possession of a copy. This
|
| 212 |
+
License will therefore apply, along with any applicable section 7
|
| 213 |
+
additional terms, to the whole of the work, and all its parts,
|
| 214 |
+
regardless of how they are packaged. This License gives no
|
| 215 |
+
permission to license the work in any other way, but it does not
|
| 216 |
+
invalidate such permission if you have separately received it.
|
| 217 |
+
|
| 218 |
+
d) If the work has interactive user interfaces, each must display
|
| 219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 221 |
+
work need not make them do so.
|
| 222 |
+
|
| 223 |
+
A compilation of a covered work with other separate and independent
|
| 224 |
+
works, which are not by their nature extensions of the covered work,
|
| 225 |
+
and which are not combined with it such as to form a larger program,
|
| 226 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 228 |
+
used to limit the access or legal rights of the compilation's users
|
| 229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 230 |
+
in an aggregate does not cause this License to apply to the other
|
| 231 |
+
parts of the aggregate.
|
| 232 |
+
|
| 233 |
+
6. Conveying Non-Source Forms.
|
| 234 |
+
|
| 235 |
+
You may convey a covered work in object code form under the terms
|
| 236 |
+
of sections 4 and 5, provided that you also convey the
|
| 237 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 238 |
+
in one of these ways:
|
| 239 |
+
|
| 240 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 241 |
+
(including a physical distribution medium), accompanied by the
|
| 242 |
+
Corresponding Source fixed on a durable physical medium
|
| 243 |
+
customarily used for software interchange.
|
| 244 |
+
|
| 245 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 246 |
+
(including a physical distribution medium), accompanied by a
|
| 247 |
+
written offer, valid for at least three years and valid for as
|
| 248 |
+
long as you offer spare parts or customer support for that product
|
| 249 |
+
model, to give anyone who possesses the object code either (1) a
|
| 250 |
+
copy of the Corresponding Source for all the software in the
|
| 251 |
+
product that is covered by this License, on a durable physical
|
| 252 |
+
medium customarily used for software interchange, for a price no
|
| 253 |
+
more than your reasonable cost of physically performing this
|
| 254 |
+
conveying of source, or (2) access to copy the
|
| 255 |
+
Corresponding Source from a network server at no charge.
|
| 256 |
+
|
| 257 |
+
c) Convey individual copies of the object code with a copy of the
|
| 258 |
+
written offer to provide the Corresponding Source. This
|
| 259 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 260 |
+
only if you received the object code with such an offer, in accord
|
| 261 |
+
with subsection 6b.
|
| 262 |
+
|
| 263 |
+
d) Convey the object code by offering access from a designated
|
| 264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 265 |
+
Corresponding Source in the same way through the same place at no
|
| 266 |
+
further charge. You need not require recipients to copy the
|
| 267 |
+
Corresponding Source along with the object code. If the place to
|
| 268 |
+
copy the object code is a network server, the Corresponding Source
|
| 269 |
+
may be on a different server (operated by you or a third party)
|
| 270 |
+
that supports equivalent copying facilities, provided you maintain
|
| 271 |
+
clear directions next to the object code saying where to find the
|
| 272 |
+
Corresponding Source. Regardless of what server hosts the
|
| 273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 274 |
+
available for as long as needed to satisfy these requirements.
|
| 275 |
+
|
| 276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 277 |
+
you inform other peers where the object code and Corresponding
|
| 278 |
+
Source of the work are being offered to the general public at no
|
| 279 |
+
charge under subsection 6d.
|
| 280 |
+
|
| 281 |
+
A separable portion of the object code, whose source code is excluded
|
| 282 |
+
from the Corresponding Source as a System Library, need not be
|
| 283 |
+
included in conveying the object code work.
|
| 284 |
+
|
| 285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 286 |
+
tangible personal property which is normally used for personal, family,
|
| 287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 290 |
+
product received by a particular user, "normally used" refers to a
|
| 291 |
+
typical or common use of that class of product, regardless of the status
|
| 292 |
+
of the particular user or of the way in which the particular user
|
| 293 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 294 |
+
is a consumer product regardless of whether the product has substantial
|
| 295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 296 |
+
the only significant mode of use of the product.
|
| 297 |
+
|
| 298 |
+
"Installation Information" for a User Product means any methods,
|
| 299 |
+
procedures, authorization keys, or other information required to install
|
| 300 |
+
and execute modified versions of a covered work in that User Product from
|
| 301 |
+
a modified version of its Corresponding Source. The information must
|
| 302 |
+
suffice to ensure that the continued functioning of the modified object
|
| 303 |
+
code is in no case prevented or interfered with solely because
|
| 304 |
+
modification has been made.
|
| 305 |
+
|
| 306 |
+
If you convey an object code work under this section in, or with, or
|
| 307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 308 |
+
part of a transaction in which the right of possession and use of the
|
| 309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 310 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 311 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 312 |
+
by the Installation Information. But this requirement does not apply
|
| 313 |
+
if neither you nor any third party retains the ability to install
|
| 314 |
+
modified object code on the User Product (for example, the work has
|
| 315 |
+
been installed in ROM).
|
| 316 |
+
|
| 317 |
+
The requirement to provide Installation Information does not include a
|
| 318 |
+
requirement to continue to provide support service, warranty, or updates
|
| 319 |
+
for a work that has been modified or installed by the recipient, or for
|
| 320 |
+
the User Product in which it has been modified or installed. Access to a
|
| 321 |
+
network may be denied when the modification itself materially and
|
| 322 |
+
adversely affects the operation of the network or violates the rules and
|
| 323 |
+
protocols for communication across the network.
|
| 324 |
+
|
| 325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 326 |
+
in accord with this section must be in a format that is publicly
|
| 327 |
+
documented (and with an implementation available to the public in
|
| 328 |
+
source code form), and must require no special password or key for
|
| 329 |
+
unpacking, reading or copying.
|
| 330 |
+
|
| 331 |
+
7. Additional Terms.
|
| 332 |
+
|
| 333 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 334 |
+
License by making exceptions from one or more of its conditions.
|
| 335 |
+
Additional permissions that are applicable to the entire Program shall
|
| 336 |
+
be treated as though they were included in this License, to the extent
|
| 337 |
+
that they are valid under applicable law. If additional permissions
|
| 338 |
+
apply only to part of the Program, that part may be used separately
|
| 339 |
+
under those permissions, but the entire Program remains governed by
|
| 340 |
+
this License without regard to the additional permissions.
|
| 341 |
+
|
| 342 |
+
When you convey a copy of a covered work, you may at your option
|
| 343 |
+
remove any additional permissions from that copy, or from any part of
|
| 344 |
+
it. (Additional permissions may be written to require their own
|
| 345 |
+
removal in certain cases when you modify the work.) You may place
|
| 346 |
+
additional permissions on material, added by you to a covered work,
|
| 347 |
+
for which you have or can give appropriate copyright permission.
|
| 348 |
+
|
| 349 |
+
Notwithstanding any other provision of this License, for material you
|
| 350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 351 |
+
that material) supplement the terms of this License with terms:
|
| 352 |
+
|
| 353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 354 |
+
terms of sections 15 and 16 of this License; or
|
| 355 |
+
|
| 356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 357 |
+
author attributions in that material or in the Appropriate Legal
|
| 358 |
+
Notices displayed by works containing it; or
|
| 359 |
+
|
| 360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 361 |
+
requiring that modified versions of such material be marked in
|
| 362 |
+
reasonable ways as different from the original version; or
|
| 363 |
+
|
| 364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 365 |
+
authors of the material; or
|
| 366 |
+
|
| 367 |
+
e) Declining to grant rights under trademark law for use of some
|
| 368 |
+
trade names, trademarks, or service marks; or
|
| 369 |
+
|
| 370 |
+
f) Requiring indemnification of licensors and authors of that
|
| 371 |
+
material by anyone who conveys the material (or modified versions of
|
| 372 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 373 |
+
any liability that these contractual assumptions directly impose on
|
| 374 |
+
those licensors and authors.
|
| 375 |
+
|
| 376 |
+
All other non-permissive additional terms are considered "further
|
| 377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 378 |
+
received it, or any part of it, contains a notice stating that it is
|
| 379 |
+
governed by this License along with a term that is a further
|
| 380 |
+
restriction, you may remove that term. If a license document contains
|
| 381 |
+
a further restriction but permits relicensing or conveying under this
|
| 382 |
+
License, you may add to a covered work material governed by the terms
|
| 383 |
+
of that license document, provided that the further restriction does
|
| 384 |
+
not survive such relicensing or conveying.
|
| 385 |
+
|
| 386 |
+
If you add terms to a covered work in accord with this section, you
|
| 387 |
+
must place, in the relevant source files, a statement of the
|
| 388 |
+
additional terms that apply to those files, or a notice indicating
|
| 389 |
+
where to find the applicable terms.
|
| 390 |
+
|
| 391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 392 |
+
form of a separately written license, or stated as exceptions;
|
| 393 |
+
the above requirements apply either way.
|
| 394 |
+
|
| 395 |
+
8. Termination.
|
| 396 |
+
|
| 397 |
+
You may not propagate or modify a covered work except as expressly
|
| 398 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 399 |
+
modify it is void, and will automatically terminate your rights under
|
| 400 |
+
this License (including any patent licenses granted under the third
|
| 401 |
+
paragraph of section 11).
|
| 402 |
+
|
| 403 |
+
However, if you cease all violation of this License, then your
|
| 404 |
+
license from a particular copyright holder is reinstated (a)
|
| 405 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 407 |
+
holder fails to notify you of the violation by some reasonable means
|
| 408 |
+
prior to 60 days after the cessation.
|
| 409 |
+
|
| 410 |
+
Moreover, your license from a particular copyright holder is
|
| 411 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 412 |
+
violation by some reasonable means, this is the first time you have
|
| 413 |
+
received notice of violation of this License (for any work) from that
|
| 414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 415 |
+
your receipt of the notice.
|
| 416 |
+
|
| 417 |
+
Termination of your rights under this section does not terminate the
|
| 418 |
+
licenses of parties who have received copies or rights from you under
|
| 419 |
+
this License. If your rights have been terminated and not permanently
|
| 420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 421 |
+
material under section 10.
|
| 422 |
+
|
| 423 |
+
9. Acceptance Not Required for Having Copies.
|
| 424 |
+
|
| 425 |
+
You are not required to accept this License in order to receive or
|
| 426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 428 |
+
to receive a copy likewise does not require acceptance. However,
|
| 429 |
+
nothing other than this License grants you permission to propagate or
|
| 430 |
+
modify any covered work. These actions infringe copyright if you do
|
| 431 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 432 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 433 |
+
|
| 434 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 435 |
+
|
| 436 |
+
Each time you convey a covered work, the recipient automatically
|
| 437 |
+
receives a license from the original licensors, to run, modify and
|
| 438 |
+
propagate that work, subject to this License. You are not responsible
|
| 439 |
+
for enforcing compliance by third parties with this License.
|
| 440 |
+
|
| 441 |
+
An "entity transaction" is a transaction transferring control of an
|
| 442 |
+
organization, or substantially all assets of one, or subdividing an
|
| 443 |
+
organization, or merging organizations. If propagation of a covered
|
| 444 |
+
work results from an entity transaction, each party to that
|
| 445 |
+
transaction who receives a copy of the work also receives whatever
|
| 446 |
+
licenses to the work the party's predecessor in interest had or could
|
| 447 |
+
give under the previous paragraph, plus a right to possession of the
|
| 448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 449 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 450 |
+
|
| 451 |
+
You may not impose any further restrictions on the exercise of the
|
| 452 |
+
rights granted or affirmed under this License. For example, you may
|
| 453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 454 |
+
rights granted under this License, and you may not initiate litigation
|
| 455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 456 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 457 |
+
sale, or importing the Program or any portion of it.
|
| 458 |
+
|
| 459 |
+
11. Patents.
|
| 460 |
+
|
| 461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 462 |
+
License of the Program or a work on which the Program is based. The
|
| 463 |
+
work thus licensed is called the contributor's "contributor version".
|
| 464 |
+
|
| 465 |
+
A contributor's "essential patent claims" are all patent claims
|
| 466 |
+
owned or controlled by the contributor, whether already acquired or
|
| 467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 468 |
+
by this License, of making, using, or selling its contributor version,
|
| 469 |
+
but do not include claims that would be infringed only as a
|
| 470 |
+
consequence of further modification of the contributor version. For
|
| 471 |
+
purposes of this definition, "control" includes the right to grant
|
| 472 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 473 |
+
this License.
|
| 474 |
+
|
| 475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 476 |
+
patent license under the contributor's essential patent claims, to
|
| 477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 478 |
+
propagate the contents of its contributor version.
|
| 479 |
+
|
| 480 |
+
In the following three paragraphs, a "patent license" is any express
|
| 481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 482 |
+
(such as an express permission to practice a patent or covenant not to
|
| 483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 484 |
+
party means to make such an agreement or commitment not to enforce a
|
| 485 |
+
patent against the party.
|
| 486 |
+
|
| 487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 488 |
+
and the Corresponding Source of the work is not available for anyone
|
| 489 |
+
to copy, free of charge and under the terms of this License, through a
|
| 490 |
+
publicly available network server or other readily accessible means,
|
| 491 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 494 |
+
consistent with the requirements of this License, to extend the patent
|
| 495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 496 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 497 |
+
covered work in a country, or your recipient's use of the covered work
|
| 498 |
+
in a country, would infringe one or more identifiable patents in that
|
| 499 |
+
country that you have reason to believe are valid.
|
| 500 |
+
|
| 501 |
+
If, pursuant to or in connection with a single transaction or
|
| 502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 503 |
+
covered work, and grant a patent license to some of the parties
|
| 504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 505 |
+
or convey a specific copy of the covered work, then the patent license
|
| 506 |
+
you grant is automatically extended to all recipients of the covered
|
| 507 |
+
work and works based on it.
|
| 508 |
+
|
| 509 |
+
A patent license is "discriminatory" if it does not include within
|
| 510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 512 |
+
specifically granted under this License. You may not convey a covered
|
| 513 |
+
work if you are a party to an arrangement with a third party that is
|
| 514 |
+
in the business of distributing software, under which you make payment
|
| 515 |
+
to the third party based on the extent of your activity of conveying
|
| 516 |
+
the work, and under which the third party grants, to any of the
|
| 517 |
+
parties who would receive the covered work from you, a discriminatory
|
| 518 |
+
patent license (a) in connection with copies of the covered work
|
| 519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 520 |
+
for and in connection with specific products or compilations that
|
| 521 |
+
contain the covered work, unless you entered into that arrangement,
|
| 522 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 523 |
+
|
| 524 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 525 |
+
any implied license or other defenses to infringement that may
|
| 526 |
+
otherwise be available to you under applicable patent law.
|
| 527 |
+
|
| 528 |
+
12. No Surrender of Others' Freedom.
|
| 529 |
+
|
| 530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 531 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 534 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 536 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 537 |
+
the Program, the only way you could satisfy both those terms and this
|
| 538 |
+
License would be to refrain entirely from conveying the Program.
|
| 539 |
+
|
| 540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 541 |
+
|
| 542 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 543 |
+
Program, your modified version must prominently offer all users
|
| 544 |
+
interacting with it remotely through a computer network (if your version
|
| 545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 546 |
+
Source of your version by providing access to the Corresponding Source
|
| 547 |
+
from a network server at no charge, through some standard or customary
|
| 548 |
+
means of facilitating copying of software. This Corresponding Source
|
| 549 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 551 |
+
following paragraph.
|
| 552 |
+
|
| 553 |
+
Notwithstanding any other provision of this License, you have
|
| 554 |
+
permission to link or combine any covered work with a work licensed
|
| 555 |
+
under version 3 of the GNU General Public License into a single
|
| 556 |
+
combined work, and to convey the resulting work. The terms of this
|
| 557 |
+
License will continue to apply to the part which is the covered work,
|
| 558 |
+
but the work with which it is combined will remain governed by version
|
| 559 |
+
3 of the GNU General Public License.
|
| 560 |
+
|
| 561 |
+
14. Revised Versions of this License.
|
| 562 |
+
|
| 563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 566 |
+
address new problems or concerns.
|
| 567 |
+
|
| 568 |
+
Each version is given a distinguishing version number. If the
|
| 569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 570 |
+
Public License "or any later version" applies to it, you have the
|
| 571 |
+
option of following the terms and conditions either of that numbered
|
| 572 |
+
version or of any later version published by the Free Software
|
| 573 |
+
Foundation. If the Program does not specify a version number of the
|
| 574 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 575 |
+
by the Free Software Foundation.
|
| 576 |
+
|
| 577 |
+
If the Program specifies that a proxy can decide which future
|
| 578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 579 |
+
public statement of acceptance of a version permanently authorizes you
|
| 580 |
+
to choose that version for the Program.
|
| 581 |
+
|
| 582 |
+
Later license versions may give you additional or different
|
| 583 |
+
permissions. However, no additional obligations are imposed on any
|
| 584 |
+
author or copyright holder as a result of your choosing to follow a
|
| 585 |
+
later version.
|
| 586 |
+
|
| 587 |
+
15. Disclaimer of Warranty.
|
| 588 |
+
|
| 589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 597 |
+
|
| 598 |
+
16. Limitation of Liability.
|
| 599 |
+
|
| 600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 608 |
+
SUCH DAMAGES.
|
| 609 |
+
|
| 610 |
+
17. Interpretation of Sections 15 and 16.
|
| 611 |
+
|
| 612 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 613 |
+
above cannot be given local legal effect according to their terms,
|
| 614 |
+
reviewing courts shall apply local law that most closely approximates
|
| 615 |
+
an absolute waiver of all civil liability in connection with the
|
| 616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 617 |
+
copy of the Program in return for a fee.
|
| 618 |
+
|
| 619 |
+
END OF TERMS AND CONDITIONS
|
| 620 |
+
|
| 621 |
+
How to Apply These Terms to Your New Programs
|
| 622 |
+
|
| 623 |
+
If you develop a new program, and you want it to be of the greatest
|
| 624 |
+
possible use to the public, the best way to achieve this is to make it
|
| 625 |
+
free software which everyone can redistribute and change under these terms.
|
| 626 |
+
|
| 627 |
+
To do so, attach the following notices to the program. It is safest
|
| 628 |
+
to attach them to the start of each source file to most effectively
|
| 629 |
+
state the exclusion of warranty; and each file should have at least
|
| 630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 631 |
+
|
| 632 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 633 |
+
Copyright (C) <year> <name of author>
|
| 634 |
+
|
| 635 |
+
This program is free software: you can redistribute it and/or modify
|
| 636 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 637 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 638 |
+
(at your option) any later version.
|
| 639 |
+
|
| 640 |
+
This program is distributed in the hope that it will be useful,
|
| 641 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 642 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 643 |
+
GNU Affero General Public License for more details.
|
| 644 |
+
|
| 645 |
+
You should have received a copy of the GNU Affero General Public License
|
| 646 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 647 |
+
|
| 648 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 649 |
+
|
| 650 |
+
If your software can interact with users remotely through a computer
|
| 651 |
+
network, you should also make sure that it provides a way for users to
|
| 652 |
+
get its source. For example, if your program is a web application, its
|
| 653 |
+
interface could display a "Source" link that leads users to an archive
|
| 654 |
+
of the code. There are many ways you could offer source, and different
|
| 655 |
+
solutions will be better for different programs; see section 13 for the
|
| 656 |
+
specific requirements.
|
| 657 |
+
|
| 658 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 659 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 660 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 661 |
+
<https://www.gnu.org/licenses/>.
|
README.en.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="center">
|
| 2 |
+
<img alt="Music Together" src="public/logo.svg" width="80">
|
| 3 |
+
</p>
|
| 4 |
+
|
| 5 |
+
<h1 align="center">Music Together</h1>
|
| 6 |
+
|
| 7 |
+
<p align="center">
|
| 8 |
+
A real-time collaborative listening platform. Run one server, then join the same rooms, queue, and synchronized playback from the web, Android, or Windows desktop client.
|
| 9 |
+
</p>
|
| 10 |
+
|
| 11 |
+
<p align="center">
|
| 12 |
+
<a href="README.md">Simplified Chinese</a>
|
| 13 |
+
</p>
|
| 14 |
+
|
| 15 |
+
## Overview
|
| 16 |
+
|
| 17 |
+
Music Together consists of a Node.js server and several clients. The server coordinates rooms, identities, queues, voting, chat, and playback timing. Clients render the experience, play audio, and integrate with their host platform. All clients use the same HTTP and WebSocket protocol, so they can join the same room across devices.
|
| 18 |
+
|
| 19 |
+
| Platform | Implementation | Current location | Best for |
|
| 20 |
+
| --- | --- | --- | --- |
|
| 21 |
+
| Web | React + Vite | `main` branch | Browsers, mobile browsers, and quick deployment |
|
| 22 |
+
| Android | Kotlin + Jetpack Compose + Media3 | `codex/android-native-client`, v2.2.5 | Native background playback, system media controls, and multi-server lobby |
|
| 23 |
+
| Windows desktop | Electron + React + TypeScript | `codex/windows-native-client` | Installed desktop experience and standalone windows |
|
| 24 |
+
|
| 25 |
+
> The Android and desktop clients are currently maintained in their own branches and do not include the server. Deploy or run the `main` branch server first, then configure its address in the client.
|
| 26 |
+
|
| 27 |
+
## Screenshots
|
| 28 |
+
|
| 29 |
+
### Web Desktop
|
| 30 |
+
|
| 31 |
+
| 1 | 2 | 3 | 4 | 5 |
|
| 32 |
+
| --- | --- | --- | --- | --- |
|
| 33 |
+
|  |  |  |  |  |
|
| 34 |
+
|
| 35 |
+
### Web Mobile
|
| 36 |
+
|
| 37 |
+
| 1 | 2 | 3 | 4 | 5 |
|
| 38 |
+
| --- | --- | --- | --- | --- |
|
| 39 |
+
|  |  |  |  |  |
|
| 40 |
+
|
| 41 |
+
### Native Android Client
|
| 42 |
+
|
| 43 |
+
| Launch | 1 | 2 | 3 | 4 |
|
| 44 |
+
| --- | --- | --- | --- | --- |
|
| 45 |
+
|  |  |  |  |  |
|
| 46 |
+
|
| 47 |
+
### Windows Desktop Client
|
| 48 |
+
|
| 49 |
+
| 1 | 2 | 3 | 4 | 5 |
|
| 50 |
+
| --- | --- | --- | --- | --- |
|
| 51 |
+
|  |  |  |  |  |
|
| 52 |
+
|
| 53 |
+
## Capabilities
|
| 54 |
+
|
| 55 |
+
- Real-time room synchronization with clock correction, scheduled actions, progress reporting, and drift correction
|
| 56 |
+
- NetEase Cloud Music, QQ Music, Kugou, Kugou Concept, and Bilibili sources
|
| 57 |
+
- Platform sign-in and playlists through QR/Cookie login, with quality selection based on account capability
|
| 58 |
+
- Rooms, password rooms, invite links, queue management, chat, roles, votes, and hidden rooms
|
| 59 |
+
- Sequential, single-loop, list-loop, and shuffle playback with word-by-word, translated, romanized, and Ruby lyrics
|
| 60 |
+
- One shared room protocol across the web, Android native player, and Windows desktop client
|
| 61 |
+
|
| 62 |
+
## Architecture
|
| 63 |
+
|
| 64 |
+
```text
|
| 65 |
+
Web client / Android client / Windows desktop client
|
| 66 |
+
│ HTTP + WebSocket
|
| 67 |
+
▼
|
| 68 |
+
Music Together Node.js server
|
| 69 |
+
│
|
| 70 |
+
Rooms, clock sync, authentication, music and lyric proxy
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
The server persists room data and server-side account configuration. Music-platform cookies are sent only to the connected Music Together server. Bilibili and proxy-required audio are routed through the server so platform credentials are not exposed to other room members.
|
| 74 |
+
|
| 75 |
+
## Web and Server Development
|
| 76 |
+
|
| 77 |
+
### Prerequisites
|
| 78 |
+
|
| 79 |
+
- Node.js 22+
|
| 80 |
+
- pnpm 10+
|
| 81 |
+
|
| 82 |
+
### Start locally
|
| 83 |
+
|
| 84 |
+
```powershell
|
| 85 |
+
git clone https://github.com/LiuYunLingNai/music-together.git
|
| 86 |
+
cd music-together
|
| 87 |
+
pnpm install --frozen-lockfile
|
| 88 |
+
pnpm dev
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
- Web client: `http://localhost:5173`
|
| 92 |
+
- Server: `http://localhost:3001`
|
| 93 |
+
|
| 94 |
+
In default auto mode, the web client connects to the server at its current origin. Set `CLIENT_URL` when deploying frontend and backend separately or when an explicit origin allowlist is required.
|
| 95 |
+
|
| 96 |
+
## Android Client
|
| 97 |
+
|
| 98 |
+
The Android app is native, not a WebView wrapper. It uses Kotlin, Jetpack Compose, OkHttp WebSocket, and Media3 ExoPlayer. It supports a multi-server lobby, background playback, system media controls, chat, queue management, search, platform accounts/playlists, votes, and word-by-word lyrics.
|
| 99 |
+
|
| 100 |
+
### Build a debug APK
|
| 101 |
+
|
| 102 |
+
Requires JDK 17-21 (the Android Studio bundled JBR is recommended) and Android SDK 36:
|
| 103 |
+
|
| 104 |
+
```powershell
|
| 105 |
+
git clone --branch codex/android-native-client --single-branch https://github.com/LiuYunLingNai/music-together.git
|
| 106 |
+
cd music-together/packages/android-client
|
| 107 |
+
.\gradlew.bat testStandardDebugUnitTest assembleStandardDebug
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
The APK is written to `app/build/outputs/apk/standard/debug/app-standard-debug.apk`.
|
| 111 |
+
|
| 112 |
+
- Android emulator to a server on the development machine: `http://10.0.2.2:3001`
|
| 113 |
+
- Physical device on a LAN: use the computer's LAN address, for example `http://192.168.1.8:3001`
|
| 114 |
+
- Use HTTPS for public servers. The app can store multiple server addresses and aggregate their rooms in one lobby.
|
| 115 |
+
|
| 116 |
+
The Android branch ships `standard` and `vivo` variants. Its GitHub Actions workflow builds Debug/Release APKs and a standard AAB.
|
| 117 |
+
|
| 118 |
+
## Windows Desktop Client
|
| 119 |
+
|
| 120 |
+
The desktop client is an Electron, React, and TypeScript application with its own interface rather than loading the web app URL. The current branch builds Windows installers and portable executables, while retaining AppImage and deb Linux targets.
|
| 121 |
+
|
| 122 |
+
It includes server connection settings, room discovery and reconnecting, NTP clock sync, rooms/password rooms, search, queue, chat, transport controls, and Apple Music-style lyrics.
|
| 123 |
+
|
| 124 |
+
### Develop and package
|
| 125 |
+
|
| 126 |
+
Requires Node.js 22+:
|
| 127 |
+
|
| 128 |
+
```powershell
|
| 129 |
+
git clone --branch codex/windows-native-client --single-branch https://github.com/LiuYunLingNai/music-together.git
|
| 130 |
+
cd music-together
|
| 131 |
+
npm ci
|
| 132 |
+
npm run dev
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Useful verification and packaging commands:
|
| 136 |
+
|
| 137 |
+
```powershell
|
| 138 |
+
npm run typecheck
|
| 139 |
+
npm test
|
| 140 |
+
npm run build
|
| 141 |
+
npm run dist:win
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Artifacts are written to `release/`. Windows targets are NSIS and Portable. Run `npm run dist:linux` for AppImage and deb packages.
|
| 145 |
+
|
| 146 |
+
## Deploy the Server with Docker
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
docker run -d --name music-together --restart unless-stopped \
|
| 150 |
+
-p 3001:3001 \
|
| 151 |
+
-v /path/to/music-together-data:/app/data \
|
| 152 |
+
ghcr.io/LiuYunLingNai/music-together:latest
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
Replace `/path/to/music-together-data` with a persistent host directory. It holds the server database and account-related data; recreating a container without the mount loses that data.
|
| 156 |
+
|
| 157 |
+
When serving HTTPS through Nginx, Caddy, 1Panel, or another reverse proxy, forward `X-Forwarded-Proto` so the server can set secure cookies correctly. Configure public clients with the proxied HTTPS address.
|
| 158 |
+
|
| 159 |
+
## Repository Layout
|
| 160 |
+
|
| 161 |
+
The `main` branch:
|
| 162 |
+
|
| 163 |
+
```text
|
| 164 |
+
packages/
|
| 165 |
+
client/ Web React app
|
| 166 |
+
server/ Node.js server
|
| 167 |
+
shared/ Shared types, constants, and permissions
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
Native-client branches:
|
| 171 |
+
|
| 172 |
+
```text
|
| 173 |
+
codex/android-native-client
|
| 174 |
+
packages/android-client/ Kotlin/Compose Android app
|
| 175 |
+
|
| 176 |
+
codex/windows-native-client
|
| 177 |
+
electron/ + src/ Electron desktop app
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
## Documentation
|
| 181 |
+
|
| 182 |
+
- [Web and server architecture](docs/PROJECT_ARCHITECTURE.md)
|
| 183 |
+
- [Android client README](https://github.com/LiuYunLingNai/music-together/tree/codex/android-native-client/packages/android-client)
|
| 184 |
+
- [Windows desktop client README](https://github.com/LiuYunLingNai/music-together/tree/codex/windows-native-client)
|
| 185 |
+
|
| 186 |
+
## License
|
| 187 |
+
|
| 188 |
+
[AGPL-3.0](LICENSE)
|
docs/PROJECT_ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Music Together — 项目速查手册
|
| 2 |
+
|
| 3 |
+
> 供 AI 助手快速理解项目全貌的参考文档。
|
| 4 |
+
|
| 5 |
+
## 1. 项目概览
|
| 6 |
+
|
| 7 |
+
**Music Together** 是一个在线同步听歌平台,允许多人在同一房间内实时同步播放音乐、聊天互动。
|
| 8 |
+
|
| 9 |
+
### 核心功能
|
| 10 |
+
|
| 11 |
+
| 功能 | 说明 |
|
| 12 |
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
| 13 |
+
| 房间系统 | 创建/加入房间,房间号邀请,可选密码保护;永久房间在用户加入时按 30 分钟间隔按需刷新当前播放链接 |
|
| 14 |
+
| 多音源搜索 | 网易云、QQ音乐、酷狗 |
|
| 15 |
+
| 同步播放 | 房间内播放进度实时同步 |
|
| 16 |
+
| 实时聊天 | 房间内文字聊天 |
|
| 17 |
+
| 权限控制 | RBAC 三级权限(owner > admin > member)基于 @casl/ability,hostId 为自动选举的播放主持(conductor) |
|
| 18 |
+
| 播放模式 | 顺序播放、列表循环、单曲循环、随机播放(Owner/Admin 直接切换,Member 投票切换) |
|
| 19 |
+
| 投票系统 | 普通成员通过投票控制播放(暂停/恢复/切歌/切换播放模式/指定播放/移除歌曲) |
|
| 20 |
+
| VIP 认证 | 平台账号登录(网易云/QQ/酷狗),房间级 Cookie 池(VIP 播放共享)+ 用户级歌单(私有) |
|
| 21 |
+
| 歌词展示 | Apple Music 风格歌词动画 (AMLL),四级优先级:TTML 在线逐词(网易云/QQ,可配置)> 平台原生逐词(网易云 YRC / 酷狗 KRC)> LRC 行级歌词 |
|
| 22 |
+
|
| 23 |
+
### 技术栈
|
| 24 |
+
|
| 25 |
+
- **前端**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS v4 + shadcn/ui + Zustand
|
| 26 |
+
- **后端**: Node.js + Express 4 + Socket.IO 4 + @meting/core
|
| 27 |
+
- **Monorepo**: pnpm workspaces(3 个包:`client`、`server`、`shared`)
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## 子文档索引
|
| 32 |
+
|
| 33 |
+
本文档已拆分为多个子文件,位于 `docs/architecture/` 目录:
|
| 34 |
+
|
| 35 |
+
| 章节 | 文件 | 内容 |
|
| 36 |
+
| --------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
| 37 |
+
| 2. 目录结构 | [directory-structure.md](architecture/directory-structure.md) | client / server / shared 三包目录树 |
|
| 38 |
+
| 3. 架构与数据流 | [data-flow.md](architecture/data-flow.md) | 分层架构、Socket 事件清单、核心类型定义、NTP 同步、播放同步、播放模式、音频质量等 |
|
| 39 |
+
| 4. 第三方库依赖 | [dependencies.md](architecture/dependencies.md) | 前端/后端/共享/开发依赖清单 |
|
| 40 |
+
| 5. 设计模式 | [design-patterns.md](architecture/design-patterns.md) | Zustand Store、Hook 组合、Dialog、Provider、分层架构、Repository 等 |
|
| 41 |
+
| 6. 代码规范 | [coding-standards.md](architecture/coding-standards.md) | 语言模块、路径别名、状态更新、错误处理 |
|
| 42 |
+
| 7. UI 设计规范 | [ui-design.md](architecture/ui-design.md) | 组件库、颜色系统、动画、歌词、响应式 |
|
| 43 |
+
| 8. 开发指南 | [dev-guide.md](architecture/dev-guide.md) | 快速启动、构建、添加组件、注意事项 |
|
| 44 |
+
| 9. 部署方案 | [deployment.md](architecture/deployment.md) | Docker、CORS、Watchtower、1Panel |
|
docs/UPDATE_NOTES.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 本轮功能更新说明
|
| 2 |
+
|
| 3 |
+
本文记录从 `Music-Together-Pro-UNM-Support` 分支移植并在当前项目中完成适配的功能,涵盖用户数据持久化、多音源、账号系统、服务器管理员、权限安全和审计日志。
|
| 4 |
+
|
| 5 |
+
## 1. 用户数据持久化
|
| 6 |
+
|
| 7 |
+
- 新增 SQLite 数据库,默认地址为 `file:./data/music-together.db`,可通过 `DATABASE_URL` 修改。
|
| 8 |
+
- 用户账号资料、密码 Hash、角色和平台登录 Cookie 均可在服务器重启后恢复。
|
| 9 |
+
- 用户再次访问时可通过身份 Cookie 恢复账号及对应的房间身份。
|
| 10 |
+
- 平台 Cookie 加密后再持久化,避免以明文形式保存第三方平台凭据。
|
| 11 |
+
- 用户 ID 使用不区分大小写的唯一性约束,不能注册或修改为已存在的 ID。
|
| 12 |
+
|
| 13 |
+
> 当前房间和播放状态仍保存在内存中。服务器重启后,账号数据会保留,但正在运行的房间会消失。
|
| 14 |
+
|
| 15 |
+
## 2. 更广的音源与音质支持
|
| 16 |
+
|
| 17 |
+
- 扩展网易云音乐相关能力,并接入 QQ 音乐、酷狗音乐等音源。
|
| 18 |
+
- 增加多平台搜索、歌曲信息解析、播放地址获取和歌单解析能力。
|
| 19 |
+
- 支持按平台选择更多音质,包括标准、高品质、无损及平台提供的更高规格音质。
|
| 20 |
+
- 客户端会根据音源展示可用音质,服务端负责对应音源和音质的解析与回退。
|
| 21 |
+
|
| 22 |
+
第三方平台接口和高音质播放能力仍受账号权限、Cookie 有效期、地区限制及平台接口可用性影响。
|
| 23 |
+
|
| 24 |
+
## 3. 账号功能
|
| 25 |
+
|
| 26 |
+
- 账号现在包含:账号 ID、昵称、密码、头像和角色。
|
| 27 |
+
- 用户可修改自己的昵称、头像、密码和账号 ID。
|
| 28 |
+
- 修改密码时需要验证当前密码,验证失败返回 `401`。
|
| 29 |
+
- 修改账号 ID 时会检查格式、重复 ID、系统保留 ID和管理员配置 ID;冲突返回 `409`,非法或不可占用的 ID 返回 `400`。
|
| 30 |
+
- 普通用户只能修改自己的账号资料,不能指定其他用户作为修改目标,也不能自行提升为服务器管理员。
|
| 31 |
+
- 未登录或身份失效时,会按接口要求返回 `401`、`403` 或 `409`,不会绕过账号归属检查。
|
| 32 |
+
|
| 33 |
+
## 4. 延迟创建正式账号
|
| 34 |
+
|
| 35 |
+
账号生成逻辑已调整为“设置昵称后才创建正式账号”:
|
| 36 |
+
|
| 37 |
+
1. 用户首次访问时只签发临时身份 Cookie。
|
| 38 |
+
2. 此时不会向 `users` 表写入账号,也不会在客户端保存或展示正式用户 ID。
|
| 39 |
+
3. 用户保存昵称,或使用需要正式身份的创建/加入房间流程后,服务器才生成唯一账号 ID并创建账号。
|
| 40 |
+
4. 正式账号创建后,账号 ID、头像和密码设置等控件才会开放。
|
| 41 |
+
|
| 42 |
+
未设置昵称时:
|
| 43 |
+
|
| 44 |
+
- `GET /api/auth/me` 返回 `204 No Content`。
|
| 45 |
+
- 密码和头像设置接口返回 `409 Conflict`。
|
| 46 |
+
- 设置页显示“保存昵称后生成账号 ID”,不会暴露临时身份值。
|
| 47 |
+
|
| 48 |
+
## 5. 修改账号 ID时的数据迁移
|
| 49 |
+
|
| 50 |
+
账号 ID 修改不是简单地改一个字段。服务端会同步迁移与旧 ID 关联的数据和运行时身份,包括:
|
| 51 |
+
|
| 52 |
+
- 用户资料、密码 Hash 和账号角色。
|
| 53 |
+
- 已保存的平台 Cookie。
|
| 54 |
+
- 房主、主持人和房间管理员身份。
|
| 55 |
+
- WebSocket 用户映射与重连票据。
|
| 56 |
+
|
| 57 |
+
迁移完成后,原账号 ID 不再代表该用户;新的身份 Cookie 和运行时映射会继续指向同一账号。
|
| 58 |
+
|
| 59 |
+
## 6. 服务器管理员
|
| 60 |
+
|
| 61 |
+
- 新增服务器管理员角色和管理界面。
|
| 62 |
+
- 可通过 `SERVER_ADMIN_IDS` 配置初始服务器管理员账号 ID,多个 ID使用英文逗号分隔。
|
| 63 |
+
- 服务器管理员可查看用户和房间列表、重置用户密码、删除用户以及解散房间。
|
| 64 |
+
- 配置中的管理员 ID和系统保留 ID不能被普通用户抢占。
|
| 65 |
+
|
| 66 |
+
### 权限分层
|
| 67 |
+
|
| 68 |
+
服务器管理员和房间管理员是两套独立身份:
|
| 69 |
+
|
| 70 |
+
- **服务器管理员**负责服务器级用户与房间治理。
|
| 71 |
+
- **房主、主持人、房间管理员**负责当前房间内的管理操作。
|
| 72 |
+
- 服务器管理员身份不会覆盖或清除房间角色,房间角色也不会自动获得服务器管理权限。
|
| 73 |
+
- 所有敏感操作都在服务端重新校验身份和目标对象,不能仅靠修改客户端请求获得权限。
|
| 74 |
+
|
| 75 |
+
## 7. 管理员审计日志
|
| 76 |
+
|
| 77 |
+
管理员操作已按项目原有日志格式增加结构化审计记录,包含操作者、目标对象、操作结果及必要上下文。
|
| 78 |
+
|
| 79 |
+
新增的主要审计事件包括:
|
| 80 |
+
|
| 81 |
+
- `admin.access_denied`
|
| 82 |
+
- `admin.users_viewed`
|
| 83 |
+
- `admin.rooms_viewed`
|
| 84 |
+
- `admin.user_deleted`
|
| 85 |
+
- `admin.user_delete_failed`
|
| 86 |
+
- `admin.user_delete_rejected`
|
| 87 |
+
- `admin.user_password_reset`
|
| 88 |
+
- `admin.user_password_reset_failed`
|
| 89 |
+
- `admin.user_password_reset_rejected`
|
| 90 |
+
- `admin.room_dissolved`
|
| 91 |
+
- `admin.room_dissolve_failed`
|
| 92 |
+
|
| 93 |
+
房间管理日志同时增加 `operatorIsServerAdmin` 字段,用于区分操作来自房间权限还是服务器管理员权限。
|
| 94 |
+
|
| 95 |
+
## 8. 安全加固
|
| 96 |
+
|
| 97 |
+
- 账号资料修改以服务端认证身份为准,不接受客户端伪造的目标用户 ID。
|
| 98 |
+
- 普通用户不能读取或修改其他用户的敏感账号信息。
|
| 99 |
+
- 普通用户不能调用服务器管理员接口,越权访问返回 `403 Forbidden`。
|
| 100 |
+
- 账号 ID修改采用唯一性校验和事务迁移,防止重复 ID及只迁移部分数据。
|
| 101 |
+
- 密码只保存 Hash,不保存明文。
|
| 102 |
+
- 平台 Cookie 加密持久化,并绑定到账号身份。
|
| 103 |
+
- 删除用户、重置密码、解散房间等高风险管理员操作均记录成功、拒绝或失败结果。
|
| 104 |
+
|
| 105 |
+
## 9. 配置项
|
| 106 |
+
|
| 107 |
+
本轮涉及的主要环境变量如下:
|
| 108 |
+
|
| 109 |
+
```dotenv
|
| 110 |
+
# SQLite 数据库地址
|
| 111 |
+
DATABASE_URL=file:./data/music-together.db
|
| 112 |
+
|
| 113 |
+
# 服务器管理员账号 ID,多个值使用英文逗号分隔
|
| 114 |
+
SERVER_ADMIN_IDS=account-id-1,account-id-2
|
| 115 |
+
|
| 116 |
+
# 是否只通过 HTTPS发送身份 Cookie
|
| 117 |
+
IDENTITY_COOKIE_SECURE=false
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
部署时应使用持久化磁盘保存数据库文件,并在 HTTPS生产环境中启用安全 Cookie 配置。
|
| 121 |
+
|
| 122 |
+
## 10. 验证结果
|
| 123 |
+
|
| 124 |
+
本轮修改已完成以下验证:
|
| 125 |
+
|
| 126 |
+
- 项目完整构建通过。
|
| 127 |
+
- Prettier 格式检查通过。
|
| 128 |
+
- Git 差异空白检查通过。
|
| 129 |
+
- 普通用户跨账号修改注入测试通过。
|
| 130 |
+
- 普通用户访问管理员接口返回 `403`。
|
| 131 |
+
- 重复账号 ID返回 `409`。
|
| 132 |
+
- 管理员配置 ID抢占返回 `400`。
|
| 133 |
+
- 错误当前密码返回 `401`。
|
| 134 |
+
- 修改账号 ID后,房主身份和平台 Cookie 能正确迁移。
|
| 135 |
+
- 管理员操作会实际输出结构化审计日志。
|
| 136 |
+
- 首次访问不会创建数据库用户;保存昵称后才生成正式账号。
|
| 137 |
+
- 保存昵称前后账号设置界面状态正确,移动端页面无水平溢出,浏览器控制台无错误。
|
docs/architecture/coding-standards.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 代码规范
|
| 2 |
+
|
| 3 |
+
## 语言与模块
|
| 4 |
+
|
| 5 |
+
- **TypeScript strict 模式**:所有包均启用 `"strict": true`
|
| 6 |
+
- **ESM**:所有包 `"type": "module"`,使用 `import/export` 语法
|
| 7 |
+
- **目标**:ES2022(server/shared),ES2022 + DOM(client)
|
| 8 |
+
- **模块解析**:`"moduleResolution": "bundler"`
|
| 9 |
+
|
| 10 |
+
## 命名规范
|
| 11 |
+
|
| 12 |
+
| 类型 | 规范 | 示例 |
|
| 13 |
+
| ---------------- | ------------------------ | -------------------------------------- |
|
| 14 |
+
| 组件文件/组件名 | PascalCase | `ChatPanel.tsx`, `AudioPlayer` |
|
| 15 |
+
| Hook 文件/函数名 | camelCase + `use` 前缀 | `usePlayer.ts`, `useHowl` |
|
| 16 |
+
| Store 文件 | camelCase + `Store` 后缀 | `playerStore.ts` |
|
| 17 |
+
| 工具函数/文件 | camelCase | `format.ts`, `formatDuration()` |
|
| 18 |
+
| 类型/接口 | PascalCase | `Track`, `RoomState`, `HandlerContext` |
|
| 19 |
+
| 常量 | UPPER_SNAKE_CASE | `EVENTS`, `LIMITS`, `TIMING` |
|
| 20 |
+
| 事件名 | 命名空间:动作 | `room:create`, `player:sync_response` |
|
| 21 |
+
|
| 22 |
+
## 路径别名
|
| 23 |
+
|
| 24 |
+
客户端使用 `@/*` 映射到 `src/*`:
|
| 25 |
+
|
| 26 |
+
```typescript
|
| 27 |
+
import { usePlayerStore } from '@/stores/playerStore'
|
| 28 |
+
import { cn } from '@/lib/utils'
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## 状态更新
|
| 32 |
+
|
| 33 |
+
Zustand Store 通过 `set()` 进行不可变更新:
|
| 34 |
+
|
| 35 |
+
```typescript
|
| 36 |
+
// 展开运算符更新嵌套状态
|
| 37 |
+
updateRoom: (partial) =>
|
| 38 |
+
set((state) => ({
|
| 39 |
+
room: state.room ? { ...state.room, ...partial } : null,
|
| 40 |
+
}))
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## 错误处理
|
| 44 |
+
|
| 45 |
+
- **REST 路由**:统一使用 `validated(schema, label, handler)` 包装器自动完成 Zod 验证 + try/catch + 日志记录,返回适当的 HTTP 状态码
|
| 46 |
+
- **Socket.IO**:中间件统一捕获异步错误,通过 `ROOM_ERROR` 事件回传 `ERROR_CODE` 枚举和消息
|
| 47 |
+
- **客户端 Hook**:hook 中处理 Socket 错误事件,使用 `sonner` toast 提示用户(含限流反馈)
|
| 48 |
+
- **客户端 ErrorBoundary**:`react-error-boundary` 全局 + 路由级双层包裹(`RouteErrorBoundary` 包裹 `HomePage` 和 `RoomPage`),页面崩溃只影响当前路由并导航回首页
|
| 49 |
+
- **客户端连接状态**:`SocketProvider` 监听 disconnect/reconnect,显示持久化 warning toast
|
| 50 |
+
- **搜索竞态防护**:`SearchDialog` 使用 `AbortController` 取消上一次请求 + `searchIdRef` 忽略过时响应 + `loadMoreAbortRef` 在卸载时中止加载更多请求。搜索结果和歌单详情均通过 `VirtualTrackList` 共享组件实现虚拟滚动 + 无限自动加载
|
| 51 |
+
- **Socket 事件速率限制**:`socketRateLimiter` 中间件(`rate-limiter-flexible`)对 `QUEUE_ADD`、`PLAYER_PLAY`、`VOTE_START` 等关键事件做 per-socket 限流(10 次/5 秒)
|
| 52 |
+
- **投票阈值动态更新**:`voteService.updateVoteThreshold` 在用户离开房间时重新计算 `requiredVotes`,防止人数减少后投票永远无法通过
|
| 53 |
+
- **外部 API 超时保护**:`musicProvider` 所有 `@meting/core` 调用使用 `Promise.race` 包裹 15s 超时
|
| 54 |
+
- **3 层引用式 LRU 缓存**:`musicProvider` 采用三层缓存架构——Layer 1: `trackRegistry`(max 10000, TTL 2h)以 `source:sourceId` 为 key 存储去重的 TrackMeta,所有经过系统的歌曲注册于此,支持跨上下文数据富化(搜索的 duration/cover 自动回填到歌单);Layer 2: `searchIndex`(max 200, TTL 10min)和 `playlistIndex`(max 50, TTL 30min)仅存 sourceId 数组引用(不存 Track 对象),2000 首歌单仅占 ~40KB 引用而非 ~1MB 对象;Layer 3: `streamUrlCache`(1h)、`coverCache`(24h)、`lyricCache`(24h)存标量值。内存预算 worst case ~8.3MB(vs 旧架构 ~104MB)。歌单分页通过 `getPlaylistPage(source, id, limit, offset)` 实现,仅对当前页解析封面后回写 registry;VIP cookie 请求不走缓存。**所有平台歌单均使用原始 API 模式**(Netease 用 ncmApi 分块请求,Tencent/Kugou 用 Meting 无 format 模式),统一通过 `rawToTrack()` 解析(exhaustive switch + `never` 检查),保留 VIP/付费标记和歌曲时长
|
| 55 |
+
- **广播防抖**:`broadcastRoomList` 使用 100ms trailing debounce,多次快速操作(create+join、多人 leave)合并为一次广播
|
| 56 |
+
- **反向索引优化**:`roomRepository` 维护 `roomToSockets` 反向索引(`Map<string, Set<string>>`),使 `getP90RTT` 从 O(全局 socket 数) 降为 O(房间内 socket 数)
|
| 57 |
+
- **Timer 泄漏防护**:`usePlayer`/`useHowl`/`usePlayerSync`/`PlayerControls` 中所有 `setTimeout` 均存入 ref 并在组件卸载时清理
|
| 58 |
+
- **Stalled 检测**:`useHowl` 的 rAF 时间更新循环中检测播放卡住(`playing()` 为 true 但 `seek()` 8 秒内无变化),自动跳到下一首并 toast 提示。兜底播放中途网络断开等 Howler 不触发任何事件的场景
|
| 59 |
+
|
| 60 |
+
## ESLint 配置
|
| 61 |
+
|
| 62 |
+
- **Flat config** 格式(`eslint.config.js`)
|
| 63 |
+
- 仅客户端包配置了 ESLint
|
| 64 |
+
- 插件:`@eslint/js` recommended + `typescript-eslint` recommended + `react-hooks` + `react-refresh`
|
| 65 |
+
- 无 Prettier(依赖编辑器格式化)
|
| 66 |
+
|
| 67 |
+
## 导入顺序(约定)
|
| 68 |
+
|
| 69 |
+
```typescript
|
| 70 |
+
// 1. 第三方库
|
| 71 |
+
import { create } from 'zustand'
|
| 72 |
+
import type { Track } from '@music-together/shared'
|
| 73 |
+
|
| 74 |
+
// 2. 内部模块(@/ 别名)
|
| 75 |
+
import { storage } from '@/lib/storage'
|
| 76 |
+
import { usePlayerStore } from '@/stores/playerStore'
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
---
|
docs/architecture/data-flow.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 架构与数据流
|
| 2 |
+
|
| 3 |
+
## 整体架构
|
| 4 |
+
|
| 5 |
+
```mermaid
|
| 6 |
+
graph TB
|
| 7 |
+
subgraph client [Client - React SPA]
|
| 8 |
+
Pages[Pages]
|
| 9 |
+
Hooks[Custom Hooks]
|
| 10 |
+
Stores[Zustand Stores]
|
| 11 |
+
SocketClient[Socket.IO Client]
|
| 12 |
+
end
|
| 13 |
+
|
| 14 |
+
subgraph server [Server - Node.js]
|
| 15 |
+
Express[Express REST API]
|
| 16 |
+
SocketServer[Socket.IO Server]
|
| 17 |
+
Controllers[Controllers]
|
| 18 |
+
Services[Services]
|
| 19 |
+
Repos[In-Memory Repositories]
|
| 20 |
+
end
|
| 21 |
+
|
| 22 |
+
subgraph external [External]
|
| 23 |
+
Meting["@meting/core 音乐源"]
|
| 24 |
+
end
|
| 25 |
+
|
| 26 |
+
Pages --> Hooks
|
| 27 |
+
Hooks --> Stores
|
| 28 |
+
Hooks --> SocketClient
|
| 29 |
+
|
| 30 |
+
SocketClient <-->|"WebSocket 双向通信"| SocketServer
|
| 31 |
+
Pages -->|"HTTP GET /api/music/*"| Express
|
| 32 |
+
|
| 33 |
+
SocketServer --> Controllers
|
| 34 |
+
Controllers --> Services
|
| 35 |
+
Services --> Repos
|
| 36 |
+
Express --> Meting
|
| 37 |
+
Services --> Meting
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Socket 事件清单
|
| 41 |
+
|
| 42 |
+
| 分类 | 客户端 → 服务端 | 服务端 → 客户端 |
|
| 43 |
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
| 44 |
+
| **Room** | `room:create`, `room:join`, `room:leave`, `room:list`, `room:settings`, `room:set_role` | `room:created`, `room:state`, `room:user_joined`, `room:user_left`, `room:settings`, `room:error`, `room:list_update`, `room:role_changed` |
|
| 45 |
+
| **Player** | `player:play`, `player:pause`, `player:seek`, `player:next`, `player:prev`, `player:sync`, `player:sync_request`, `player:set_mode` | `player:play`, `player:pause`, `player:resume`, `player:seek`, `player:sync_response` |
|
| 46 |
+
| **Queue** | `queue:add`, `queue:add_batch`, `queue:remove`, `queue:reorder`, `queue:clear` | `queue:updated` |
|
| 47 |
+
| **Chat** | `chat:message` | `chat:message`, `chat:history` |
|
| 48 |
+
| **Vote** | `vote:start`, `vote:cast` | `vote:started`, `vote:result` |
|
| 49 |
+
| **Auth** | `auth:request_qr`, `auth:check_qr`, `auth:set_cookie`, `auth:logout`, `auth:get_status` | `auth:qr_generated`, `auth:qr_status`, `auth:set_cookie_result`, `auth:status_update`, `auth:my_status` |
|
| 50 |
+
| **Playlist** | `playlist:get_my` | `playlist:my_list` |
|
| 51 |
+
| **NTP** | `ntp:ping` | `ntp:pong` |
|
| 52 |
+
| **Server** | — | `server:audio_proxy_policy` |
|
| 53 |
+
|
| 54 |
+
## 关键数据模型
|
| 55 |
+
|
| 56 |
+
```typescript
|
| 57 |
+
// 音乐曲目
|
| 58 |
+
interface Track {
|
| 59 |
+
id: string
|
| 60 |
+
title: string
|
| 61 |
+
artist: string[]
|
| 62 |
+
album: string
|
| 63 |
+
duration: number
|
| 64 |
+
cover: string
|
| 65 |
+
source: 'netease' | 'tencent' | 'kugou'
|
| 66 |
+
sourceId: string
|
| 67 |
+
urlId: string
|
| 68 |
+
lyricId?: string
|
| 69 |
+
picId?: string
|
| 70 |
+
streamUrl?: string
|
| 71 |
+
requiresServerProxy?: boolean // 上游字节需要服务器解密等处理
|
| 72 |
+
requestedBy?: string // 点歌人昵称
|
| 73 |
+
vip?: boolean // 是否为 VIP / 付费歌曲(可能无法播放或仅试听)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// 播放模式
|
| 77 |
+
type PlayMode = 'sequential' | 'loop-all' | 'loop-one' | 'shuffle'
|
| 78 |
+
|
| 79 |
+
// 音频质量档位 (kbps)
|
| 80 |
+
type AudioQuality = 128 | 192 | 320 | 999 | 'highest' | ProviderSpecificQuality
|
| 81 |
+
|
| 82 |
+
interface AudioProxyPolicy {
|
| 83 |
+
kugouForceProxy: boolean
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// 客户端可见的房间状态
|
| 87 |
+
interface RoomState {
|
| 88 |
+
id: string
|
| 89 |
+
name: string
|
| 90 |
+
creatorId: string
|
| 91 |
+
hostId: string
|
| 92 |
+
hasPassword: boolean
|
| 93 |
+
password?: string | null // 仅 owner 专用状态携带;公开状���不含该字段
|
| 94 |
+
audioQuality: AudioQuality
|
| 95 |
+
users: User[]
|
| 96 |
+
queue: Track[]
|
| 97 |
+
currentTrack: Track | null
|
| 98 |
+
playState: PlayState
|
| 99 |
+
playMode: PlayMode
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// 播放状态(含服务端时间戳用于同步校准)
|
| 103 |
+
interface PlayState {
|
| 104 |
+
isPlaying: boolean
|
| 105 |
+
currentTime: number
|
| 106 |
+
serverTimestamp: number
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// 预定执行播放状态(play/pause/seek/resume 广播时使用)
|
| 110 |
+
interface ScheduledPlayState extends PlayState {
|
| 111 |
+
serverTimeToExecute: number // 客户端应在此服务器时间点执行动作
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// 歌单元数据
|
| 115 |
+
interface Playlist {
|
| 116 |
+
id: string
|
| 117 |
+
name: string
|
| 118 |
+
cover: string
|
| 119 |
+
trackCount: number
|
| 120 |
+
source: MusicSource
|
| 121 |
+
creator?: string
|
| 122 |
+
description?: string
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// 用户(RBAC: owner > admin > member)
|
| 126 |
+
// hostId 是自动选举的播放主持(conductor),不是可见角色
|
| 127 |
+
interface User {
|
| 128 |
+
id: string
|
| 129 |
+
nickname: string
|
| 130 |
+
role: UserRole
|
| 131 |
+
}
|
| 132 |
+
type UserRole = 'owner' | 'admin' | 'member'
|
| 133 |
+
|
| 134 |
+
// 聊天消息
|
| 135 |
+
interface ChatMessage {
|
| 136 |
+
id: string
|
| 137 |
+
userId: string
|
| 138 |
+
nickname: string
|
| 139 |
+
content: string
|
| 140 |
+
timestamp: number
|
| 141 |
+
type: 'user' | 'system'
|
| 142 |
+
}
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
## 播放同步机制
|
| 146 |
+
|
| 147 |
+
采用**事件驱动同步 + 周期性比例漂移校正**架构:NTP 时钟同步 + Scheduled Execution + 比例控制漂移校正(EMA 平滑 + proportional rate + hard seek)。
|
| 148 |
+
|
| 149 |
+
**三层防护**:
|
| 150 |
+
|
| 151 |
+
1. **NTP 时钟同步**:保证各客户端时钟与服务器对齐(时间衰减加权中位数)
|
| 152 |
+
2. **Scheduled Execution**:离散事件(play/pause/seek/resume)通过预定执行消除网络延迟差异(P90 RTT 自适应调度)
|
| 153 |
+
3. **周期性比例漂移校正**:客户端每 2 秒发起 `PLAYER_SYNC_REQUEST`,服务端返回当前预期位置;漂移经 EMA 低通滤波后进入比例控制器,rate 调整幅度与漂移成正比(自然收敛无振荡),>200ms 用 hard seek 跳转
|
| 154 |
+
|
| 155 |
+
## Layer 1:NTP 时钟同步 + RTT 回报
|
| 156 |
+
|
| 157 |
+
客户端与服务器通过 `ntp:ping` / `ntp:pong` 事件交换时间戳,计算 `clockOffset`(客户端与服务端时钟差值),使 `getServerTime()` 返回与服务端对齐的时间。
|
| 158 |
+
|
| 159 |
+
- 初始阶段:快速采样(每 50ms)收集 20 个样本,使用 `switchedRef` 保证仅在首次校准完成时切换到稳定阶段
|
| 160 |
+
- 稳定阶段:每 5 秒一次 NTP 心跳
|
| 161 |
+
- NTP 仅在用户进入房间后启动(`ClockSyncRunner` 渲染在 `RoomPage` 中),大厅用户不运行时钟同步
|
| 162 |
+
- 使用**时间衰减加权中位数**计算 offset:每个样本按 `exp(-age / halfLife)` 衰减(halfLife=30s),兼具中位数的异常值鲁棒性和对网络环境突变的快速收敛能力
|
| 163 |
+
- **`performance.now()` 锚点**:`getServerTime()` 基于 `performance.now()`(单调递增)计算时间流逝,不受系统时钟突变影响(NTP 调整、手动改时间、休眠唤醒)。每次 `processPong` 刷新锚点:`anchorServerTime = Date.now() + medianOffset`、`anchorPerfNow = performance.now()`
|
| 164 |
+
- **RTT 回报**:每次 `ntp:ping` 附带 `lastRttMs`(客户端中位 RTT),服务端在 `NTP_PING` handler 中调用 `roomRepo.setSocketRTT()` 存储,用于自适应调度延迟计算
|
| 165 |
+
- 核心模块:`clockSync.ts`(采样引擎 + `getServerTime()` + `getMedianRTT()` + `computeWeightedMedian()`)、`useClockSync.ts`(React Hook,在 SocketProvider 中运行)
|
| 166 |
+
|
| 167 |
+
## Layer 2:Scheduled Execution(预定执行)
|
| 168 |
+
|
| 169 |
+
所有多客户端同步动作(play、pause、seek、resume)由服务端广播 `ScheduledPlayState`,包含 `serverTimeToExecute` 字段。客户端收到后通过 `setTimeout(execute, serverTimeToExecute - getServerTime())` 在同一时刻执行,消除网络延迟差异。
|
| 170 |
+
|
| 171 |
+
- 服务端根据房间 **P90 RTT** 动态计算调度延迟:`max(P90RTT * 1.5 + 100, 300ms)`,上限 3000ms。P90 避免单个慢连接拖累整个房间,房间人数 ≤3 时退化为取 max
|
| 172 |
+
- RTT 由客户端 NTP 测量后通过 `ntp:ping` 事件回报,服务端以指数移动平均(alpha=0.2)平滑存储在 `roomRepository` 的 per-socket RTT map
|
| 173 |
+
- 全部客户端(含操作发起者)统一收到广播并在预定时刻执行
|
| 174 |
+
- **serverTimestamp 对齐**:播放中的动作(play/resume/seek)将 `room.playState.serverTimestamp` 设为 `serverTimeToExecute` 而非 `Date.now()`,确保 `estimateCurrentTime()` 在下一次 conductor 上报前也能准确估算位置
|
| 175 |
+
- Scheduled action(seek/pause/resume)执行时自动重置 `rate(1)`,避免残留非正常速率;执行后同步更新 `roomStore.playState`(仅 `PlayState` 三字段,不含 `serverTimeToExecute`),确保 recovery effect 读到最新状态
|
| 176 |
+
- **NTP 未校准保护**:`scheduleDelay()` 和 `usePlayer` 的 PLAYER_PLAY 调度在 NTP 未校准完成前退化为 0(立即执行),避免本地时钟偏差导致离谱的调度延迟
|
| 177 |
+
- **Action ID 竞态保护**:每个 scheduled action 分配单调递增 ID,`setTimeout(fn, 0)` 回调执行前检查 ID 是否匹配,防止快速连续事件导致 stale 回调执行
|
| 178 |
+
|
| 179 |
+
## Layer 3:周期性比例漂移校正(EMA + Proportional Rate + Hard Seek)
|
| 180 |
+
|
| 181 |
+
**非 conductor 客户端**每 `SYNC_REQUEST_INTERVAL_MS`(2s)向服务端发送 `PLAYER_SYNC_REQUEST`(conductor 跳过,因为 conductor 是权威播放源,不应被 server 估算值反向校正),服务端通过 `estimateCurrentTime()` 计算当前预期位置后回复 `PLAYER_SYNC_RESPONSE`。客户端利用 NTP 校准时钟补偿网络延迟,计算原始漂移量后经 **EMA 低通滤波**(alpha=0.3)得到 `smoothedDrift`,再进入比例控制器:
|
| 182 |
+
|
| 183 |
+
- **新曲 Grace Period**:新曲加载后 `DRIFT_GRACE_PERIOD_MS`(3s)内**仅跳过 rate 微调**(EMA 产生的比例速率校正),但**保留 hard seek**(大偏差 >200ms 仍会跳转修正)。此窗口内 `estimateCurrentTime()` 基于 `scheduleTime` 锚点,尚未被 conductor 上报修正,rate 微调可能基于不准确的估算。等待至少一次 conductor 上报后再启用全面校正
|
| 184 |
+
- **EMA 平滑**:`smoothed = alpha * rawDrift + (1 - alpha) * prevSmoothed`,消除测量噪声导致的正负跳动
|
| 185 |
+
- **EMA 冷启动种子**:pause/resume/新曲/hard seek 后 EMA 重置,首次 sync response 直接用 rawDrift 种子初始化(而非从 0 开始混合),避免恢复播放后 6-8 秒的 EMA 收敛滞后
|
| 186 |
+
- `|smoothedDrift|` > `DRIFT_SEEK_THRESHOLD_MS`(200ms)→ hard seek 到预期位置 + rate(1) + 重置 smoothedDrift
|
| 187 |
+
- `|smoothedDrift|` 5~200ms(死区之上) → **比例控制**:`rate = 1 - clamp(smoothedDrift * Kp, ±MAX_RATE_ADJUSTMENT)`(Kp=0.5,最大 ±2%)。漂移越大修正越强,接近目标时自然减速——数学上保证不振荡
|
| 188 |
+
- `|smoothedDrift|` < `DRIFT_DEAD_ZONE_MS`(5ms)→ 恢复正常速率 rate(1)(消除稳态微小抖动)
|
| 189 |
+
- UI 展示 smoothedDrift 而非 rawDrift,界面数值更稳定
|
| 190 |
+
- **插件干扰自动降级**:设置 rate 后通过 `setTimeout(50ms)` 验证是否生效(timer 存于 ref,每次新 sync response 前清理上一个,组件卸载时也清理),若连续 3 次检测到被浏览器倍速插件覆盖才标记 `rateDisabled`;禁用后 hard seek 阈值降至 `DRIFT_PLUGIN_SEEK_THRESHOLD_MS`(30ms);新曲加载时重置标记和计数器
|
| 191 |
+
|
| 192 |
+
典型场景:手机息屏暂停后解锁、浏览器后台标签页节流、网络波动导致的累积偏移。
|
| 193 |
+
|
| 194 |
+
## Conductor 上报与服务端状态维护
|
| 195 |
+
|
| 196 |
+
Conductor(当前 `hostId` 对应用户)**自适应频率**上报当前播放位置到服务端:新曲开始后前 10 秒高频上报(每 2 秒,`CONDUCTOR_REPORT_FAST_INTERVAL_MS`),之后回到正常频率(每 5 秒,`CONDUCTOR_REPORT_INTERVAL_MS`),使用动态 `setTimeout` 链实现。仅用于维护 `room.playState` 的准确性(供 mid-song join、reconnect recovery 和漂移校正使用),**不会转发给其他客户端**。Conductor 标签页从后台恢复时(`visibilitychange` → visible),立即补偿上报一次当前位置,避免 `setTimeout` 被浏览器节流后 `playState` 过时。
|
| 197 |
+
|
| 198 |
+
- **NTP 校准时间戳**:conductor 上报时附带 `hostServerTime`(历史字段名,通过 `getServerTime()` 获取的 NTP 校准后服务器时间),服务端优先使用此值作为 `playState.serverTimestamp`,替代 `Date.now()`。这消除了 conductor→Server 单向网络延迟(≈RTT/2)导致的 `estimateCurrentTime()` 系统性落后偏差。服务端对 `hostServerTime` 做 10 秒容差校验(`Math.abs(hostServerTime - Date.now()) < 10_000`),超出范围回退到 `Date.now()`
|
| 199 |
+
- 服务端通过 `playerService.validateConductorReport()` 校验 conductor 上报位置与 `estimateCurrentTime()` 预估值的偏差,超过 `CONDUCTOR_REJECT_DRIFT_THRESHOLD_S`(3 秒)的报告视为过时数据(如手机息屏后恢复)被拒绝;但连续拒绝 `CONDUCTOR_REJECT_FORCE_ACCEPT_COUNT`(2)次后强制接受以打破僵局。`conductorRejectCount`、`lastNextTimestamp`、`playMutexes` 统一在 `playerService.cleanupRoom()` 中清理,避免内存泄漏。Conductor 切换时自动刷新 `playState.serverTimestamp` 和 `currentTime`,确保新 conductor 的首个报告不会被误拒
|
| 200 |
+
- `syncService.estimateCurrentTime()` 基于 conductor 上报的位置 + 经过时间估算当前位置,对 `elapsed` 做 `Math.max(0, ...)` 防护(`serverTimestamp` 可能是未来的 `scheduleTime`),且 clamp 到曲目时长上界(`room.currentTrack.duration`),防止 conductor 断线后估算值无限增长
|
| 201 |
+
- 新用户加入时,通过 `ROOM_STATE` 获取 `playState` 并计算应跳转到的位置
|
| 202 |
+
- 断线重连时,`usePlayer` 的 recovery 机制自动检测 desync 并重新加载音轨。Recovery 通过检查 `loadingRef` 避免与 `onPlayerPlay` 双重 `loadTrack`,且在加载前清理 `playTimerRef` 防止定时器重复触发
|
| 203 |
+
- **加载补偿上限**:`useHowl` 加载音频后会根据 `loadStartTime` 计算 elapsed 补偿 seek,但 elapsed 被 `MAX_LOAD_COMPENSATION_S`(2s)上限 clamp,防止网络慢时跳过歌曲开头过多
|
| 204 |
+
- 漂移校正时,`PLAYER_SYNC_RESPONSE` 基于此数据返回准确位置
|
| 205 |
+
|
| 206 |
+
## 播放模式
|
| 207 |
+
|
| 208 |
+
房间支持 4 种播放模式(`PlayMode`),由 `room.playMode` 字段控制,默认 `loop-all`:
|
| 209 |
+
|
| 210 |
+
| 模式 | 说明 |
|
| 211 |
+
| ------------ | -------------------------------------- |
|
| 212 |
+
| `sequential` | 顺序播放,末尾停止 |
|
| 213 |
+
| `loop-all` | 列表循环,末尾回到第一首 |
|
| 214 |
+
| `loop-one` | 单曲循环,重播当前曲目 |
|
| 215 |
+
| `shuffle` | 随机播放,从队列随机选一首(排除当前) |
|
| 216 |
+
|
| 217 |
+
- **Owner/Admin** 直接 emit `player:set_mode`,服务端更新 `room.playMode` 并广播 `ROOM_STATE`
|
| 218 |
+
- **Member** 通过 `vote:start { action: 'set-mode', payload: { mode } }` 投票切换
|
| 219 |
+
- **指定播放**:播放列表工具栏提供 Play 按钮,Owner/Admin 直接 emit `player:play`;Member 通过 `vote:start { action: 'play-track', payload: { trackId, trackTitle } }` 投票播放
|
| 220 |
+
- **投票移除**:播放列表工具栏的删除按钮对所有用户可见,Owner/Admin 直接 emit `queue:remove`;Member 通过 `vote:start { action: 'remove-track', payload: { trackId, trackTitle } }` 投票移除
|
| 221 |
+
- 服务端 `queueService.getNextTrack(roomId, playMode)` 根据模式返回下一首;`getPreviousTrack` 在 `loop-all` 模式下支持尾→首回绕
|
| 222 |
+
- 客户端 `PlayerControls` 提供循环切换按钮,带 `AnimatePresence` 图标过渡动画
|
| 223 |
+
|
| 224 |
+
## 音频质量
|
| 225 |
+
|
| 226 |
+
房间音质由 `room.audioQuality` 字段控制,默认 `320`(HQ)。除固定和平台特色档位外,`highest` 表示“尽量高”:
|
| 227 |
+
|
| 228 |
+
| 档位 | bitrate | 说明 |
|
| 229 |
+
| ------- | -------- | ------------------------------------ |
|
| 230 |
+
| 标准 | 128 kbps | 流量节省 |
|
| 231 |
+
| 较高 | 192 kbps | 平衡音质与流量 |
|
| 232 |
+
| HQ | 320 kbps | 高品质(默认) |
|
| 233 |
+
| 无损 SQ | 999 kbps | 无损音质,通常需要 VIP 账号 |
|
| 234 |
+
| 尽量高 | highest | 根据歌曲平台及房间内最高会员等级选择 |
|
| 235 |
+
|
| 236 |
+
- **房主和房间管理员**可在房间设置中切换音质;其他房间设置仍仅限房主
|
| 237 |
+
- 登录平台会员账号不会改变房间音质;新房间仍保持默认 `320`,需要房主手动切换其他档位
|
| 238 |
+
- 音质切换仅对**下一首歌**生效,当前播放不中断
|
| 239 |
+
- 服务端将各平台会员统一为 `0/1/2`(普通/VIP/SVIP),并从房间凭据池选择该歌曲平台等级最高的账号
|
| 240 |
+
- `audioQualityPolicy.getEffectiveQuality()` 一次确定不超过账号权益和房间设置的目标档位,不通过连续请求猜测会员等级
|
| 241 |
+
- 网易云:普通账号最高 320、VIP 最高高清臻音、SVIP 最高超清母带;QQ 与酷狗使用各自对应的 VIP/SVIP 档位
|
| 242 |
+
- B站:普通账号最高 192K,普通/年度大会员最高 Hi-Res;服务端用带 Cookie 的单次 WBI `playurl` 请求合并普通和 FLAC DASH 音轨,并在同一响应内选择不超过目标的最高可用档位。B站杜比为 E-AC-3,桌面浏览器支持不完整,当前明确不参与选流
|
| 243 |
+
- B站沿用现有五档,不增加平台专属 UI 选项:
|
| 244 |
+
|
| 245 |
+
| 房间音质 | 无会员 | 普通大会员 | 年度大会员 |
|
| 246 |
+
| -------------- | -------- | ---------- | ---------- |
|
| 247 |
+
| 标准 128kbps | B站 132K | B站 132K | B站 132K |
|
| 248 |
+
| 较高 192kbps | B站 192K | B站 192K | B站 192K |
|
| 249 |
+
| 高品质 320kbps | B站 192K | B站 192K | B站 192K |
|
| 250 |
+
| 无损 SQ | B站 192K | Hi-Res | Hi-Res |
|
| 251 |
+
| 尽量高 | B站 192K | Hi-Res | Hi-Res |
|
| 252 |
+
|
| 253 |
+
B站没有与房间 128K、320K 完全对应的普通 DASH 音轨,因此分别映射为最接近的 132K、192K。Hi-Res 还要求视频本身提供 FLAC 音轨,否则直接从同一次响应选 192K,不再次请求其他音质。
|
| 254 |
+
|
| 255 |
+
- 上游若返回歌曲实际可用的较低规格,服务端记录 `actualQuality` 和实际平均码率,不再逐档重新请求
|
| 256 |
+
- `musicProvider.streamUrlCache` 的 key 包含 bitrate,不同音质自动隔离缓存
|
| 257 |
+
|
| 258 |
+
## 音频代理策略
|
| 259 |
+
|
| 260 |
+
- B站音频始终通过服务器代理,播放所需 Cookie 只保留在服务端,不提供关闭入口。
|
| 261 |
+
- 全局 `AudioProxyPolicy` 只包含酷狗策略并持久化在 SQLite `server_settings` 表,旧数据库或无效配置默认酷狗强制代理。
|
| 262 |
+
- 只有服务器管理员可以通过 `GET/PATCH /api/admin/audio-proxy-policy` 读取或修改酷狗策略;PATCH 接受 `kugouForceProxy` 并返回完整策略。
|
| 263 |
+
- 新 WebSocket 连接会收到 `server:audio_proxy_policy`,管理员修改后服务端向 `lobby` 中的全部连接广播完整策略。
|
| 264 |
+
- `kugouForceProxy=false` 时,Web 端的酷狗标准版明文资源由兼容代理入口返回 `307` 到 CDN,音频字节不经过服务器;酷狗概念版和需要服务端处理的资源仍使用代理。Android 继续按自身能力先请求 CDN,失败时仅回退一次现有服务器代理。
|
| 265 |
+
- 重新启用酷狗强制代理时,Android 将正在直连的对应曲目保留位置切回代理;关闭强制代理不打断当前播放,从下一次加载开始生效。
|
| 266 |
+
- 酷狗策略同时覆盖 `kugou` 与 `kugou_concept`。服务端登记概念版 URL,并为已注册 QMC2 解密器的流设置 `Track.requiresServerProxy=true`;`.mflac`/`.mgg` 地址也按加密资源保守处理。Android 即使在关闭强制代理时也直接使用服务器代理解密,仅对明文资源尝试 CDN 直连。
|
| 267 |
+
|
| 268 |
+
## 队列清空
|
| 269 |
+
|
| 270 |
+
- Owner/Admin 可通过播放列表抽屉的「清空」按钮(`ListX` 图标)一次性清空队列
|
| 271 |
+
- 采用二次确认防误操作:首次点击变为 destructive 提示,3 秒内再次点击才执行
|
| 272 |
+
- 服务端 `queue:clear` handler 复用 `remove` on `Queue` 权限,清空后停止播放并广播 `QUEUE_UPDATED` + `PLAYER_PAUSE` + `ROOM_STATE`
|
| 273 |
+
|
| 274 |
+
## 其他同步机制
|
| 275 |
+
|
| 276 |
+
1. **暂停快照**:服务端 `pauseTrack()` 在暂停前调用 `estimateCurrentTime()` 快照准确位置
|
| 277 |
+
2. **恢复播放**:暂停后点击播放,服务端检测同一首歌时发 `player:resume`(所有客户端预定时刻恢复)
|
| 278 |
+
3. **自动续播**:房主独自重新加入时,若有歌曲暂停/排队中,自动恢复播放
|
| 279 |
+
4. **加入房间补偿**:中途加入的客户端使用 `getServerTime()` 计算当前应处的播放位置,采用 fade-in 淡入策略(400ms 等待 + 200ms fade)减少加入延迟
|
| 280 |
+
5. **房间宽限期**:普通房间空置 60 秒 (`ROOM_GRACE_PERIOD_MS`) 后自动清理(重复调用 `scheduleDeletion` 不会创建重复 timer);永久房间不会因空置被清理,最近 200 条聊天消息会持久化到 SQLite 并在服务重启后恢复
|
| 281 |
+
6. **角色与 Conductor 机制**:房间记录 `creatorId`(创建者 ID,永久不变)、`adminUserIds: Set<string>`(持久化 admin 集合)和 `temporaryAdminUserId`(临时管理员,仅在线态)。非空房间通过 `reconcileRoomRoles()` 保证至少有一个具备管理能力的在线用户:创建者在线时为 `owner`;持久 admin 在线时保持 `admin`;若 owner / 持久 admin 都不在线,则选择一个在线成员作为临时 `admin`,且不写入 `adminUserIds`。owner / 持久 admin 返回时会清除临时管理员并恢复其普通成员身份。`room.hostId` 是自动选举的播放主持(conductor),在用户加入/离开时基于已协调后的角色重选(优先级:owner > admin > member),无需宽限期。`setUserRole` 只能设置持久 `admin` / `member`(不能改 `owner`),同步维护 `adminUserIds`。返回的创建者/持久化 admin 免密码验证
|
| 282 |
+
7. **持久化用户身份**:客户端通过 `storage.getUserId()` 生成并持久化 `nanoid`,每次 `ROOM_CREATE` / `ROOM_JOIN` 携带 `userId`,使服务端可跨 socket 重连识别同一用户。服务端通过 `roomRepo.getSocketMapping(socket.id)` 获取 `{ roomId, userId }` 映射——`socket.id` 仅用于 Socket 映射查找,所有涉及用户身份的操作(host 判断、auth cookie 归属、权限检查等)统一使用 `mapping.userId`
|
| 283 |
+
8. **`currentUser` 自动推导**:`roomStore` 中 `currentUser` 始终从 `room.users` 自动推导(`deriveCurrentUser`),`setRoom` / `addUser` / `removeUser` / `updateRoom` 等 action 内部自动同步,不暴露 `setCurrentUser` 以避免脱节风险
|
| 284 |
+
9. **断线时钟重置**:`resetAllRoomState()` 除重置 Zustand stores 外,还调用 `resetClockSync()` 清空 NTP 采样,确保重连后使用全新的时钟校准数据
|
| 285 |
+
10. **Socket 断开竞态防护**:页面刷新时新旧 socket 的 join/disconnect 到达顺序不确定,`leaveRoom` 通过 `roomRepo.hasOtherSocketForUser()` 检测同一用户是否有更新的 socket 连接,避免旧 socket disconnect 误删活跃用户
|
| 286 |
+
11. **投票安全网**:`voteController` 接收 `VOTE_START` 时,若检测到用户已有直接操作权限(owner/admin),不再返回错误,而是直接执行该操作(`executeAction`),防止客户端-服务端角色不同步时操作失效。部分 VoteAction 通过 `PERM_MAP` 映射到不同的 CASL action+subject(如 `'play-track'` → `('play', 'Player')`,`'remove-track'` → `('remove', 'Queue')`)
|
| 287 |
+
12. **切歌防抖**:500ms (`PLAYER_NEXT_DEBOUNCE_MS`) 内不重复触发下一首。`playNextTrackInRoom` / `playPrevTrackInRoom` 将 debounce 检查和队列导航封装在 per-room mutex 内部,确保同 tick 的多个 NEXT/PREV 事件不会都通过 debounce。支持 `{ skipDebounce: true }` 选项,投票执行、删除当前曲目等场景绕过 debounce 以确保操作不被静默吞掉
|
| 288 |
+
13. **停止播放统一处理**:`playerService.stopPlayback()` 统一处理"队列为空/清空"场景——清除 currentTrack、emit PLAYER_PAUSE、广播 ROOM_STATE、刷新大厅列表,避免 controller 中重复逻辑。`stopPlaybackSafe()` 提供 mutex 保护版本,`QUEUE_CLEAR` 使用此版本防止与并发 `autoPlayIfEmpty` 竞态
|
| 289 |
+
14. **大厅重连刷新**:`useLobby` 监听 socket `connect` 事件,断线重连后自动重新拉取房间列表
|
| 290 |
+
15. **投票执行**:`VOTE_CAST` / `VOTE_START` 中 `executeAction` 使用 `await` 确保动作完成后才广播 `VOTE_RESULT`。投票的 `next`/`prev` 通过 `playerService.playNextTrackInRoom` / `playPrevTrackInRoom`(`skipDebounce: true`)执行,与直接操作路径完全一致(含 stopPlayback 兜底和播放失败重试),且不受 debounce 影响
|
| 291 |
+
16. **密码安全隔离**:`toPublicRoomState()` 默认不含密码明文;`toPublicRoomStateForOwner()` 仅在发送给 owner 的 socket 时使用(创建房间、加入房间、设置变更、conductor/角色变更)。非 owner 成员仅能看到 `hasPassword` 布尔标记,无法获取密码明文。设置广播通过 `socket.emit`(owner) + `socket.to(roomId).emit`(其他成员)分别发送。owner 在线且 conductor/角色变更时,通过 `roomRepo.getSocketIdForUser()` 反查 owner 的 socketId 定向发送含密码版本;没有 owner 在线(仅临时管理员)时广播不含密码版本
|
| 292 |
+
|
| 293 |
+
## REST API
|
| 294 |
+
|
| 295 |
+
| 路径 | 方法 | 用途 |
|
| 296 |
+
| ------------------------------- | ----- | ------------------------------------------------------------------------------------------------------- |
|
| 297 |
+
| `/api/music/search` | GET | 搜索曲目(`source` + `keyword` + `page`) |
|
| 298 |
+
| `/api/music/url` | GET | 解析流媒体 URL(`source` + `id`) |
|
| 299 |
+
| `/api/music/lyric` | GET | 获取歌词 |
|
| 300 |
+
| `/api/music/cover` | GET | 获取封面图 |
|
| 301 |
+
| `/api/music/playlist` | GET | 获取歌单曲目列表(`source` + `id` + `limit` + `offset`),分页返回 `{ tracks, total, offset, hasMore }` |
|
| 302 |
+
| `/api/rooms/:roomId/check` | GET | 房间预检(存在性 + 是否需要密码),用于分享链接直接访问时的前置校验 |
|
| 303 |
+
| `/api/admin/audio-proxy-policy` | GET | 服务器管理员读取酷狗全局强制代理策略 |
|
| 304 |
+
| `/api/admin/audio-proxy-policy` | PATCH | 服务器管理员部分更新代理策略并广播完整结果 |
|
| 305 |
+
| `/api/health` | GET | 健康检查 |
|
| 306 |
+
|
| 307 |
+
---
|
docs/architecture/dependencies.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 第三方库依赖
|
| 2 |
+
|
| 3 |
+
## Client 核心依赖
|
| 4 |
+
|
| 5 |
+
| 分类 | 库 | 版本 | 用途 |
|
| 6 |
+
| ------------ | ---------------------------------------------------------- | -------- | --------------------------------------- |
|
| 7 |
+
| **UI 框架** | react, react-dom | ^19.2.0 | UI 基础 |
|
| 8 |
+
| | shadcn/ui (new-york) | — | 组件库(基于 Radix UI) |
|
| 9 |
+
| | radix-ui | ^1.4.3 | 无障碍 UI 原语 |
|
| 10 |
+
| | tailwindcss | ^4.1.18 | 原子化 CSS |
|
| 11 |
+
| | class-variance-authority | ^0.7.1 | 组件变体样式 |
|
| 12 |
+
| | tailwind-merge | ^3.4.0 | class 合并去重 |
|
| 13 |
+
| | clsx | ^2.1.1 | 条件 class 拼接 |
|
| 14 |
+
| **权限** | @casl/react | ^5.0.1 | RBAC 权限(配合 @casl/ability) |
|
| 15 |
+
| **错误边界** | react-error-boundary | ^6.1.0 | React Error Boundary |
|
| 16 |
+
| **状态管理** | zustand | ^5.0.11 | 轻量全局状态 |
|
| 17 |
+
| **路由** | react-router-dom | ^7.13.0 | 客户端路由 |
|
| 18 |
+
| **实时通信** | socket.io-client | ^4.8.3 | WebSocket 客户端 |
|
| 19 |
+
| **音频** | howler | ^2.2.4 | 音频播放引擎 |
|
| 20 |
+
| | @applemusic-like-lyrics/core | ^0.2.0 | 歌词解析核心 |
|
| 21 |
+
| | @applemusic-like-lyrics/react | ^0.2.0 | 歌词 React 组件 |
|
| 22 |
+
| **动画** | motion | ^12.34.0 | Framer Motion 动画 |
|
| 23 |
+
| | tw-animate-css | ^1.4.0 | Tailwind 动画预设 |
|
| 24 |
+
| **图形渲染** | @pixi/app, core, display, sprite | ^7.4.3 | PixiJS(歌词背景渲染) |
|
| 25 |
+
| | @pixi/filter-blur, filter-bulge-pinch, filter-color-matrix | — | PixiJS 滤镜 |
|
| 26 |
+
| **弹窗** | vaul | ^1.x | 移动端 Drawer(底部抽屉,支持拖拽关闭) |
|
| 27 |
+
| **虚拟列表** | @tanstack/react-virtual | ^3.13.18 | 虚拟滚动(歌单详情大列表) |
|
| 28 |
+
| **工具** | dayjs | ^1.11.19 | 日期格式化 |
|
| 29 |
+
| | nanoid | ^5.1.6 | ID 生成 |
|
| 30 |
+
| | sonner | ^2.0.7 | Toast 通知 |
|
| 31 |
+
| | lucide-react | ^0.563.0 | 图标库 |
|
| 32 |
+
| | jss, jss-preset-default | ^10.10.0 | CSS-in-JS(AMLL 依赖) |
|
| 33 |
+
|
| 34 |
+
## Server 核心依赖
|
| 35 |
+
|
| 36 |
+
| 库 | 版本 | 用途 |
|
| 37 |
+
| --------------------------------- | ------- | ------------------------------------------------------------------- |
|
| 38 |
+
| express | ^4.21.0 | HTTP 框架 |
|
| 39 |
+
| socket.io | ^4.8.3 | WebSocket 服务端 |
|
| 40 |
+
| @meting/core | ^1.6.0 | 多音源音乐数据聚合 |
|
| 41 |
+
| @s4p/kugou-lrc | ^0.2.0 | 酷狗 KRC 逐字歌词获取与解析 |
|
| 42 |
+
| nanoid | ^5.0.9 | 房间 ID 生成 |
|
| 43 |
+
| cors | ^2.8.5 | 跨域 |
|
| 44 |
+
| dotenv | ^16.4.5 | 环境变量 |
|
| 45 |
+
| zod | ^4.3.6 | 请求数据验证(配合 shared schemas) |
|
| 46 |
+
| pino | ^10.3.1 | 结构化日志 |
|
| 47 |
+
| rate-limiter-flexible | ^9.1.1 | 聊天限流 |
|
| 48 |
+
| @neteasecloudmusicapienhanced/api | ^4.30.1 | 网易云 QR 登录 / Cookie 验证 / 用户信息 |
|
| 49 |
+
| qrcode | ^1.5.4 | QR 码生成(酷狗扫码登录,API 仅返回 URL 需服务端转 base64 DataURL) |
|
| 50 |
+
| escape-html | ^1.0.3 | HTML 转义(防注入) |
|
| 51 |
+
| p-limit | ^7.3.0 | 并发控制(封面批量解析) |
|
| 52 |
+
| lru-cache | ^11.2.6 | LRU 缓存(musicProvider 外部 API 结果缓存) |
|
| 53 |
+
|
| 54 |
+
## Shared 核心依赖
|
| 55 |
+
|
| 56 |
+
| 库 | 版本 | 用途 |
|
| 57 |
+
| ------------- | ------ | --------------------------- |
|
| 58 |
+
| @casl/ability | ^6.8.0 | RBAC 权限定义(前后端共用) |
|
| 59 |
+
| zod | ^4.3.6 | 数据验证 Schema |
|
| 60 |
+
|
| 61 |
+
## 开发工具
|
| 62 |
+
|
| 63 |
+
| 库 | 版本 | 包 | 用途 |
|
| 64 |
+
| --------------------------- | ------- | ------ | ----------------------- |
|
| 65 |
+
| vite | ^7.3.1 | client | 前端构建 |
|
| 66 |
+
| @vitejs/plugin-react | ^5.1.1 | client | Vite React 插件 |
|
| 67 |
+
| @tailwindcss/vite | ^4.1.18 | client | Vite Tailwind 插件 |
|
| 68 |
+
| typescript | ~5.9.3 | all | 类型系统 |
|
| 69 |
+
| tsx | ^4.19.0 | server | 服务端 TS 运行/热重载 |
|
| 70 |
+
| pino-pretty | ^13.1.3 | server | 开发环境日志美化 |
|
| 71 |
+
| eslint | ^9.39.1 | client | 代码检查 |
|
| 72 |
+
| eslint-plugin-react-hooks | ^7.0.1 | client | React Hooks 规则 |
|
| 73 |
+
| eslint-plugin-react-refresh | ^0.4.24 | client | React Fast Refresh 规则 |
|
| 74 |
+
| concurrently | ^9.2.1 | root | 并行运行前后端 |
|
| 75 |
+
| kill-port | ^2.0.1 | root | 端口清理 |
|
| 76 |
+
|
| 77 |
+
---
|
docs/architecture/deployment.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 部署方案
|
| 2 |
+
|
| 3 |
+
## 架构
|
| 4 |
+
|
| 5 |
+
采用**纯 Node.js 单镜像**方案:Express 同时托管前端 SPA 静态文件和后端 API/WebSocket,无需 Nginx。
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
Docker 容器 (:3001)
|
| 9 |
+
├── / 静态文件 → client/dist(Vite 产物)
|
| 10 |
+
├── /api/* → REST API
|
| 11 |
+
└── /socket.io/* → WebSocket
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
## CI/CD 流程
|
| 15 |
+
|
| 16 |
+
1. **push 到 main** → GitHub Actions 构建 Docker 镜像 → 推送到 GHCR(`ghcr.io`)
|
| 17 |
+
2. **服务器上** Watchtower 每 5 分钟检查镜像更新 → 自动拉取并重启容器
|
| 18 |
+
|
| 19 |
+
零人工干预,GitHub 零额外 Secrets(使用自带的 `GITHUB_TOKEN`)。
|
| 20 |
+
|
| 21 |
+
## Docker 多阶段构建
|
| 22 |
+
|
| 23 |
+
- **阶段 1(deps)**:`pnpm install --frozen-lockfile` 安装全部依赖
|
| 24 |
+
- **阶段 2(build)**:分别构建 shared、server(tsc)、client(vite build)
|
| 25 |
+
- **阶段 3(production)**:仅安装 server 生产依赖(`--filter @music-together/server...`),复制构建产物
|
| 26 |
+
|
| 27 |
+
## CORS 策略
|
| 28 |
+
|
| 29 |
+
- `CLIENT_URL` 未设置 → 自动模式,允许所有来源访问(适用于单镜像同域部署、局域网、公网反代)
|
| 30 |
+
- `CLIENT_URL` 显式设置 → 严格白名单模式(适用于前后端分离跨域部署)
|
| 31 |
+
|
| 32 |
+
## Identity Cookie 策略
|
| 33 |
+
|
| 34 |
+
- 未显式设置 `IDENTITY_COOKIE_SECURE` 时,服务端会根据当前请求协议自动决定是否添加 `Secure`
|
| 35 |
+
- 局域网 HTTP 访问会下发非 Secure cookie
|
| 36 |
+
- 公网 HTTPS / 反代 HTTPS 访问会下发 Secure cookie
|
| 37 |
+
- 自动判断 HTTPS 依赖代理正确透传 `X-Forwarded-Proto`
|
| 38 |
+
- 仅在需要强制行为时才手动设置 `IDENTITY_COOKIE_SECURE`
|
| 39 |
+
|
| 40 |
+
## 前端同域适配
|
| 41 |
+
|
| 42 |
+
`SERVER_URL` 默认使用 `window.location.origin`,同域部署时自动指向当前页面的 origin,无需配置。
|
| 43 |
+
|
| 44 |
+
## 静态文件托管
|
| 45 |
+
|
| 46 |
+
`packages/server/src/index.ts` 在启动时检测 `client/dist/index.html` 是否存在:
|
| 47 |
+
|
| 48 |
+
- **存在**(生产环境):挂载 `express.static` + SPA fallback
|
| 49 |
+
- **不存在**(本地开发):跳过,零影响
|
| 50 |
+
|
| 51 |
+
## 服务器部署命令
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
# 启动应用容器
|
| 55 |
+
docker run -d --name music-together --restart unless-stopped -p 3001:3001 ghcr.io/<owner>/music-together:latest
|
| 56 |
+
|
| 57 |
+
# 启动 Watchtower 自动更新
|
| 58 |
+
docker run -d --name watchtower --restart unless-stopped \
|
| 59 |
+
-v /var/run/docker.sock:/var/run/docker.sock \
|
| 60 |
+
-e WATCHTOWER_CLEANUP=true \
|
| 61 |
+
containrrr/watchtower --interval 300 music-together
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
如使用 1Panel,创建反向代理网站指向 `127.0.0.1:3001`,启用 WebSocket 和 HTTPS。
|
docs/architecture/design-patterns.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 设计模式
|
| 2 |
+
|
| 3 |
+
## 前端模式
|
| 4 |
+
|
| 5 |
+
## Zustand Store 模式
|
| 6 |
+
|
| 7 |
+
5 个独立 store,各自管理一个领域的状态:
|
| 8 |
+
|
| 9 |
+
| Store | 职责 | 持久化 |
|
| 10 |
+
| --------------- | --------------------------------------------------- | ------------------------- |
|
| 11 |
+
| `playerStore` | 播放状态(曲目、进度、音量、歌词) | 音量持久化到 localStorage |
|
| 12 |
+
| `roomStore` | 房间状态(room、currentUser 自动推导自 room.users) | 无 |
|
| 13 |
+
| `chatStore` | 聊天(消息列表、未读数、开关状态) | 无 |
|
| 14 |
+
| `lobbyStore` | 大厅(房间列表、加载状态) | 无 |
|
| 15 |
+
| `settingsStore` | 设置(歌词对齐/动画/字体/翻译字体大小、背景参数) | 全部持久化到 localStorage |
|
| 16 |
+
|
| 17 |
+
使用方式:通过选择器订阅特定字段,避免不必要的渲染:
|
| 18 |
+
|
| 19 |
+
```typescript
|
| 20 |
+
const volume = usePlayerStore((s) => s.volume)
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
在 Socket 回调中使用 `getState()` 避免闭包问题:
|
| 24 |
+
|
| 25 |
+
```typescript
|
| 26 |
+
const room = useRoomStore.getState().room
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
## 自定义 Hooks 组合模式
|
| 30 |
+
|
| 31 |
+
两个核心组合 hook 各自编排多个子 hook:
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
usePlayer useRoom
|
| 35 |
+
├── useHowl (Howler.js 实例) ├── useRoomState (核心房间事件)
|
| 36 |
+
├── useLyric (歌词解析) ├── useChatSync (聊天事件)
|
| 37 |
+
└── usePlayerSync ├── useQueueSync (队列事件)
|
| 38 |
+
├── Scheduled Execution ├── useAuthSync (Cookie 持久化 + toast 反馈,永不删除 cookie)
|
| 39 |
+
└── Host Progress Reporting └── useConnectionGuard (断线重置)
|
| 40 |
+
|
| 41 |
+
SocketProvider (连接管理,无 NTP)
|
| 42 |
+
|
| 43 |
+
RoomPage
|
| 44 |
+
└── ClockSyncRunner → useClockSync (NTP 时钟同步,仅房间内运行)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
通用工具 Hook:`useSocketEvent(event, handler)` 封装 `socket.on/off` 样板代码,已在 `useLobby` 和 `useVote` 中使用。
|
| 48 |
+
|
| 49 |
+
其他独立 hook:`useChat`、`useLobby`、`useQueue`、`useVote`、`useAuth`、`usePlaylist`,每个 hook 负责将 Socket 事件绑定到对应 Store。
|
| 50 |
+
|
| 51 |
+
`usePlaylist` 管理歌单功能:通过 Socket 获取用户歌单列表(`playlist:get_my` → `playlist:my_list`),通过 REST 分页获取歌单曲目(`GET /api/music/playlist?limit=100&offset=0`,返回 `{ tracks, total, offset, hasMore }`),提供 `loadMoreTracks()` 无限加载下一页、URL/ID 解析工具函数(`parsePlaylistInput`),以及单曲/批量添加到队列。切换歌单时立即重置状态防止闪旧数据,内部 `loadingMoreRef`(ref)做同步防重,避免 React 批量更新前的闭包竞态。URL 拼接通过 `buildPlaylistUrl()` 辅助函数集中管理。
|
| 52 |
+
|
| 53 |
+
## 受控 Dialog 模式
|
| 54 |
+
|
| 55 |
+
所有弹窗组件遵循统一的 prop 接口:
|
| 56 |
+
|
| 57 |
+
```typescript
|
| 58 |
+
interface DialogProps {
|
| 59 |
+
open: boolean
|
| 60 |
+
onOpenChange: (open: boolean) => void
|
| 61 |
+
// 业务回调...
|
| 62 |
+
}
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
父组件(页面)管理 `open` 状态,弹窗组件只负责渲染和用户交互。
|
| 66 |
+
|
| 67 |
+
## ResponsiveDialog 模式
|
| 68 |
+
|
| 69 |
+
`responsive-dialog.tsx` 通用组件根据视口宽度自动切换呈现方式:
|
| 70 |
+
|
| 71 |
+
- **桌面端**(≥640px):居中 Dialog(基于 Radix UI)
|
| 72 |
+
- **移动端**(<640px):底部 Drawer(基于 vaul,支持拖拽关闭)
|
| 73 |
+
|
| 74 |
+
通过 React Context 向子组件传递 `isMobile` 状态,提供一一映射的子组件:`ResponsiveDialog`、`ResponsiveDialogContent`、`ResponsiveDialogHeader`、`ResponsiveDialogTitle`、`ResponsiveDialogDescription`、`ResponsiveDialogFooter`、`ResponsiveDialogClose`、`ResponsiveDialogBody`。使用方只需替换 import 路径即可获得响应式行为。项目中所有业务弹窗(CreateRoomDialog、PasswordDialog、NicknameDialog、SearchDialog、SettingsDialog)均已迁移至此组件。
|
| 75 |
+
|
| 76 |
+
## Context Provider 模式
|
| 77 |
+
|
| 78 |
+
`SocketProvider` 通过 React Context 提供 Socket.IO 实例和连接状态,并内置断线/重连 Toast 提示:
|
| 79 |
+
|
| 80 |
+
```typescript
|
| 81 |
+
const { socket, isConnected } = useSocketContext()
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
`AbilityProvider` 通过 React Context 提供 CASL ability 实例,组件可通过 `useContext(AbilityContext)` 查询权限。
|
| 85 |
+
|
| 86 |
+
## 组件组合模式
|
| 87 |
+
|
| 88 |
+
`AudioPlayer` 组合 `NowPlaying` + `SongInfoBar` + `PlayerControls` + `LyricDisplay`,各组件独立负责自己的渲染逻辑。`RoomPage` 组合所有功能区域和覆盖层弹窗。
|
| 89 |
+
|
| 90 |
+
## 移动端双模式播放页面
|
| 91 |
+
|
| 92 |
+
移动端(竖屏)播放页面通过 `lyricExpanded` 状态实现两种模式切换,模仿 Apple Music 移动端交互:
|
| 93 |
+
|
| 94 |
+
- **默认模式**(`lyricExpanded=false`):大封面 + 歌曲信息 + 控制器,封面自适应剩余空间(`flex-1 min-h-0` + `aspect-square max-h-full max-w-full`),控制器固定底部,不显示歌词
|
| 95 |
+
- **歌词模式**(`lyricExpanded=true`):点击封面后,封面缩小到顶部变为 compact 横排(48px 小封面 `rounded-md` + ��题 `text-base` / 艺术家 `text-sm`),歌词区域 fade+slide-up 入场占满中间空间,控制器固定底部
|
| 96 |
+
|
| 97 |
+
布局策略:
|
| 98 |
+
|
| 99 |
+
- 移动端外层 padding `px-5 py-7`(水平 20px,垂直 28px),所有子元素 `w-full`,不使用 `max-w` 约束,边距由外层 padding 统一控制
|
| 100 |
+
- `NowPlaying` wrapper 使用 `flex-1 min-h-0`,默认模式下封面在剩余空间内居中缩放(`aspect-square max-h-full max-w-full`),避免封面过大挤压控件
|
| 101 |
+
|
| 102 |
+
关键技术:
|
| 103 |
+
|
| 104 |
+
- `NowPlaying` 组件支持 `compact` prop 切换大图/小图布局,`onCoverClick` 触发模式切换
|
| 105 |
+
- `framer-motion` 的 `layoutId`("cover-art" / "song-info")实现封面和文字在两种布局间的共享布局动画(0.45s Apple 风格贝塞尔缓动)
|
| 106 |
+
- `LayoutGroup` 包裹移动端内容区,确保跨组件 `layoutId` 动画生效
|
| 107 |
+
- `AnimatePresence` + `motion.div` 实现歌词区域的 fade+slide-up 入场/退场
|
| 108 |
+
- 歌词模式状态在切歌时保持不变,用户手动点击封面切换
|
| 109 |
+
- 桌面端保持左右分栏布局不受影响
|
| 110 |
+
|
| 111 |
+
## 后端模式
|
| 112 |
+
|
| 113 |
+
## 分层架构
|
| 114 |
+
|
| 115 |
+
```
|
| 116 |
+
Controller → Service → Repository / Utils
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
- **Controller**:注册 Socket 事件监听器,薄编排层(校验输入 → 调用 Service → 编排通知)。不包含业务逻辑。
|
| 120 |
+
- **Service**:业务逻辑、跨领域编排、Socket 广播。关键服务职责拆分:
|
| 121 |
+
- `roomService`:房间 CRUD + 角色管理 + 临时管理员协调(`reconcileRoomRoles`)+ conductor 选举(`electConductor`)+ 加入校验(`validateJoinRequest`)。Re-export `toPublicRoomState` 和 `broadcastRoomList` 以保持控制器调用方式不变。
|
| 122 |
+
- `roomLifecycleService`:房间空置删除定时器 + 防抖广播。不依赖 `roomService`,消除循环依赖。API:`scheduleDeletion`、`cancelDeletionTimer`、`broadcastRoomList`、`clearAllTimers`。角色宽限期已移除(conductor 自动选举,无需 grace period)。
|
| 123 |
+
- `playerService`:播放状态管理 + 流 URL 解析 + 切歌防抖 + 加入播放同步(`syncPlaybackToSocket`)+ 房间清理(`cleanupRoom`)。`playTrackInRoom` 通过 per-room Promise 链互斥锁防止并发竞态。`playNextTrackInRoom` / `playPrevTrackInRoom` 将 debounce + 队列导航 + 播放统一封装在 mutex 内部。`autoPlayIfEmpty` 在 mutex 内重新检查 `room.currentTrack`,防止并发 QUEUE_ADD 双重自动播放。
|
| 124 |
+
- **Repository**:数据存取(当前为内存 Map,接口抽象,可替换为数据库)
|
| 125 |
+
- **Utils**:纯函数工具(`toPublicRoomState` 等),无状态,可被任意层引用
|
| 126 |
+
|
| 127 |
+
## Repository 模式
|
| 128 |
+
|
| 129 |
+
使用 TypeScript 接口抽象数据访问:
|
| 130 |
+
|
| 131 |
+
```typescript
|
| 132 |
+
interface RoomRepository {
|
| 133 |
+
get(roomId: string): RoomData | undefined
|
| 134 |
+
set(roomId: string, room: RoomData): void
|
| 135 |
+
delete(roomId: string): void
|
| 136 |
+
getAll(): Map<string, RoomData>
|
| 137 |
+
// ...
|
| 138 |
+
}
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
当前实现为 `InMemoryRoomRepository`(`Map<string, RoomData>`),未来可替换为 Redis/数据库实现。
|
| 142 |
+
|
| 143 |
+
## Socket.IO 中间件链
|
| 144 |
+
|
| 145 |
+
```
|
| 146 |
+
withPermission(action, subject) → withRoom(io) → Handler
|
| 147 |
+
withOwnerOnly(io) → withRoom(io) → Handler
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
- `withRoom`:校验 Socket 是否在房间中,构建 `HandlerContext`(io, socket, roomId, room, user)
|
| 151 |
+
- `withPermission`:在 `withRoom` 基础上用 CASL `defineAbilityFor(role)` 检查 `(action, subject)` 权限
|
| 152 |
+
- `withOwnerOnly`:在 `withRoom` 基础上仅允许房主(`user.role === 'owner'`),用于设置和角色管理
|
| 153 |
+
|
| 154 |
+
错误统一通过 `ROOM_ERROR` 事件回传给客户端,错误码使用 `ERROR_CODE` 枚举(`shared/types.ts`),包括:`NOT_IN_ROOM`、`ROOM_NOT_FOUND`、`NO_PERMISSION`、`INVALID_DATA`、`QUEUE_FULL`、`RATE_LIMITED`、`INTERNAL` 等。
|
| 155 |
+
|
| 156 |
+
## 结构化日志(pino)
|
| 157 |
+
|
| 158 |
+
基于 [pino](https://github.com/pinojs/pino) 的薄封装,开发环境使用 `pino-pretty` 美化输出:
|
| 159 |
+
|
| 160 |
+
```typescript
|
| 161 |
+
logger.info('Room created', { roomId, socketId: socket.id })
|
| 162 |
+
logger.error('Failed to resolve stream URL', err, { roomId, trackId })
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
输出格式:`[ISO_TIMESTAMP] LEVEL message {JSON_CONTEXT}`
|
| 166 |
+
|
| 167 |
+
## 共享模式
|
| 168 |
+
|
| 169 |
+
## 类型驱动的事件系统
|
| 170 |
+
|
| 171 |
+
`EVENTS` 常量对象定义所有事件名,`ClientToServerEvents` / `ServerToClientEvents` 接口为每个事件定义精确的负载类型,确保前后端通信的类型安全。
|
| 172 |
+
|
| 173 |
+
## 构建优化
|
| 174 |
+
|
| 175 |
+
- **路由级懒加载**:`RoomPage` 和 `NotFoundPage` 使用 `React.lazy` + `Suspense`(`HomePage` 保持同步加载以保证首屏速度)
|
| 176 |
+
- **Vite manualChunks 分包**:react、socket.io、motion、radix-ui、pixi.js 分别打包为独立 chunk,利用浏览器长期缓存
|
| 177 |
+
- **React.memo**:列表项组件(`RoomCard`、`ChatMessage`、`TrackListItem`)和高频更新组件(`PlayerControls`)均使用 `React.memo` 避免不必要的 re-render
|
| 178 |
+
- **Zustand 细粒度 selector**:避免 `useRoomStore((s) => s.room)` 的粗粒度订阅,改用 `s.room?.name` 等精确字段
|
| 179 |
+
|
| 180 |
+
## 常量集中管理
|
| 181 |
+
|
| 182 |
+
`LIMITS` 和 `TIMING` 在 shared 包中统一定义,���后端共用:
|
| 183 |
+
|
| 184 |
+
```typescript
|
| 185 |
+
LIMITS.QUEUE_MAX_SIZE // 100
|
| 186 |
+
LIMITS.QUEUE_BATCH_MAX_SIZE // 100
|
| 187 |
+
LIMITS.CHAT_HISTORY_MAX // 200
|
| 188 |
+
TIMING.ROOM_GRACE_PERIOD_MS // 60_000
|
| 189 |
+
TIMING.PLAYER_NEXT_DEBOUNCE_MS // 500
|
| 190 |
+
TIMING.VOTE_TIMEOUT_MS // 30_000
|
| 191 |
+
NTP.INITIAL_INTERVAL_MS // 50
|
| 192 |
+
NTP.STEADY_STATE_INTERVAL_MS // 5_000
|
| 193 |
+
NTP.MAX_INITIAL_SAMPLES // 20
|
| 194 |
+
NTP.MIN_SCHEDULE_DELAY_MS // 300
|
| 195 |
+
NTP.MAX_SCHEDULE_DELAY_MS // 3_000
|
| 196 |
+
QR_STATUS.EXPIRED // 800
|
| 197 |
+
QR_STATUS.WAITING_SCAN // 801
|
| 198 |
+
QR_STATUS.SCANNED // 802
|
| 199 |
+
QR_STATUS.SUCCESS // 803
|
| 200 |
+
QR_TIMING.POLL_INTERVAL_MS // 2_000
|
| 201 |
+
QR_TIMING.SUCCESS_CLOSE_DELAY_MS // 1_000
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
docs/architecture/dev-guide.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 开发指南
|
| 2 |
+
|
| 3 |
+
## 快速启动
|
| 4 |
+
|
| 5 |
+
```bash
|
| 6 |
+
# 安装依赖
|
| 7 |
+
pnpm install
|
| 8 |
+
|
| 9 |
+
# 启动前后端开发服务器(默认同时开放到局域网)
|
| 10 |
+
pnpm dev
|
| 11 |
+
|
| 12 |
+
# 其他设备通过 http://你的局域网IP:5173 访问前端
|
| 13 |
+
# 前端会自动连接到 http://你的局域网IP:3001 后端
|
| 14 |
+
|
| 15 |
+
# 仅启动前端
|
| 16 |
+
pnpm dev:client
|
| 17 |
+
|
| 18 |
+
# 仅启动后端
|
| 19 |
+
pnpm dev:server
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## 端口
|
| 23 |
+
|
| 24 |
+
| 服务 | 默认端口 |
|
| 25 |
+
| -------------------------- | -------- |
|
| 26 |
+
| 前端 (Vite) | 5173 |
|
| 27 |
+
| 后端 (Express + Socket.IO) | 3001 |
|
| 28 |
+
|
| 29 |
+
## 环境变量
|
| 30 |
+
|
| 31 |
+
## 服务端 (`packages/server/.env`)
|
| 32 |
+
|
| 33 |
+
| 变量 | 说明 | 默认值 |
|
| 34 |
+
| -------------- | ------------------------ | ----------------------- |
|
| 35 |
+
| `PORT` | 服务端口 | `3001` |
|
| 36 |
+
| `CLIENT_URL` | 客户端地址(CORS) | `http://localhost:5173` |
|
| 37 |
+
| `CORS_ORIGINS` | 额外 CORS 源(逗号分隔) | 空 |
|
| 38 |
+
|
| 39 |
+
## 客户端 (Vite 环境变量)
|
| 40 |
+
|
| 41 |
+
| 变量 | 说明 | 默认值 |
|
| 42 |
+
| ----------------- | -------- | ----------------------- |
|
| 43 |
+
| `VITE_SERVER_URL` | 后端地址 | `http://localhost:3001` |
|
| 44 |
+
|
| 45 |
+
## 构建
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# 构建所有包
|
| 49 |
+
pnpm build
|
| 50 |
+
|
| 51 |
+
# 前端产物 → packages/client/dist/
|
| 52 |
+
# 后端产物 → packages/server/dist/
|
| 53 |
+
# shared 包无构建步骤,直接作为 TS 源码引用
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## 添加 shadcn/ui 组件
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
cd packages/client
|
| 60 |
+
npx shadcn@latest add <component-name>
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
组件会安装到 `src/components/ui/`。
|
| 64 |
+
|
| 65 |
+
## 注意事项
|
| 66 |
+
|
| 67 |
+
- 服务端数据全部存储在内存中,重启后丢失
|
| 68 |
+
- 无数据库、无服务端持久化(客户端 Cookie 通过 localStorage 持久化)
|
| 69 |
+
- 用户身份基于持久化 nanoid(localStorage)+ 昵称(无注册/登录账号系统);`socket.id` 仅用于 Socket 传输层映射
|
| 70 |
+
- 平台认证(网易云/酷狗 QR 扫码登录、QQ 手动 Cookie)用于 VIP 歌曲访问,Cookie 作用域为房间级。QR 登录状态码在 `shared/constants.ts` 中定义为 `QR_STATUS`(800-803),前后端共用,`QrLoginDialog` 无需区分平台
|
| 71 |
+
- Auth Cookie 持久化策略:**只有用户主动登出(`useAuth.logout()`)才删除 localStorage 中的 cookie**。`useAuthSync` 在收到 `AUTH_SET_COOKIE_RESULT` 失败时(无论 `reason` 是 `expired` 还是 `error`)仅通过 toast 反馈,永远不删除 cookie,确保下次进房间时自动重试。`LoginSection` 在 localStorage 有 cookie 但服务端未确认时显示 "验证登录中…" 乐观状态
|
| 72 |
+
- Auth Cookie 自动重发:`useRoomState` 在收到 `ROOM_STATE` 时自动重发 localStorage 中的 cookie。另外,当从 HomePage 导航到 RoomPage 时(`ROOM_STATE` 已被 HomePage 提前消费),`useRoomState` 挂载时检测到 room 已存在会立即补发 cookie,确保任何入口都能恢复认证
|
| 73 |
+
- Auth Cookie 服务端验证双路径:`authController` 收到 `AUTH_SET_COOKIE` 时先检查 `hasCookie()`——如果 cookie 已在内存池中(刷新页面场景),走 **fast path** 跳过 API 调用直接返回成功;否则走 **slow path** 调用 `getUserInfo()` → `ncmApi.login_status()`。slow path 对**任何失败原因**(`expired` 或 `error`)都自动重试 1 次(间隔 1.5 秒),因为网易云 `login_status` API 可能对有效 cookie 临时返回空 profile
|
| 74 |
+
- `shared` 包修改后前后端会自动热重载(pnpm workspace 链接)
|
docs/architecture/directory-structure.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 目录结构
|
| 2 |
+
|
| 3 |
+
## 根目录
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
music-together/
|
| 7 |
+
├── packages/
|
| 8 |
+
│ ├── client/ # React 前端
|
| 9 |
+
│ ├── server/ # Node.js 后端
|
| 10 |
+
│ └── shared/ # 共享类型与常量
|
| 11 |
+
├── docs/ # 项目文档(含本文件 PROJECT_ARCHITECTURE.md)
|
| 12 |
+
├── package.json # 根 package(工作区编排)
|
| 13 |
+
├── pnpm-workspace.yaml # pnpm 工作区定义
|
| 14 |
+
├── pnpm-lock.yaml
|
| 15 |
+
├── README.md
|
| 16 |
+
└── .gitignore
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## packages/client/src/ — 前端源码
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
src/
|
| 23 |
+
├── main.tsx # 入口:ReactDOM.createRoot
|
| 24 |
+
├── App.tsx # 根组件:Router + Provider + ErrorBoundary + Suspense 懒加载
|
| 25 |
+
├── index.css # 全局样式:Tailwind + 配色变量 + 自定义动画
|
| 26 |
+
│
|
| 27 |
+
├── pages/ # 页面级组件
|
| 28 |
+
│ ├── HomePage.tsx # 大厅:创建/加入房间、房间列表
|
| 29 |
+
│ ├── RoomPage.tsx # 房间:播放器 + 聊天(桌面侧栏/移动端 Drawer) + 覆盖层弹窗
|
| 30 |
+
│ └── NotFoundPage.tsx # 404 页面
|
| 31 |
+
│
|
| 32 |
+
├── components/ # UI 组件
|
| 33 |
+
│ ├── Chat/
|
| 34 |
+
│ │ ├── ChatMessage.tsx # 单条消息(用户/系统)
|
| 35 |
+
│ │ └── ChatPanel.tsx # 聊天面板(消息列表 + 输入框)
|
| 36 |
+
│ ├── Lobby/
|
| 37 |
+
│ │ ├── CreateRoomDialog.tsx # 创建房间弹窗
|
| 38 |
+
│ │ ├── NicknameDialog.tsx # 设置昵称弹窗
|
| 39 |
+
│ │ ├── PasswordDialog.tsx # 输入房间密码弹窗
|
| 40 |
+
│ │ ├── RoomCard.tsx # 房间列表卡片
|
| 41 |
+
│ │ ├── UserPopover.tsx # 用户信息气泡
|
| 42 |
+
│ │ ├── HeroSection.tsx # 首页 Hero 标题区域
|
| 43 |
+
│ │ ├── ActionCards.tsx # 创建/加入房间卡片
|
| 44 |
+
│ │ └── RoomListSection.tsx # 活跃房间列表区域
|
| 45 |
+
│ ├── TrackListItem.tsx # 共享曲目行渲染(序号+封面+标题VIP+歌手可点击+时长+isAdded 添加按钮,memo 优化)
|
| 46 |
+
│ ├── VirtualTrackList.tsx # 共享虚拟滚动曲目列表(@tanstack/react-virtual + 无限加载 + skeleton + 空态,forwardRef 暴露 scrollToTop)
|
| 47 |
+
│ ├── Overlays/
|
| 48 |
+
│ │ ├── QueueDrawer.tsx # 播放队列抽屉(vaul Drawer,移动端底部/桌面端右侧)
|
| 49 |
+
│ │ ├── SearchDialog.tsx # 音乐搜索弹窗(VirtualTrackList 虚拟滚动 + 自动无限加载 + AbortController 竞态防护)
|
| 50 |
+
│ │ ├── SettingsDialog.tsx # 设置弹窗(壳,Tab 导航:房间/成员/账号/个人/外观,移动端 nav scrollbar-hide)
|
| 51 |
+
│ │ └── Settings/
|
| 52 |
+
│ │ ├── SettingRow.tsx # 设置行共享组件
|
| 53 |
+
│ │ ├── RoomSettingsSection.tsx # 房间设置(名称、密码)
|
| 54 |
+
│ │ ├── MembersSection.tsx # 成员列表(角色管理)
|
| 55 |
+
│ │ ├── PlatformAuthSection.tsx # 平台账号认证(VIP Cookie,旧版,已被 PlatformHub 替代)
|
| 56 |
+
│ │ ├── PlatformHub.tsx # 平台中心(登录 + 歌单浏览 + 导入,替代 PlatformAuthSection)
|
| 57 |
+
│ │ ├── LoginSection.tsx # 精简版平台登录区域(PlatformHub 子组件)
|
| 58 |
+
│ │ ├── PlaylistSection.tsx # 歌单列表 + 手动输入(PlatformHub 子组件)
|
| 59 |
+
│ │ ├── PlaylistDetail.tsx # 歌单详情(header + VirtualTrackList + queueKeys/addedIds 去重 + 动态全部添加过滤重复)
|
| 60 |
+
│ │ ├── ProfileSettingsSection.tsx # 个人设置(昵称)
|
| 61 |
+
│ │ ├── AppearanceSection.tsx # 外观设置(歌词 + 背景 + 布局)
|
| 62 |
+
│ │ ├── OtherSettingsSection.tsx # 其他设置
|
| 63 |
+
│ │ ├── ManualCookieDialog.tsx # 手动输入 Cookie 弹窗
|
| 64 |
+
│ │ └── QrLoginDialog.tsx # 通用 QR 扫码登录弹窗(网易云 + 酷狗 + QQ 音乐)
|
| 65 |
+
│ ├── Player/
|
| 66 |
+
│ │ ├── constants.ts # 共享动画常量(SPRING / LAYOUT_TRANSITION),NowPlaying 和 SongInfoBar 统一导入
|
| 67 |
+
│ │ ├── AudioPlayer.tsx # 主播放器布局(桌面:左右分栏;移动:双模式封面/歌词切换)
|
| 68 |
+
│ │ ├── LyricDisplay.tsx # AMLL 歌词渲染(LRC 正则支持 [mm:ss] / [mm:ss.x] / [mm:ss.xx] / [mm:ss.xxx])
|
| 69 |
+
│ │ ├── NowPlaying.tsx # 当前曲目展示(支持 compact 小封面横排模式 + layoutId 共享动画)
|
| 70 |
+
│ │ ├── SongInfoBar.tsx # 歌曲信息栏(标题/艺术家 + 音量/聊天按钮,竖屏模式自适应缩放)
|
| 71 |
+
│ │ └── PlayerControls.tsx # 进度条+播放控制+播放模式切换
|
| 72 |
+
│ ├── Room/
|
| 73 |
+
│ │ └── RoomHeader.tsx # 房间头部(房间名/人数/连接状态;移动端 DropdownMenu 收纳设置/离开等操作)
|
| 74 |
+
│ ├── Vote/
|
| 75 |
+
│ │ └── VoteBanner.tsx # 投票横幅(进行中的投票显示 + 投票按钮)
|
| 76 |
+
│ ├── InteractionGate.tsx # 浏览器交互解锁(点击后才能播放音频)
|
| 77 |
+
│ └── ui/ # shadcn/ui 基础组件
|
| 78 |
+
│ ├── avatar.tsx
|
| 79 |
+
│ ├── badge.tsx
|
| 80 |
+
│ ├── button.tsx
|
| 81 |
+
│ ├── card.tsx
|
| 82 |
+
│ ├── dialog.tsx
|
| 83 |
+
│ ├── drawer.tsx
|
| 84 |
+
│ ├── dropdown-menu.tsx
|
| 85 |
+
│ ├── input.tsx
|
| 86 |
+
│ ├── label.tsx
|
| 87 |
+
│ ├── popover.tsx
|
| 88 |
+
│ ├── resize-handle.tsx
|
| 89 |
+
│ ├── scroll-area.tsx
|
| 90 |
+
│ ├── select.tsx
|
| 91 |
+
│ ├── separator.tsx
|
| 92 |
+
│ ├── marquee-text.tsx
|
| 93 |
+
│ ├── responsive-dialog.tsx
|
| 94 |
+
│ ├── sheet.tsx
|
| 95 |
+
│ ├── skeleton.tsx
|
| 96 |
+
│ ├── slider.tsx
|
| 97 |
+
│ ├── switch.tsx
|
| 98 |
+
│ ├── tabs.tsx
|
| 99 |
+
│ └── tooltip.tsx
|
| 100 |
+
│
|
| 101 |
+
├── hooks/ # 自定义 Hooks
|
| 102 |
+
│ ├── useSocketEvent.ts # 通用 Socket 事件订阅工具 Hook(自动 on/off,ref 稳定)
|
| 103 |
+
│ ├── usePlayer.ts # 播放器主 hook(组合 useHowl + useLyric + usePlayerSync)
|
| 104 |
+
│ ├── useHowl.ts # Howler.js 音频实例管理
|
| 105 |
+
│ ├── useLyric.ts # 歌词加载(TTML → 平台逐词 YRC/KRC → LRC)
|
| 106 |
+
│ ├── usePlayerSync.ts # 播放同步(Scheduled Execution + conductor 上报 + 周期性漂移校正)
|
| 107 |
+
│ ├── useClockSync.ts # NTP 时钟同步 hook(校准客户端时钟与服务器对齐)
|
| 108 |
+
│ ├── useRoom.ts # 房间组合 hook(编排 5 个子 hook,对外 API 不变)
|
| 109 |
+
│ ├── room/ # useRoom 子 hook(按职责拆分)
|
| 110 |
+
│ │ ├── useRoomState.ts # ROOM_STATE / JOIN / LEFT / SETTINGS / ROLE_CHANGED / ERROR + 挂载时补发 cookie(覆盖 HomePage 提前消费 ROOM_STATE 的场景)
|
| 111 |
+
│ │ ├── useChatSync.ts # CHAT_HISTORY / CHAT_MESSAGE
|
| 112 |
+
│ │ ├── useQueueSync.ts # QUEUE_UPDATED
|
| 113 |
+
│ │ ├── useAuthSync.ts # AUTH_SET_COOKIE_RESULT + localStorage 持久化(验证失败只做 toast 反馈,永不删除 cookie;删除权仅在 useAuth.logout)
|
| 114 |
+
│ │ └── useConnectionGuard.ts # disconnect → resetAllRoomState
|
| 115 |
+
│ ├── useAuth.ts # 平台认证 UI & Socket 事件
|
| 116 |
+
│ ├── useVote.ts # 投票(使用 useSocketEvent)
|
| 117 |
+
│ ├── useChat.ts # 聊天消息收发
|
| 118 |
+
│ ├── useLobby.ts # 大厅房间列表与操作(使用 useSocketEvent)
|
| 119 |
+
│ ├── useQueue.ts # 播放队列操作(含 addBatchTracks 批量添加)
|
| 120 |
+
│ ├── usePlaylist.ts # 歌单管理(用户歌单列表、分页曲目获取 + 无限加载、URL 解析、批量导入)
|
| 121 |
+
│ ├── useIsMobile.ts # 布局维度:orientation 检测(portrait=竖屏布局,landscape=横屏布局)
|
| 122 |
+
│ ├── useHasHover.ts # 交互维度:hover 能力检测((hover: hover) 媒体查询,触控设备=false)
|
| 123 |
+
│ ├── useContainerPortrait.ts # 容器宽高比检测(ResizeObserver,用于播放器横竖屏切换)
|
| 124 |
+
│ └── useCoverWidth.ts # 封面容器最小尺寸测量(约束信息栏/控件宽度与封面对齐)
|
| 125 |
+
│
|
| 126 |
+
├── stores/ # Zustand 状态仓库
|
| 127 |
+
│ ├── playerStore.ts # 播放状态(currentTrack, isPlaying, volume 等)
|
| 128 |
+
│ ├── roomStore.ts # 房间状态(room, currentUser, users)
|
| 129 |
+
│ ├── chatStore.ts # 聊天(messages, unreadCount, isChatOpen)
|
| 130 |
+
│ ├── lobbyStore.ts # 大厅(rooms 列表, isLoading)
|
| 131 |
+
│ └── settingsStore.ts # 设置(歌词参数、背景参数,持久化到 localStorage)
|
| 132 |
+
│
|
| 133 |
+
├── providers/ # React Context Provider
|
| 134 |
+
│ ├── SocketProvider.tsx # Socket.IO 连接管理,提供 socket + isConnected + 断线/重连 Toast
|
| 135 |
+
│ └── AbilityProvider.tsx # CASL 权限上下文(基于 currentUser.role)
|
| 136 |
+
│
|
| 137 |
+
└── lib/ # 工具库
|
| 138 |
+
├── config.ts # 配置常量(SERVER_URL)
|
| 139 |
+
├── constants.ts # 命名常量(定时器、阈值、布局尺寸)
|
| 140 |
+
├── clockSync.ts # NTP 时钟同步引擎(采样、offset 计算、getServerTime)
|
| 141 |
+
├── resetStores.ts # 全局 store 重置工具
|
| 142 |
+
├── socket.ts # Socket.IO 客户端实例
|
| 143 |
+
├── storage.ts # localStorage 封装(带类型校验)
|
| 144 |
+
├── platform.ts # 平台常量��PLATFORM_LABELS / PLATFORM_SHORT_LABELS / PLATFORM_COLORS / VIP_LABELS / 状态查找函数)
|
| 145 |
+
├── format.ts # 格式化工具(时间、文本等)
|
| 146 |
+
├── audioUnlock.ts # 浏览器音频自动播放解锁
|
| 147 |
+
└── utils.ts # cn() + trackKey() 等通用工具
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## packages/server/src/ — 后端源码
|
| 151 |
+
|
| 152 |
+
```
|
| 153 |
+
src/
|
| 154 |
+
├── index.ts # 入口:Express + HTTP + Socket.IO 服务启动与优雅关闭
|
| 155 |
+
├── config.ts # 环境变量配置(PORT, CLIENT_URL, CORS)
|
| 156 |
+
│
|
| 157 |
+
├── controllers/ # 控制器:注册 Socket 事件处理器(薄编排层,不含业务逻辑)
|
| 158 |
+
│ ├── index.ts # 统一注册入口
|
| 159 |
+
│ ├── roomController.ts # 房间生命周期(创建/加入/离开/发现/设置/角色)
|
| 160 |
+
│ ├── playerController.ts # 播放控制(play/pause/seek/next/prev/sync/set_mode)+ NTP ping/pong
|
| 161 |
+
│ ├── queueController.ts # 队列管理(add/remove/reorder/clear)
|
| 162 |
+
│ ├── chatController.ts # 聊天消息(含限流反馈)
|
| 163 |
+
│ ├── voteController.ts # 投票系统(发起/投票/超时/执行,支持 set-mode / play-track / remove-track 投票)
|
| 164 |
+
│ ├── authController.ts # 平台认证(QR 登录/Cookie 管理/状态查询;支持网易云/酷狗/QQ 音乐三平台;策略模式——通过 AUTH_PROVIDERS 映射表统一处理;fast path: 内存池命中跳过 API;slow path: getUserInfo + 任意失败重试 1 次)
|
| 165 |
+
│ └── playlistController.ts # 歌单管理(获取用户歌单列表 via Socket,使用 getUserCookie 取请求者自己的 cookie,歌单私有)
|
| 166 |
+
│
|
| 167 |
+
├── services/ # 服务层:业务逻辑
|
| 168 |
+
│ ├── roomService.ts # 房间 CRUD + 角色管理 + 加入校验(validateJoinRequest)
|
| 169 |
+
│ ├── roomLifecycleService.ts # 房间生命周期定时器(空置删除)+ 防抖广播
|
| 170 |
+
│ ├── playerService.ts # 播放状态管理 + 流 URL 解析 + 切歌防抖 + 加入播放同步
|
| 171 |
+
│ ├── queueService.ts # 队列操作(reorder 保留未包含曲目防丢歌,getNextTrack 支持 4 种播放模式,clearQueue 清空,addBatchTracks 批量添加)
|
| 172 |
+
│ ├── chatService.ts # 聊天消息处理 + HTML 转义(含系统消息)
|
| 173 |
+
│ ├── syncService.ts # 播放位置估算工具(estimateCurrentTime)
|
| 174 |
+
│ ├── musicProvider.ts # 音乐数据聚合(3 层引用式 LRU 缓存 + 外部 API 超时保护 + 歌单分页获取;Netease 歌单使用 ncmApi.playlist_track_all 分块请求突破 1000 首限制,Kugou 用户歌单使用原生 API (get_other_list_file_nofilt) + Meting fallback,Tencent 使用 Meting 原始模式保留 VIP/时长字段)
|
| 175 |
+
│ ├── authService.ts # Cookie 池管理(房间级作用域;getAnyCookie 用于 VIP 播放共享,getUserCookie 用于歌单等用户私有操作)
|
| 176 |
+
│ ├── authProvider.ts # 统一认证接口(AuthProvider 接口定义 + GetUserInfoResult/UserInfoData 共享类型 + AUTH_PROVIDERS 策略映射表)
|
| 177 |
+
│ ├── neteaseAuthService.ts # 网易云 API 认证(QR / Cookie 验证 / 用户信息 / 用户歌单列表;getUserInfo 返回 { ok, data? } | { ok: false, reason: 'expired' | 'error' } 区分过期与临时故障)
|
| 178 |
+
│ ├── kugouAuthService.ts # 酷狗 API 认证(QR 扫码登录 + VIP 检查 + 用户昵称(RSA) + 用户歌单列表 + 歌单歌曲获取;kugouRequest 含 HTTP 状态检查与 JSON 安全解析;自包含签名实现,状态码归一化为 800-803 与网易云统一)
|
| 179 |
+
│ ├── tencentAuthService.ts # QQ 音乐认证(5 步 OAuth QR 扫码登录:ptqrshow/ptqrlogin/check_sig/authorize/QQLogin 换取 musickey;zzc 签名防风控;getUserInfo 获取昵称 + VIP 状态;getUserPlaylists 获取自建 + 收藏歌单;getPlaylistTracks 分页获取歌单歌曲)
|
| 180 |
+
│ └── voteService.ts # 投票状态管理
|
| 181 |
+
│
|
| 182 |
+
├── repositories/ # 数据仓库:内存状态与 SQLite 持久化
|
| 183 |
+
│ ├── types.ts # 接口定义(RoomRepository, ChatRepository)
|
| 184 |
+
│ ├── roomRepository.ts # 房间数据 + Socket 映射 + per-socket RTT + roomToSockets 反向索引(Map<string, RoomData>)
|
| 185 |
+
│ ├── chatRepository.ts # 聊天记录(内存最多 200 条;永久房间同步持久化到 SQLite)
|
| 186 |
+
│ └── audioProxyPolicyRepository.ts # 全局酷狗音频代理策略(SQLite server_settings)
|
| 187 |
+
│
|
| 188 |
+
├── middleware/ # Socket.IO 中间件
|
| 189 |
+
│ ├── types.ts # TypedServer, TypedSocket, HandlerContext
|
| 190 |
+
│ ├── withRoom.ts # 房间成员身份校验
|
| 191 |
+
│ ├── withControl.ts # 操作权限校验(包装 withRoom)
|
| 192 |
+
│ └── socketRateLimiter.ts # Socket 事件速率限制(per-socket,10次/5秒)+ 断连清理(cleanupSocketRateLimit)
|
| 193 |
+
│
|
| 194 |
+
├── routes/ # Express REST 路由
|
| 195 |
+
│ ├── music.ts # GET /api/music/search|url|lyric|cover|playlist|ttml(统一 validated() 路由包装器消除重复 try/catch + Zod 模式)
|
| 196 |
+
│ └── rooms.ts # GET /api/rooms/:roomId/check(房间预检)
|
| 197 |
+
│
|
| 198 |
+
├── types/
|
| 199 |
+
│ └── meting.d.ts # @meting/core 类型声明
|
| 200 |
+
│
|
| 201 |
+
└── utils/
|
| 202 |
+
├── logger.ts # 结构化日志(基于 pino,info/warn/error + JSON context)
|
| 203 |
+
└── roomUtils.ts # 房间数据转换纯函数(toPublicRoomState / toPublicRoomStateForOwner,密码仅 owner 可见)
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
## packages/shared/src/ — 共享代码
|
| 207 |
+
|
| 208 |
+
```
|
| 209 |
+
src/
|
| 210 |
+
├── index.ts # 统一导出(re-export 所有模块)
|
| 211 |
+
├── types.ts # 核心类型:ERROR_CODE, Track, RoomState, PlayState, ScheduledPlayState, PlayMode, AudioQuality, User, ChatMessage, VoteAction (incl. play-track, remove-track), VoteState, RoomListItem, Playlist
|
| 212 |
+
├── events.ts # 事件常量:EVENTS 对象(room:*, player:*, queue:*, chat:*, auth:*, ntp:*, playlist:*)
|
| 213 |
+
├── socket-types.ts # Socket.IO 类型:ServerToClientEvents, ClientToServerEvents
|
| 214 |
+
├── constants.ts # 业务常量:LIMITS(长度/数量限制), TIMING(同步间隔/宽限期), NTP(时钟同步参数), QR_STATUS(扫码状态码), QR_TIMING(轮询间隔)
|
| 215 |
+
├── schemas.ts # Zod 验证 schema
|
| 216 |
+
└── abilities.ts # CASL 权限定义(Actions incl. set-mode, Subjects, defineAbilityFor)
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
---
|
docs/architecture/ui-design.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI 设计规范
|
| 2 |
+
|
| 3 |
+
## 组件库
|
| 4 |
+
|
| 5 |
+
- **shadcn/ui**:`new-york` 风格变体,`neutral` 基色
|
| 6 |
+
- **配置**:非 RSC(`"rsc": false`),使用 CSS 变量,Lucide 图标
|
| 7 |
+
- **安装路径**:`@/components/ui/`,工具函数 `@/lib/utils`
|
| 8 |
+
|
| 9 |
+
## 颜色系统
|
| 10 |
+
|
| 11 |
+
使用 **oklch 色彩空间**,单一深色主题,CSS 变量直接定义在 `:root`(无亮/暗切换):
|
| 12 |
+
|
| 13 |
+
```css
|
| 14 |
+
:root {
|
| 15 |
+
--background: oklch(0.178 0.005 265); /* 深蓝灰 */
|
| 16 |
+
--foreground: oklch(0.985 0 0); /* 近白 */
|
| 17 |
+
--primary: oklch(0.922 0 0); /* 亮色主色 */
|
| 18 |
+
--card: oklch(0.235 0.008 265); /* 微蓝灰卡片 */
|
| 19 |
+
--accent: oklch(0.35 0.008 265); /* 强调色 */
|
| 20 |
+
/* ... */
|
| 21 |
+
}
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## 字体
|
| 25 |
+
|
| 26 |
+
- **主字体**:Plus Jakarta Sans(通过 `@fontsource/plus-jakarta-sans` 本地打包)
|
| 27 |
+
- **回退**:system-ui, -apple-system, sans-serif
|
| 28 |
+
|
| 29 |
+
## 圆角
|
| 30 |
+
|
| 31 |
+
基准值 `--radius: 0.625rem`,其他圆角通过计算派生:
|
| 32 |
+
|
| 33 |
+
```css
|
| 34 |
+
--radius-sm: calc(var(--radius) - 4px); /* 小 */
|
| 35 |
+
--radius-md: calc(var(--radius) - 2px); /* 中 */
|
| 36 |
+
--radius-lg: var(--radius); /* 标准 */
|
| 37 |
+
--radius-xl: calc(var(--radius) + 4px); /* 大 */
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## 图标
|
| 41 |
+
|
| 42 |
+
使用 **lucide-react**,按需导入:
|
| 43 |
+
|
| 44 |
+
```typescript
|
| 45 |
+
import { Play, Pause, SkipForward, Volume2 } from 'lucide-react'
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 动画
|
| 49 |
+
|
| 50 |
+
- **motion (Framer Motion)**:组件进入/退出动画、列表动画
|
| 51 |
+
- **tw-animate-css**:Tailwind 动画预设类
|
| 52 |
+
- **自定义动画**:`float` 关键帧(6s 缓动上下浮动)、`marquee` 关键帧(文本溢出自动滚动)
|
| 53 |
+
|
| 54 |
+
```css
|
| 55 |
+
@keyframes float {
|
| 56 |
+
0%,
|
| 57 |
+
100% {
|
| 58 |
+
transform: translateY(0);
|
| 59 |
+
}
|
| 60 |
+
50% {
|
| 61 |
+
transform: translateY(-6px);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
@keyframes marquee {
|
| 66 |
+
0%,
|
| 67 |
+
15% {
|
| 68 |
+
transform: translateX(0);
|
| 69 |
+
}
|
| 70 |
+
45%,
|
| 71 |
+
55% {
|
| 72 |
+
transform: translateX(var(--marquee-distance));
|
| 73 |
+
}
|
| 74 |
+
85%,
|
| 75 |
+
100% {
|
| 76 |
+
transform: translateX(0);
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## MarqueeText 组件
|
| 82 |
+
|
| 83 |
+
`marquee-text.tsx` 通用文本溢出自动滚动组件,用于播放列表项、SongInfoBar、NowPlaying compact 模式等场景:
|
| 84 |
+
|
| 85 |
+
- 使用 `ResizeObserver` 检测文本是否溢出容器(`scrollWidth > clientWidth`)
|
| 86 |
+
- 溢出时通过 CSS `translateX` 动画实现 pause-scroll-pause 循环(GPU 加速)
|
| 87 |
+
- 动画时长根据溢出距离动态计算(`Math.max(5, 4 + overflow / 30)`),长文本滚动更慢
|
| 88 |
+
- 未溢出时无动画,等同普通文本
|
| 89 |
+
- 遵循 `prefers-reduced-motion` 系统偏好(已有全局 `animation-duration: 0.01ms` 降级)
|
| 90 |
+
|
| 91 |
+
## 滚动条
|
| 92 |
+
|
| 93 |
+
自定义 WebKit 滚动条:6px 宽/高,透明轨道,半透明滑块。Firefox 使用 `scrollbar-width: thin`。`.scrollbar-hide` 工具类隐藏滚动条但保持滚动功能(用于移动端设置导航栏等)。
|
| 94 |
+
|
| 95 |
+
## 歌词渲染
|
| 96 |
+
|
| 97 |
+
- **AMLL** (`@applemusic-like-lyrics/react`):Apple Music 风格逐行歌词动画
|
| 98 |
+
- **逐词来源**:TTML 在线库(网易云/QQ)、平台原生 YRC(网易云)、平台原生 KRC(酷狗,服务端解析后以 `wordByWord` 返回)
|
| 99 |
+
- **PixiJS 背景**:`BackgroundRender` 组件使用 PixiJS 渲染动态专辑封面背景
|
| 100 |
+
- 歌词设置可调:对齐锚点、弹簧动画、模糊效果、缩放效果、字重、字体大小、翻译字体大小
|
| 101 |
+
|
| 102 |
+
## 样式工具函数
|
| 103 |
+
|
| 104 |
+
```typescript
|
| 105 |
+
// cn() — 条件 class 合并(clsx + tailwind-merge)
|
| 106 |
+
import { cn } from '@/lib/utils'
|
| 107 |
+
|
| 108 |
+
<div className={cn('base-class', isActive && 'active-class')} />
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## Toast 通知
|
| 112 |
+
|
| 113 |
+
使用 **sonner**,定位在顶部居中:
|
| 114 |
+
|
| 115 |
+
```typescript
|
| 116 |
+
<Toaster position="top-center" richColors />
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## 触控与 Hover 双模式
|
| 120 |
+
|
| 121 |
+
项目将**布局**和**交互**解耦为两个正交维度:
|
| 122 |
+
|
| 123 |
+
| Hook | 维度 | 媒体查询 | 控制内容 |
|
| 124 |
+
| --------------- | ---- | ------------------------- | --------------------------------------------- |
|
| 125 |
+
| `useIsMobile()` | 布局 | `(orientation: portrait)` | Drawer 方向、面板高度、Dialog vs Drawer |
|
| 126 |
+
| `useHasHover()` | 交互 | `(hover: hover)` | hover 工具栏 vs tap 展开、Slider thumb 可见性 |
|
| 127 |
+
|
| 128 |
+
横屏手机 = `isMobile: false`(使用桌面布局)+ `hasHover: false`(使用触控交互)。
|
| 129 |
+
|
| 130 |
+
- **Slider Thumb**:进度条 Thumb 默认透明,桌面端 `group-hover` 显示,触控设备通过 `focus:opacity-100` 在触摸拖拽时显示
|
| 131 |
+
- **触控目标尺寸**:所有可点击按钮在移动端通过 `min-h-11 min-w-11`(44px)扩大触控热区,桌面端 `sm:min-h-0 sm:min-w-0` 还原视觉尺寸。涉及 QueueDrawer 工具栏、VoteBanner、RoomHeader
|
| 132 |
+
- **QueueDrawer tap-to-expand**:触控设备(`isTouch = !hasHover`)点击列表项展开操作工具栏;桌面端通过 `group-hover` 显示工具栏。两种模式在横屏手机上都能正确工作
|
| 133 |
+
|
| 134 |
+
## 封面图片 onError 回退
|
| 135 |
+
|
| 136 |
+
- **NowPlaying**:使用 `coverError` state 追踪加载失败,切歌时重置;失败后回退到 `Disc3` 占位图标
|
| 137 |
+
- **QueueDrawer**:`<img>` 的 `onError` 隐藏图片并显示 `Music` 占位图标(始终渲染占位 div,通过 `hidden` class 切换)
|
| 138 |
+
|
| 139 |
+
## 音频加载失败重试
|
| 140 |
+
|
| 141 |
+
`useHowl` 的 `onloaderror` 处理:
|
| 142 |
+
|
| 143 |
+
- 首次失败自动重试一次(`retryRef` flag + `howl.load()`)
|
| 144 |
+
- 重试仍失败则 toast 显示歌曲名(`trackTitleRef`)并跳到下一首
|
| 145 |
+
- `onloaderror` 开头有 stale guard(`howlRef.current !== howl`),防止旧 Howl 实例的重试回调影响新曲
|
| 146 |
+
- `loadTrack` 时重置 retry flag 和歌曲名 ref,并清理 `playErrorTimerRef`(防止上一首的 play-error 超时跳过新曲)
|
| 147 |
+
- `onload` 和 `onplay` 中对 `howl.duration()` 做 `Number.isFinite` + `> 0` 校验,防止流式加载时无效 duration 写入 store
|
| 148 |
+
- `queueAddSchema` 对 `duration` 做 `z.number().finite().nonnegative()` 校验,从数据入口杜绝无效时长
|
| 149 |
+
|
| 150 |
+
---
|
package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "music-together",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "Online music listening together platform",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"predev": "kill-port 3001 --silent || true",
|
| 8 |
+
"dev": "concurrently --kill-others --names server,client --prefix-colors blue,green \"pnpm run dev:server\" \"pnpm run dev:client\"",
|
| 9 |
+
"dev:client": "pnpm --filter @music-together/client dev",
|
| 10 |
+
"dev:server": "pnpm --filter @music-together/server dev",
|
| 11 |
+
"build": "pnpm -r run build && node scripts/prepare-production.mjs",
|
| 12 |
+
"start": "node packages/server/dist/index.js",
|
| 13 |
+
"format": "prettier --write \"packages/*/src/**/*.{ts,tsx}\" \"packages/shared/src/**/*.ts\""
|
| 14 |
+
},
|
| 15 |
+
"keywords": [],
|
| 16 |
+
"author": "",
|
| 17 |
+
"license": "AGPL-3.0",
|
| 18 |
+
"packageManager": "pnpm@10.29.2",
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"concurrently": "^9.2.1",
|
| 21 |
+
"kill-port": "^2.0.1",
|
| 22 |
+
"prettier": "^3.8.1"
|
| 23 |
+
},
|
| 24 |
+
"workspaces": [
|
| 25 |
+
"packages/*"
|
| 26 |
+
],
|
| 27 |
+
"dependencies": {
|
| 28 |
+
"semver": "^7.8.5"
|
| 29 |
+
}
|
| 30 |
+
}
|
packages/client/.env.development
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 开发环境:前端 5173、后端 3001,需要显式指定后端地址
|
| 2 |
+
VITE_SERVER_URL=http://localhost:3001
|
packages/client/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
packages/client/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
packages/client/components.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": false,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/index.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"rtl": false,
|
| 15 |
+
"aliases": {
|
| 16 |
+
"components": "@/components",
|
| 17 |
+
"utils": "@/lib/utils",
|
| 18 |
+
"ui": "@/components/ui",
|
| 19 |
+
"lib": "@/lib",
|
| 20 |
+
"hooks": "@/hooks"
|
| 21 |
+
},
|
| 22 |
+
"registries": {}
|
| 23 |
+
}
|
packages/client/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 'latest',
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
packages/client/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="color-scheme" content="dark" />
|
| 8 |
+
<title>Music Together - 一起听歌</title>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="root"></div>
|
| 12 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
packages/client/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@music-together/client",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"typecheck": "tsc -b --noEmit",
|
| 11 |
+
"preview": "vite preview"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@applemusic-like-lyrics/core": "^0.2.0",
|
| 15 |
+
"@applemusic-like-lyrics/lyric": "^0.3.0",
|
| 16 |
+
"@applemusic-like-lyrics/react": "^0.2.0",
|
| 17 |
+
"@casl/react": "^5.0.1",
|
| 18 |
+
"@fontsource/plus-jakarta-sans": "^5.3.0",
|
| 19 |
+
"@music-together/shared": "workspace:*",
|
| 20 |
+
"@pixi/app": "^7.4.3",
|
| 21 |
+
"@pixi/core": "^7.4.3",
|
| 22 |
+
"@pixi/display": "^7.4.3",
|
| 23 |
+
"@pixi/filter-blur": "^7.4.3",
|
| 24 |
+
"@pixi/filter-bulge-pinch": "^5.1.1",
|
| 25 |
+
"@pixi/filter-color-matrix": "^7.4.3",
|
| 26 |
+
"@pixi/sprite": "^7.4.3",
|
| 27 |
+
"@soundtouchjs/audio-worklet": "2.1.0",
|
| 28 |
+
"@tanstack/react-virtual": "^3.13.18",
|
| 29 |
+
"class-variance-authority": "^0.7.1",
|
| 30 |
+
"clsx": "^2.1.1",
|
| 31 |
+
"dayjs": "^1.11.19",
|
| 32 |
+
"howler": "^2.2.4",
|
| 33 |
+
"jss": "^10.10.0",
|
| 34 |
+
"jss-preset-default": "^10.10.0",
|
| 35 |
+
"lucide-react": "^0.563.0",
|
| 36 |
+
"motion": "^12.34.0",
|
| 37 |
+
"nanoid": "^5.1.6",
|
| 38 |
+
"radix-ui": "^1.4.3",
|
| 39 |
+
"react": "^19.2.0",
|
| 40 |
+
"react-dom": "^19.2.0",
|
| 41 |
+
"react-error-boundary": "^6.1.0",
|
| 42 |
+
"react-router-dom": "^7.13.0",
|
| 43 |
+
"sonner": "^2.0.7",
|
| 44 |
+
"tailwind-merge": "^3.4.0",
|
| 45 |
+
"vaul": "^1.1.2",
|
| 46 |
+
"zustand": "^5.0.11"
|
| 47 |
+
},
|
| 48 |
+
"devDependencies": {
|
| 49 |
+
"@eslint/js": "^9.39.1",
|
| 50 |
+
"@tailwindcss/vite": "^4.1.18",
|
| 51 |
+
"@types/howler": "^2.2.12",
|
| 52 |
+
"@types/node": "^24.10.1",
|
| 53 |
+
"@types/react": "^19.2.7",
|
| 54 |
+
"@types/react-dom": "^19.2.3",
|
| 55 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 56 |
+
"eslint": "^9.39.1",
|
| 57 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 58 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 59 |
+
"globals": "^16.5.0",
|
| 60 |
+
"shadcn": "^3.8.4",
|
| 61 |
+
"tailwindcss": "^4.1.18",
|
| 62 |
+
"tw-animate-css": "^1.4.0",
|
| 63 |
+
"typescript": "~5.9.3",
|
| 64 |
+
"typescript-eslint": "^8.48.0",
|
| 65 |
+
"vite": "^7.3.1",
|
| 66 |
+
"vite-plugin-wasm": "^3.5.0"
|
| 67 |
+
}
|
| 68 |
+
}
|
packages/client/public/logo.svg
ADDED
|
|
packages/client/src/App.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { lazy, Suspense } from 'react'
|
| 2 |
+
import { ErrorBoundary } from 'react-error-boundary'
|
| 3 |
+
import { TooltipProvider } from '@/components/ui/tooltip'
|
| 4 |
+
import { Button } from '@/components/ui/button'
|
| 5 |
+
import { SocketProvider } from '@/providers/SocketProvider'
|
| 6 |
+
import HomePage from '@/pages/HomePage'
|
| 7 |
+
import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
| 8 |
+
import { Toaster } from 'sonner'
|
| 9 |
+
import { AnimatePresence } from 'motion/react'
|
| 10 |
+
import { AlertTriangle } from 'lucide-react'
|
| 11 |
+
|
| 12 |
+
// Lazy-loaded routes (keep HomePage sync for fast first paint)
|
| 13 |
+
const RoomPage = lazy(() => import('@/pages/RoomPage'))
|
| 14 |
+
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
| 15 |
+
|
| 16 |
+
function ErrorFallback({ error, resetErrorBoundary }: { error: unknown; resetErrorBoundary: () => void }) {
|
| 17 |
+
const message = error instanceof Error ? error.message : '应用遇到了意外错误'
|
| 18 |
+
return (
|
| 19 |
+
<div className="flex min-h-screen items-center justify-center p-6">
|
| 20 |
+
<div className="flex max-w-md flex-col items-center gap-4 rounded-xl border bg-card p-8 text-center shadow-lg">
|
| 21 |
+
<AlertTriangle className="h-12 w-12 text-destructive" />
|
| 22 |
+
<h2 className="text-xl font-semibold">出了点问题</h2>
|
| 23 |
+
<p className="text-sm text-muted-foreground">{message}</p>
|
| 24 |
+
<Button onClick={resetErrorBoundary} variant="default">
|
| 25 |
+
重试
|
| 26 |
+
</Button>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function RouteFallback() {
|
| 33 |
+
return (
|
| 34 |
+
<div className="flex min-h-screen items-center justify-center">
|
| 35 |
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/** Route-level ErrorBoundary: navigates home on reset instead of full reload */
|
| 41 |
+
function RouteErrorBoundary({ children }: { children: React.ReactNode }) {
|
| 42 |
+
const navigate = useNavigate()
|
| 43 |
+
return (
|
| 44 |
+
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => navigate('/', { replace: true })}>
|
| 45 |
+
{children}
|
| 46 |
+
</ErrorBoundary>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AnimatedRoutes() {
|
| 51 |
+
const location = useLocation()
|
| 52 |
+
return (
|
| 53 |
+
<AnimatePresence mode="wait">
|
| 54 |
+
<Suspense fallback={<RouteFallback />}>
|
| 55 |
+
<Routes location={location} key={location.pathname}>
|
| 56 |
+
<Route
|
| 57 |
+
path="/"
|
| 58 |
+
element={
|
| 59 |
+
<RouteErrorBoundary>
|
| 60 |
+
<HomePage />
|
| 61 |
+
</RouteErrorBoundary>
|
| 62 |
+
}
|
| 63 |
+
/>
|
| 64 |
+
<Route
|
| 65 |
+
path="/room/:roomId"
|
| 66 |
+
element={
|
| 67 |
+
<RouteErrorBoundary>
|
| 68 |
+
<RoomPage />
|
| 69 |
+
</RouteErrorBoundary>
|
| 70 |
+
}
|
| 71 |
+
/>
|
| 72 |
+
<Route path="*" element={<NotFoundPage />} />
|
| 73 |
+
</Routes>
|
| 74 |
+
</Suspense>
|
| 75 |
+
</AnimatePresence>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export default function App() {
|
| 80 |
+
return (
|
| 81 |
+
<BrowserRouter>
|
| 82 |
+
<SocketProvider>
|
| 83 |
+
<TooltipProvider>
|
| 84 |
+
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => window.location.reload()}>
|
| 85 |
+
<AnimatedRoutes />
|
| 86 |
+
</ErrorBoundary>
|
| 87 |
+
<Toaster position="top-center" richColors />
|
| 88 |
+
</TooltipProvider>
|
| 89 |
+
</SocketProvider>
|
| 90 |
+
</BrowserRouter>
|
| 91 |
+
)
|
| 92 |
+
}
|
packages/client/src/components/Chat/ChatMessage.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react'
|
| 2 |
+
import dayjs from 'dayjs'
|
| 3 |
+
import { motion } from 'motion/react'
|
| 4 |
+
import type { ChatMessage as ChatMessageType } from '@music-together/shared'
|
| 5 |
+
import { cn } from '@/lib/utils'
|
| 6 |
+
|
| 7 |
+
interface ChatMessageProps {
|
| 8 |
+
message: ChatMessageType
|
| 9 |
+
isOwnMessage: boolean
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const ChatMessage = memo(function ChatMessage({ message, isOwnMessage }: ChatMessageProps) {
|
| 13 |
+
if (message.type === 'system') {
|
| 14 |
+
return (
|
| 15 |
+
<motion.div
|
| 16 |
+
initial={{ opacity: 0 }}
|
| 17 |
+
animate={{ opacity: 1 }}
|
| 18 |
+
className="py-1.5 text-center text-xs text-muted-foreground/50"
|
| 19 |
+
>
|
| 20 |
+
{message.content}
|
| 21 |
+
</motion.div>
|
| 22 |
+
)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<motion.div
|
| 27 |
+
initial={{ opacity: 0, y: 10, scale: 0.96 }}
|
| 28 |
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
| 29 |
+
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
| 30 |
+
className={cn('flex flex-col gap-0.5 py-1.5', isOwnMessage ? 'items-end' : 'items-start')}
|
| 31 |
+
>
|
| 32 |
+
<div className="flex items-center gap-2">
|
| 33 |
+
<span className="text-xs font-medium text-muted-foreground">{message.nickname}</span>
|
| 34 |
+
<span className="text-xs text-muted-foreground/40">{dayjs(message.timestamp).format('HH:mm')}</span>
|
| 35 |
+
</div>
|
| 36 |
+
<div
|
| 37 |
+
className={cn(
|
| 38 |
+
'max-w-[80%] rounded-xl px-3 py-2 text-sm break-words',
|
| 39 |
+
isOwnMessage ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground',
|
| 40 |
+
)}
|
| 41 |
+
>
|
| 42 |
+
{message.content}
|
| 43 |
+
</div>
|
| 44 |
+
</motion.div>
|
| 45 |
+
)
|
| 46 |
+
})
|
packages/client/src/components/Chat/ChatPanel.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
| 2 |
+
import { ArrowDown, MessageSquare, PanelRightClose, Send } from 'lucide-react'
|
| 3 |
+
import { Button } from '@/components/ui/button'
|
| 4 |
+
import { Input } from '@/components/ui/input'
|
| 5 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
| 6 |
+
import { ChatMessage } from './ChatMessage'
|
| 7 |
+
import { useChatStore } from '@/stores/chatStore'
|
| 8 |
+
import { useRoomStore } from '@/stores/roomStore'
|
| 9 |
+
import { useChat } from '@/hooks/useChat'
|
| 10 |
+
|
| 11 |
+
/** Threshold (px) to consider the user "at the bottom" of the scroll container */
|
| 12 |
+
const SCROLL_BOTTOM_THRESHOLD = 80
|
| 13 |
+
|
| 14 |
+
interface ChatPanelProps {
|
| 15 |
+
onCollapse?: () => void
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function ChatPanel({ onCollapse }: ChatPanelProps) {
|
| 19 |
+
const [input, setInput] = useState('')
|
| 20 |
+
const [showNewMsgHint, setShowNewMsgHint] = useState(false)
|
| 21 |
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
| 22 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
| 23 |
+
const isAtBottomRef = useRef(true)
|
| 24 |
+
const messages = useChatStore((s) => s.messages)
|
| 25 |
+
const currentUser = useRoomStore((s) => s.currentUser)
|
| 26 |
+
const { sendMessage } = useChat()
|
| 27 |
+
|
| 28 |
+
// Track whether the user has scrolled to the bottom
|
| 29 |
+
const handleScroll = useCallback(() => {
|
| 30 |
+
const el = scrollContainerRef.current
|
| 31 |
+
if (!el) return
|
| 32 |
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_BOTTOM_THRESHOLD
|
| 33 |
+
isAtBottomRef.current = atBottom
|
| 34 |
+
if (atBottom) setShowNewMsgHint(false)
|
| 35 |
+
}, [])
|
| 36 |
+
|
| 37 |
+
// Smart auto-scroll: only scroll to bottom if user was already at the bottom
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
const frame = requestAnimationFrame(() => {
|
| 40 |
+
if (isAtBottomRef.current) {
|
| 41 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 42 |
+
} else {
|
| 43 |
+
setShowNewMsgHint(true)
|
| 44 |
+
}
|
| 45 |
+
})
|
| 46 |
+
return () => cancelAnimationFrame(frame)
|
| 47 |
+
}, [messages])
|
| 48 |
+
|
| 49 |
+
const scrollToBottom = useCallback(() => {
|
| 50 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 51 |
+
setShowNewMsgHint(false)
|
| 52 |
+
}, [])
|
| 53 |
+
|
| 54 |
+
const handleSend = () => {
|
| 55 |
+
if (!input.trim()) return
|
| 56 |
+
sendMessage(input)
|
| 57 |
+
setInput('')
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="flex min-h-0 w-full flex-1 flex-col border-l border-border/50 bg-background/60 backdrop-blur-sm">
|
| 62 |
+
{/* Header */}
|
| 63 |
+
<div className="relative flex shrink-0 items-center gap-2 border-b border-border/50 px-4 py-3">
|
| 64 |
+
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
| 65 |
+
{onCollapse && (
|
| 66 |
+
<Tooltip>
|
| 67 |
+
<TooltipTrigger asChild>
|
| 68 |
+
<Button
|
| 69 |
+
type="button"
|
| 70 |
+
variant="ghost"
|
| 71 |
+
size="icon-xs"
|
| 72 |
+
className="absolute right-3"
|
| 73 |
+
onClick={onCollapse}
|
| 74 |
+
aria-label="收起聊天"
|
| 75 |
+
>
|
| 76 |
+
<PanelRightClose className="h-4 w-4" />
|
| 77 |
+
</Button>
|
| 78 |
+
</TooltipTrigger>
|
| 79 |
+
<TooltipContent>收起聊天</TooltipContent>
|
| 80 |
+
</Tooltip>
|
| 81 |
+
)}
|
| 82 |
+
<span className="text-sm font-medium">聊天</span>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Messages */}
|
| 86 |
+
<div className="relative min-h-0 flex-1">
|
| 87 |
+
<div
|
| 88 |
+
ref={scrollContainerRef}
|
| 89 |
+
onScroll={handleScroll}
|
| 90 |
+
className="absolute inset-0 overflow-y-auto px-3"
|
| 91 |
+
aria-live="polite"
|
| 92 |
+
aria-label="聊天消息"
|
| 93 |
+
>
|
| 94 |
+
<div className="py-3">
|
| 95 |
+
{messages.length === 0 ? (
|
| 96 |
+
<p className="text-center text-sm text-muted-foreground/30 py-8">还没有消息,开始聊天吧~</p>
|
| 97 |
+
) : (
|
| 98 |
+
messages.map((msg) => (
|
| 99 |
+
<ChatMessage key={msg.id} message={msg} isOwnMessage={msg.userId === currentUser?.id} />
|
| 100 |
+
))
|
| 101 |
+
)}
|
| 102 |
+
<div ref={messagesEndRef} />
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* New message hint */}
|
| 107 |
+
{showNewMsgHint && (
|
| 108 |
+
<Button
|
| 109 |
+
variant="secondary"
|
| 110 |
+
size="sm"
|
| 111 |
+
className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 gap-1 rounded-full shadow-md"
|
| 112 |
+
onClick={scrollToBottom}
|
| 113 |
+
>
|
| 114 |
+
<ArrowDown className="h-3.5 w-3.5" />
|
| 115 |
+
新消息
|
| 116 |
+
</Button>
|
| 117 |
+
)}
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* Input */}
|
| 121 |
+
<div className="flex shrink-0 gap-2 border-t border-border/50 px-3 py-3">
|
| 122 |
+
<Input
|
| 123 |
+
placeholder="输入消息..."
|
| 124 |
+
value={input}
|
| 125 |
+
onChange={(e) => setInput(e.target.value)}
|
| 126 |
+
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
| 127 |
+
className="flex-1"
|
| 128 |
+
aria-label="输入聊天消息"
|
| 129 |
+
/>
|
| 130 |
+
<Tooltip>
|
| 131 |
+
<TooltipTrigger asChild>
|
| 132 |
+
<Button size="icon" variant="secondary" onClick={handleSend} disabled={!input.trim()} aria-label="发送消息">
|
| 133 |
+
<Send className="h-4 w-4" />
|
| 134 |
+
</Button>
|
| 135 |
+
</TooltipTrigger>
|
| 136 |
+
<TooltipContent>发送</TooltipContent>
|
| 137 |
+
</Tooltip>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
)
|
| 141 |
+
}
|
packages/client/src/components/GlobalBackground.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState, type CSSProperties } from 'react'
|
| 2 |
+
import { EVENTS, type GlobalBackgroundSettings } from '@music-together/shared'
|
| 3 |
+
import { SERVER_URL } from '@/lib/config'
|
| 4 |
+
import { useSocketEvent } from '@/hooks/useSocketEvent'
|
| 5 |
+
import { useGlobalBackgroundStore } from '@/stores/globalBackgroundStore'
|
| 6 |
+
|
| 7 |
+
function resolveBackgroundUrl(backgroundUrl: string | null): string | undefined {
|
| 8 |
+
if (!backgroundUrl) return undefined
|
| 9 |
+
return backgroundUrl.startsWith('/uploads/') ? `${SERVER_URL}${backgroundUrl}` : backgroundUrl
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function GlobalBackground() {
|
| 13 |
+
const backgroundUrl = useGlobalBackgroundStore((state) => state.backgroundUrl)
|
| 14 |
+
const glassOverlay = useGlobalBackgroundStore((state) => state.glassOverlay)
|
| 15 |
+
const colorPreset = useGlobalBackgroundStore((state) => state.colorPreset)
|
| 16 |
+
const backgroundBrightness = useGlobalBackgroundStore((state) => state.backgroundBrightness)
|
| 17 |
+
const autoTint = useGlobalBackgroundStore((state) => state.autoTint)
|
| 18 |
+
const setBackgroundUrl = useGlobalBackgroundStore((state) => state.setBackgroundUrl)
|
| 19 |
+
const setGlassOverlay = useGlobalBackgroundStore((state) => state.setGlassOverlay)
|
| 20 |
+
const setColorPreset = useGlobalBackgroundStore((state) => state.setColorPreset)
|
| 21 |
+
const setBackgroundBrightness = useGlobalBackgroundStore((state) => state.setBackgroundBrightness)
|
| 22 |
+
const setAutoTint = useGlobalBackgroundStore((state) => state.setAutoTint)
|
| 23 |
+
const [tint, setTint] = useState<string | null>(null)
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
let cancelled = false
|
| 27 |
+
void fetch(`${SERVER_URL}/api/settings/background`, { credentials: 'include' })
|
| 28 |
+
.then(async (response) => {
|
| 29 |
+
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
| 30 |
+
return (await response.json()) as GlobalBackgroundSettings
|
| 31 |
+
})
|
| 32 |
+
.then((settings) => {
|
| 33 |
+
if (!cancelled) {
|
| 34 |
+
setBackgroundUrl(settings.backgroundUrl)
|
| 35 |
+
setGlassOverlay(settings.glassOverlay)
|
| 36 |
+
setColorPreset(settings.colorPreset)
|
| 37 |
+
setBackgroundBrightness(settings.backgroundBrightness)
|
| 38 |
+
setAutoTint(settings.autoTint)
|
| 39 |
+
}
|
| 40 |
+
})
|
| 41 |
+
.catch(() => {
|
| 42 |
+
// The default theme remains usable if a server is temporarily unavailable.
|
| 43 |
+
})
|
| 44 |
+
return () => {
|
| 45 |
+
cancelled = true
|
| 46 |
+
}
|
| 47 |
+
}, [setBackgroundUrl, setGlassOverlay, setColorPreset, setBackgroundBrightness, setAutoTint])
|
| 48 |
+
|
| 49 |
+
useSocketEvent(EVENTS.SERVER_GLOBAL_BACKGROUND, (settings) => {
|
| 50 |
+
setBackgroundUrl(settings.backgroundUrl)
|
| 51 |
+
setGlassOverlay(settings.glassOverlay)
|
| 52 |
+
setColorPreset(settings.colorPreset)
|
| 53 |
+
setBackgroundBrightness(settings.backgroundBrightness)
|
| 54 |
+
setAutoTint(settings.autoTint)
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
document.documentElement.dataset.colorPreset = colorPreset
|
| 59 |
+
}, [colorPreset])
|
| 60 |
+
|
| 61 |
+
const resolvedUrl = resolveBackgroundUrl(backgroundUrl)
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
if (!autoTint || !resolvedUrl) {
|
| 65 |
+
setTint(null)
|
| 66 |
+
return
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
let cancelled = false
|
| 70 |
+
const image = new Image()
|
| 71 |
+
image.crossOrigin = 'anonymous'
|
| 72 |
+
image.onload = () => {
|
| 73 |
+
try {
|
| 74 |
+
const canvas = document.createElement('canvas')
|
| 75 |
+
canvas.width = 32
|
| 76 |
+
canvas.height = 32
|
| 77 |
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
| 78 |
+
if (!context) return
|
| 79 |
+
context.drawImage(image, 0, 0, 32, 32)
|
| 80 |
+
const pixels = context.getImageData(0, 0, 32, 32).data
|
| 81 |
+
let red = 0
|
| 82 |
+
let green = 0
|
| 83 |
+
let blue = 0
|
| 84 |
+
let count = 0
|
| 85 |
+
for (let index = 0; index < pixels.length; index += 4) {
|
| 86 |
+
if (pixels[index + 3] < 32) continue
|
| 87 |
+
red += pixels[index]!
|
| 88 |
+
green += pixels[index + 1]!
|
| 89 |
+
blue += pixels[index + 2]!
|
| 90 |
+
count += 1
|
| 91 |
+
}
|
| 92 |
+
if (!cancelled && count > 0) {
|
| 93 |
+
setTint(`rgb(${Math.round(red / count)} ${Math.round(green / count)} ${Math.round(blue / count)})`)
|
| 94 |
+
}
|
| 95 |
+
} catch {
|
| 96 |
+
if (!cancelled) setTint(null)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
image.onerror = () => {
|
| 100 |
+
if (!cancelled) setTint(null)
|
| 101 |
+
}
|
| 102 |
+
image.src = resolvedUrl
|
| 103 |
+
return () => {
|
| 104 |
+
cancelled = true
|
| 105 |
+
}
|
| 106 |
+
}, [autoTint, resolvedUrl])
|
| 107 |
+
|
| 108 |
+
if (!resolvedUrl) return null
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div
|
| 112 |
+
className={glassOverlay ? 'mt-global-background mt-global-background-glass' : 'mt-global-background'}
|
| 113 |
+
aria-hidden="true"
|
| 114 |
+
style={{
|
| 115 |
+
backgroundImage: `url("${resolvedUrl}")`,
|
| 116 |
+
'--mt-background-brightness': backgroundBrightness / 100,
|
| 117 |
+
'--mt-auto-tint': tint ?? 'transparent',
|
| 118 |
+
} as CSSProperties}
|
| 119 |
+
/>
|
| 120 |
+
)
|
| 121 |
+
}
|
packages/client/src/components/InteractionGate.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion, useReducedMotion } from 'motion/react'
|
| 2 |
+
import { Headphones, Lock, UserRound } from 'lucide-react'
|
| 3 |
+
import { LIMITS } from '@music-together/shared'
|
| 4 |
+
import { Button } from '@/components/ui/button'
|
| 5 |
+
import { Input } from '@/components/ui/input'
|
| 6 |
+
import { Label } from '@/components/ui/label'
|
| 7 |
+
import { useState } from 'react'
|
| 8 |
+
import { storage } from '@/lib/storage'
|
| 9 |
+
|
| 10 |
+
interface InteractionGateProps {
|
| 11 |
+
onStart: (password?: string) => void
|
| 12 |
+
roomName?: string
|
| 13 |
+
hasPassword?: boolean
|
| 14 |
+
passwordError?: string | null
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function InteractionGate({ onStart, roomName, hasPassword, passwordError }: InteractionGateProps) {
|
| 18 |
+
const prefersReducedMotion = useReducedMotion()
|
| 19 |
+
const [password, setPassword] = useState('')
|
| 20 |
+
|
| 21 |
+
const savedNickname = storage.getNickname()
|
| 22 |
+
const needsNickname = !savedNickname
|
| 23 |
+
const [nickname, setNickname] = useState(savedNickname)
|
| 24 |
+
|
| 25 |
+
const canStart = (!needsNickname || nickname.trim().length > 0) && (!hasPassword || password.trim().length > 0)
|
| 26 |
+
|
| 27 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 28 |
+
e.preventDefault()
|
| 29 |
+
if (!canStart) return
|
| 30 |
+
if (needsNickname) {
|
| 31 |
+
storage.setNickname(nickname.trim())
|
| 32 |
+
}
|
| 33 |
+
onStart(hasPassword ? password.trim() : undefined)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background px-4">
|
| 38 |
+
<motion.div
|
| 39 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
| 40 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 41 |
+
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
| 42 |
+
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-2xl border border-border bg-card p-10 shadow-lg"
|
| 43 |
+
>
|
| 44 |
+
<motion.div
|
| 45 |
+
animate={prefersReducedMotion ? {} : { rotate: [0, 5, -5, 0] }}
|
| 46 |
+
transition={prefersReducedMotion ? {} : { duration: 4, repeat: Infinity, ease: 'easeInOut' }}
|
| 47 |
+
>
|
| 48 |
+
<Headphones className="h-16 w-16 text-primary" />
|
| 49 |
+
</motion.div>
|
| 50 |
+
|
| 51 |
+
<div className="flex flex-col items-center gap-1.5">
|
| 52 |
+
<h2 className="text-xl font-semibold">准备就绪</h2>
|
| 53 |
+
{roomName ? (
|
| 54 |
+
<p className="text-sm text-muted-foreground">即将加入「{roomName}」</p>
|
| 55 |
+
) : (
|
| 56 |
+
<p className="text-sm text-muted-foreground">点击开始,与房间好友一起听歌</p>
|
| 57 |
+
)}
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-4">
|
| 61 |
+
{needsNickname && (
|
| 62 |
+
<div className="flex flex-col gap-2">
|
| 63 |
+
<Label htmlFor="gate-nickname" className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
| 64 |
+
<UserRound className="h-3.5 w-3.5" />
|
| 65 |
+
<span>设置你的昵称</span>
|
| 66 |
+
</Label>
|
| 67 |
+
<Input
|
| 68 |
+
id="gate-nickname"
|
| 69 |
+
placeholder="你的昵称..."
|
| 70 |
+
value={nickname}
|
| 71 |
+
onChange={(e) => setNickname(e.target.value)}
|
| 72 |
+
maxLength={LIMITS.NICKNAME_MAX_LENGTH}
|
| 73 |
+
autoFocus
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
)}
|
| 77 |
+
|
| 78 |
+
{hasPassword && (
|
| 79 |
+
<div className="flex flex-col gap-2">
|
| 80 |
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
| 81 |
+
<Lock className="h-3.5 w-3.5" />
|
| 82 |
+
<span>该房间需要密码</span>
|
| 83 |
+
</div>
|
| 84 |
+
<motion.div animate={passwordError ? { x: [-8, 8, -6, 6, -3, 3, 0] } : {}} transition={{ duration: 0.5 }}>
|
| 85 |
+
<Input
|
| 86 |
+
type="password"
|
| 87 |
+
placeholder="输入房间密码..."
|
| 88 |
+
value={password}
|
| 89 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 90 |
+
autoFocus={!needsNickname}
|
| 91 |
+
className={passwordError ? 'border-destructive' : ''}
|
| 92 |
+
/>
|
| 93 |
+
{passwordError && (
|
| 94 |
+
<motion.p
|
| 95 |
+
initial={{ opacity: 0, y: -4 }}
|
| 96 |
+
animate={{ opacity: 1, y: 0 }}
|
| 97 |
+
className="mt-1.5 text-xs text-destructive"
|
| 98 |
+
>
|
| 99 |
+
{passwordError}
|
| 100 |
+
</motion.p>
|
| 101 |
+
)}
|
| 102 |
+
</motion.div>
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
|
| 106 |
+
<Button type="submit" size="lg" className="w-full" disabled={!canStart} aria-label="开始收听">
|
| 107 |
+
开始收听
|
| 108 |
+
</Button>
|
| 109 |
+
</form>
|
| 110 |
+
</motion.div>
|
| 111 |
+
</div>
|
| 112 |
+
)
|
| 113 |
+
}
|
packages/client/src/components/Lobby/ActionCards.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from '@/components/ui/button'
|
| 2 |
+
import { Input } from '@/components/ui/input'
|
| 3 |
+
import { Home, LogIn } from 'lucide-react'
|
| 4 |
+
import { motion } from 'motion/react'
|
| 5 |
+
|
| 6 |
+
interface ActionCardsProps {
|
| 7 |
+
directRoomId: string
|
| 8 |
+
onDirectRoomIdChange: (value: string) => void
|
| 9 |
+
onCreateClick: () => void
|
| 10 |
+
onDirectJoin: () => void
|
| 11 |
+
actionLoading: boolean
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function ActionCards({
|
| 15 |
+
directRoomId,
|
| 16 |
+
onDirectRoomIdChange,
|
| 17 |
+
onCreateClick,
|
| 18 |
+
onDirectJoin,
|
| 19 |
+
actionLoading,
|
| 20 |
+
}: ActionCardsProps) {
|
| 21 |
+
return (
|
| 22 |
+
<motion.div
|
| 23 |
+
initial={{ opacity: 0, y: 10 }}
|
| 24 |
+
animate={{ opacity: 1, y: 0 }}
|
| 25 |
+
transition={{ duration: 0.4, delay: 0.15 }}
|
| 26 |
+
className="mb-10 grid gap-4 sm:grid-cols-2"
|
| 27 |
+
>
|
| 28 |
+
{/* Create room card */}
|
| 29 |
+
<div className="mt-card flex flex-col justify-between rounded-2xl p-5">
|
| 30 |
+
<div className="mb-4">
|
| 31 |
+
<div className="mb-2 flex items-center gap-2">
|
| 32 |
+
<div className="mt-icon-box h-9 w-9">
|
| 33 |
+
<Home className="h-4 w-4 text-primary" />
|
| 34 |
+
</div>
|
| 35 |
+
<h2 className="text-base font-semibold text-foreground">创建房间</h2>
|
| 36 |
+
</div>
|
| 37 |
+
<p className="text-xs text-muted-foreground">新建一个房间,分享房间号邀请朋友加入</p>
|
| 38 |
+
</div>
|
| 39 |
+
<Button onClick={onCreateClick} className="w-full">
|
| 40 |
+
<Home className="mr-2 h-4 w-4" />
|
| 41 |
+
创建房间
|
| 42 |
+
</Button>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
{/* Join room card */}
|
| 46 |
+
<div className="mt-card flex flex-col justify-between rounded-2xl p-5">
|
| 47 |
+
<div className="mb-4">
|
| 48 |
+
<div className="mb-2 flex items-center gap-2">
|
| 49 |
+
<div className="mt-icon-box h-9 w-9">
|
| 50 |
+
<LogIn className="h-4 w-4 text-primary" />
|
| 51 |
+
</div>
|
| 52 |
+
<h2 className="text-base font-semibold text-foreground">加入房间</h2>
|
| 53 |
+
</div>
|
| 54 |
+
<p className="text-xs text-muted-foreground">输入房间号直接加入已有房间</p>
|
| 55 |
+
</div>
|
| 56 |
+
<div className="flex gap-2">
|
| 57 |
+
<Input
|
| 58 |
+
placeholder="输入房间号..."
|
| 59 |
+
value={directRoomId}
|
| 60 |
+
onChange={(e) => onDirectRoomIdChange(e.target.value)}
|
| 61 |
+
onKeyDown={(e) => e.key === 'Enter' && onDirectJoin()}
|
| 62 |
+
className="flex-1"
|
| 63 |
+
/>
|
| 64 |
+
<Button variant="secondary" onClick={onDirectJoin} disabled={actionLoading}>
|
| 65 |
+
加入
|
| 66 |
+
</Button>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</motion.div>
|
| 70 |
+
)
|
| 71 |
+
}
|
packages/client/src/components/Lobby/CreateRoomDialog.tsx
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Lock, Music, Loader2 } from 'lucide-react'
|
| 3 |
+
import { LIMITS } from '@music-together/shared'
|
| 4 |
+
import {
|
| 5 |
+
ResponsiveDialog,
|
| 6 |
+
ResponsiveDialogBody,
|
| 7 |
+
ResponsiveDialogContent,
|
| 8 |
+
ResponsiveDialogHeader,
|
| 9 |
+
ResponsiveDialogTitle,
|
| 10 |
+
} from '@/components/ui/responsive-dialog'
|
| 11 |
+
import { Input } from '@/components/ui/input'
|
| 12 |
+
import { Button } from '@/components/ui/button'
|
| 13 |
+
import { Switch } from '@/components/ui/switch'
|
| 14 |
+
import { Label } from '@/components/ui/label'
|
| 15 |
+
|
| 16 |
+
interface CreateRoomDialogProps {
|
| 17 |
+
open: boolean
|
| 18 |
+
onOpenChange: (open: boolean) => void
|
| 19 |
+
onCreateRoom: (nickname: string, roomName?: string, password?: string) => void
|
| 20 |
+
defaultNickname: string
|
| 21 |
+
isLoading: boolean
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function CreateRoomDialog({
|
| 25 |
+
open,
|
| 26 |
+
onOpenChange,
|
| 27 |
+
onCreateRoom,
|
| 28 |
+
defaultNickname,
|
| 29 |
+
isLoading,
|
| 30 |
+
}: CreateRoomDialogProps) {
|
| 31 |
+
const [nickname, setNickname] = useState(defaultNickname)
|
| 32 |
+
const [roomName, setRoomName] = useState('')
|
| 33 |
+
const [passwordEnabled, setPasswordEnabled] = useState(false)
|
| 34 |
+
const [password, setPassword] = useState('')
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!open) return
|
| 38 |
+
const frame = requestAnimationFrame(() => setNickname(defaultNickname))
|
| 39 |
+
return () => cancelAnimationFrame(frame)
|
| 40 |
+
}, [open, defaultNickname])
|
| 41 |
+
|
| 42 |
+
const canSubmit = nickname.trim() && !(passwordEnabled && !password.trim())
|
| 43 |
+
|
| 44 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 45 |
+
e.preventDefault()
|
| 46 |
+
if (!canSubmit) return
|
| 47 |
+
onCreateRoom(
|
| 48 |
+
nickname.trim(),
|
| 49 |
+
roomName.trim() || undefined,
|
| 50 |
+
passwordEnabled && password.trim() ? password.trim() : undefined,
|
| 51 |
+
)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
| 56 |
+
<ResponsiveDialogContent className="sm:max-w-md">
|
| 57 |
+
<ResponsiveDialogHeader>
|
| 58 |
+
<ResponsiveDialogTitle className="flex items-center gap-2 text-lg">
|
| 59 |
+
<Music className="h-5 w-5 text-primary" />
|
| 60 |
+
创建房间
|
| 61 |
+
</ResponsiveDialogTitle>
|
| 62 |
+
</ResponsiveDialogHeader>
|
| 63 |
+
|
| 64 |
+
<ResponsiveDialogBody>
|
| 65 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 66 |
+
<div className="space-y-2">
|
| 67 |
+
<Label className="text-sm font-medium text-foreground/80">昵称</Label>
|
| 68 |
+
<Input
|
| 69 |
+
placeholder="你的昵称..."
|
| 70 |
+
value={nickname}
|
| 71 |
+
onChange={(e) => setNickname(e.target.value)}
|
| 72 |
+
maxLength={LIMITS.NICKNAME_MAX_LENGTH}
|
| 73 |
+
autoFocus
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div className="space-y-2">
|
| 78 |
+
<Label className="text-sm font-medium text-foreground/80">房间名 (可选)</Label>
|
| 79 |
+
<Input
|
| 80 |
+
placeholder="给房间起个名字..."
|
| 81 |
+
value={roomName}
|
| 82 |
+
onChange={(e) => setRoomName(e.target.value)}
|
| 83 |
+
maxLength={LIMITS.ROOM_NAME_MAX_LENGTH}
|
| 84 |
+
/>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="space-y-3">
|
| 88 |
+
<div className="flex items-center gap-2">
|
| 89 |
+
<Switch id="password-toggle" checked={passwordEnabled} onCheckedChange={setPasswordEnabled} />
|
| 90 |
+
<Label
|
| 91 |
+
htmlFor="password-toggle"
|
| 92 |
+
className="flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground"
|
| 93 |
+
>
|
| 94 |
+
<Lock className="h-3.5 w-3.5" />
|
| 95 |
+
设置密码
|
| 96 |
+
</Label>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{passwordEnabled && (
|
| 100 |
+
<Input
|
| 101 |
+
type="password"
|
| 102 |
+
placeholder="设置房间密码..."
|
| 103 |
+
value={password}
|
| 104 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 105 |
+
maxLength={LIMITS.ROOM_PASSWORD_MAX_LENGTH}
|
| 106 |
+
autoFocus
|
| 107 |
+
/>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<Button type="submit" className="w-full" size="lg" disabled={isLoading || !canSubmit}>
|
| 112 |
+
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
| 113 |
+
创建房间
|
| 114 |
+
</Button>
|
| 115 |
+
</form>
|
| 116 |
+
</ResponsiveDialogBody>
|
| 117 |
+
</ResponsiveDialogContent>
|
| 118 |
+
</ResponsiveDialog>
|
| 119 |
+
)
|
| 120 |
+
}
|
packages/client/src/components/Lobby/HeroSection.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from 'motion/react'
|
| 2 |
+
|
| 3 |
+
export function HeroSection() {
|
| 4 |
+
return (
|
| 5 |
+
<motion.div
|
| 6 |
+
initial={{ opacity: 0, y: -12 }}
|
| 7 |
+
animate={{ opacity: 1, y: 0 }}
|
| 8 |
+
transition={{ duration: 0.5 }}
|
| 9 |
+
className="mt-hero mb-8 px-6 text-center"
|
| 10 |
+
>
|
| 11 |
+
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
| 12 |
+
<span className="flex items-end gap-0.5" aria-hidden="true">
|
| 13 |
+
<i className="h-1 w-0.5 rounded-full bg-current" />
|
| 14 |
+
<i className="h-2.5 w-0.5 rounded-full bg-current" />
|
| 15 |
+
<i className="h-1.5 w-0.5 rounded-full bg-current" />
|
| 16 |
+
</span>
|
| 17 |
+
实时同步音乐播放
|
| 18 |
+
</div>
|
| 19 |
+
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">和朋友一起听歌</h1>
|
| 20 |
+
<p className="mt-2 text-sm text-muted-foreground">创建或加入一个房间,实时同步音乐播放</p>
|
| 21 |
+
</motion.div>
|
| 22 |
+
)
|
| 23 |
+
}
|
packages/client/src/components/Lobby/NicknameDialog.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { UserRound } from 'lucide-react'
|
| 3 |
+
import { LIMITS } from '@music-together/shared'
|
| 4 |
+
import {
|
| 5 |
+
ResponsiveDialog,
|
| 6 |
+
ResponsiveDialogBody,
|
| 7 |
+
ResponsiveDialogContent,
|
| 8 |
+
ResponsiveDialogDescription,
|
| 9 |
+
ResponsiveDialogHeader,
|
| 10 |
+
ResponsiveDialogTitle,
|
| 11 |
+
} from '@/components/ui/responsive-dialog'
|
| 12 |
+
import { Input } from '@/components/ui/input'
|
| 13 |
+
import { Label } from '@/components/ui/label'
|
| 14 |
+
import { Button } from '@/components/ui/button'
|
| 15 |
+
import { storage } from '@/lib/storage'
|
| 16 |
+
|
| 17 |
+
interface NicknameDialogProps {
|
| 18 |
+
open: boolean
|
| 19 |
+
onOpenChange: (open: boolean) => void
|
| 20 |
+
onConfirm: (nickname: string) => void
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function NicknameDialog({ open, onOpenChange, onConfirm }: NicknameDialogProps) {
|
| 24 |
+
const [nickname, setNickname] = useState(storage.getNickname())
|
| 25 |
+
|
| 26 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 27 |
+
e.preventDefault()
|
| 28 |
+
const trimmed = nickname.trim()
|
| 29 |
+
if (!trimmed) return
|
| 30 |
+
storage.setNickname(trimmed)
|
| 31 |
+
onConfirm(trimmed)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Sync from storage every time the dialog opens
|
| 35 |
+
const handleOpenChange = (nextOpen: boolean) => {
|
| 36 |
+
if (nextOpen) {
|
| 37 |
+
setNickname(storage.getNickname())
|
| 38 |
+
}
|
| 39 |
+
onOpenChange(nextOpen)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
|
| 44 |
+
<ResponsiveDialogContent className="sm:max-w-sm">
|
| 45 |
+
<ResponsiveDialogHeader>
|
| 46 |
+
<ResponsiveDialogTitle className="flex items-center gap-2 text-lg">
|
| 47 |
+
<UserRound className="h-5 w-5 text-primary" />
|
| 48 |
+
设置昵称
|
| 49 |
+
</ResponsiveDialogTitle>
|
| 50 |
+
<ResponsiveDialogDescription>加入房间前,请先设置你的昵称</ResponsiveDialogDescription>
|
| 51 |
+
</ResponsiveDialogHeader>
|
| 52 |
+
|
| 53 |
+
<ResponsiveDialogBody>
|
| 54 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 55 |
+
<div className="space-y-2">
|
| 56 |
+
<Label htmlFor="nickname-input">昵称</Label>
|
| 57 |
+
<Input
|
| 58 |
+
id="nickname-input"
|
| 59 |
+
placeholder="你的昵称..."
|
| 60 |
+
value={nickname}
|
| 61 |
+
onChange={(e) => setNickname(e.target.value)}
|
| 62 |
+
maxLength={LIMITS.NICKNAME_MAX_LENGTH}
|
| 63 |
+
autoFocus
|
| 64 |
+
/>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<Button type="submit" className="w-full" disabled={!nickname.trim()}>
|
| 68 |
+
确认并加入
|
| 69 |
+
</Button>
|
| 70 |
+
</form>
|
| 71 |
+
</ResponsiveDialogBody>
|
| 72 |
+
</ResponsiveDialogContent>
|
| 73 |
+
</ResponsiveDialog>
|
| 74 |
+
)
|
| 75 |
+
}
|
packages/client/src/components/Lobby/PasswordDialog.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from '@/components/ui/button'
|
| 2 |
+
import { Input } from '@/components/ui/input'
|
| 3 |
+
import {
|
| 4 |
+
ResponsiveDialog,
|
| 5 |
+
ResponsiveDialogBody,
|
| 6 |
+
ResponsiveDialogContent,
|
| 7 |
+
ResponsiveDialogHeader,
|
| 8 |
+
ResponsiveDialogTitle,
|
| 9 |
+
} from '@/components/ui/responsive-dialog'
|
| 10 |
+
import { Loader2, Lock } from 'lucide-react'
|
| 11 |
+
import { motion } from 'motion/react'
|
| 12 |
+
import { useState } from 'react'
|
| 13 |
+
|
| 14 |
+
interface PasswordDialogProps {
|
| 15 |
+
open: boolean
|
| 16 |
+
onOpenChange: (open: boolean) => void
|
| 17 |
+
roomName: string
|
| 18 |
+
onSubmit: (password: string) => void
|
| 19 |
+
error: string | null
|
| 20 |
+
isLoading: boolean
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function PasswordDialog({ open, onOpenChange, roomName, onSubmit, error, isLoading }: PasswordDialogProps) {
|
| 24 |
+
const [password, setPassword] = useState('')
|
| 25 |
+
|
| 26 |
+
// Reset password when dialog opens
|
| 27 |
+
const handleOpenChange = (nextOpen: boolean) => {
|
| 28 |
+
if (nextOpen) setPassword('')
|
| 29 |
+
onOpenChange(nextOpen)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 33 |
+
e.preventDefault()
|
| 34 |
+
if (!password.trim()) return
|
| 35 |
+
onSubmit(password.trim())
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
|
| 40 |
+
<ResponsiveDialogContent className="sm:max-w-sm">
|
| 41 |
+
<ResponsiveDialogHeader>
|
| 42 |
+
<ResponsiveDialogTitle className="flex items-center gap-2 text-lg">
|
| 43 |
+
<Lock className="h-5 w-5 text-muted-foreground" />
|
| 44 |
+
需要密码
|
| 45 |
+
</ResponsiveDialogTitle>
|
| 46 |
+
</ResponsiveDialogHeader>
|
| 47 |
+
|
| 48 |
+
<ResponsiveDialogBody className="space-y-4">
|
| 49 |
+
<p className="text-sm text-muted-foreground">房间「{roomName}」已设置密码保护</p>
|
| 50 |
+
|
| 51 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 52 |
+
<motion.div animate={error ? { x: [-8, 8, -6, 6, -3, 3, 0] } : {}} transition={{ duration: 0.5 }}>
|
| 53 |
+
<Input
|
| 54 |
+
type="password"
|
| 55 |
+
placeholder="输入房间密码..."
|
| 56 |
+
value={password}
|
| 57 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 58 |
+
autoFocus
|
| 59 |
+
className={error ? 'border-destructive' : ''}
|
| 60 |
+
/>
|
| 61 |
+
{error && (
|
| 62 |
+
<motion.p
|
| 63 |
+
initial={{ opacity: 0, y: -4 }}
|
| 64 |
+
animate={{ opacity: 1, y: 0 }}
|
| 65 |
+
className="mt-1.5 text-xs text-destructive"
|
| 66 |
+
>
|
| 67 |
+
{error}
|
| 68 |
+
</motion.p>
|
| 69 |
+
)}
|
| 70 |
+
</motion.div>
|
| 71 |
+
|
| 72 |
+
<Button type="submit" className="w-full" disabled={isLoading || !password.trim()}>
|
| 73 |
+
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
| 74 |
+
加入房间
|
| 75 |
+
</Button>
|
| 76 |
+
</form>
|
| 77 |
+
</ResponsiveDialogBody>
|
| 78 |
+
</ResponsiveDialogContent>
|
| 79 |
+
</ResponsiveDialog>
|
| 80 |
+
)
|
| 81 |
+
}
|
packages/client/src/components/Lobby/RoomCard.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react'
|
| 2 |
+
import { Lock, LockOpen, Music, Pin, Users } from 'lucide-react'
|
| 3 |
+
import { motion, useReducedMotion } from 'motion/react'
|
| 4 |
+
import type { RoomListItem } from '@music-together/shared'
|
| 5 |
+
import { cn } from '@/lib/utils'
|
| 6 |
+
|
| 7 |
+
interface RoomCardProps {
|
| 8 |
+
room: RoomListItem
|
| 9 |
+
index: number
|
| 10 |
+
onClick: () => void
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const RoomCard = memo(function RoomCard({ room, index, onClick }: RoomCardProps) {
|
| 14 |
+
const prefersReducedMotion = useReducedMotion()
|
| 15 |
+
return (
|
| 16 |
+
<motion.button
|
| 17 |
+
initial={{ opacity: 0, y: 20 }}
|
| 18 |
+
animate={{ opacity: 1, y: 0 }}
|
| 19 |
+
transition={{ duration: 0.4, delay: index * 0.06, ease: [0.25, 0.46, 0.45, 0.94] }}
|
| 20 |
+
whileHover={{ y: -4, scale: 1.01 }}
|
| 21 |
+
whileTap={{ scale: 0.98 }}
|
| 22 |
+
onClick={onClick}
|
| 23 |
+
className={cn(
|
| 24 |
+
'group relative flex w-full flex-col gap-3 overflow-hidden rounded-xl p-5 text-left',
|
| 25 |
+
'mt-card rounded-2xl transition-all duration-300',
|
| 26 |
+
'hover:-translate-y-1 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5',
|
| 27 |
+
)}
|
| 28 |
+
>
|
| 29 |
+
{/* Room name row */}
|
| 30 |
+
<div className="flex items-start justify-between">
|
| 31 |
+
<div className="min-w-0 flex-1">
|
| 32 |
+
<h3 className="truncate text-base font-semibold text-foreground">{room.name}</h3>
|
| 33 |
+
{room.currentTrackTitle && (
|
| 34 |
+
<p className="mt-1 flex items-center gap-1.5 truncate text-xs text-muted-foreground">
|
| 35 |
+
<Music className="h-3 w-3 shrink-0 text-primary" />
|
| 36 |
+
<span className="truncate">
|
| 37 |
+
{room.currentTrackTitle}
|
| 38 |
+
{room.currentTrackArtist && ` - ${room.currentTrackArtist}`}
|
| 39 |
+
</span>
|
| 40 |
+
</p>
|
| 41 |
+
)}
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{/* Right side: playing animation + lock icon */}
|
| 45 |
+
<div className="ml-3 flex shrink-0 items-center gap-2">
|
| 46 |
+
{room.permanent && <Pin className="h-4 w-4 text-primary/70" aria-label="永久房间" />}
|
| 47 |
+
{/* Subtle playing animation for rooms with active tracks */}
|
| 48 |
+
{room.currentTrackTitle && (
|
| 49 |
+
<div className="flex items-end gap-0.5">
|
| 50 |
+
{[0, 1, 2].map((i) => (
|
| 51 |
+
<motion.div
|
| 52 |
+
key={i}
|
| 53 |
+
className="w-0.5 rounded-full bg-primary/40"
|
| 54 |
+
animate={prefersReducedMotion ? { height: 8 } : { height: [4, 12, 6, 10, 4] }}
|
| 55 |
+
transition={
|
| 56 |
+
prefersReducedMotion
|
| 57 |
+
? {}
|
| 58 |
+
: {
|
| 59 |
+
duration: 1.2,
|
| 60 |
+
repeat: Infinity,
|
| 61 |
+
delay: i * 0.15,
|
| 62 |
+
ease: 'easeInOut',
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
/>
|
| 66 |
+
))}
|
| 67 |
+
</div>
|
| 68 |
+
)}
|
| 69 |
+
{room.hasPassword ? (
|
| 70 |
+
<Lock className="h-4 w-4 text-muted-foreground/60" />
|
| 71 |
+
) : (
|
| 72 |
+
<LockOpen className="h-4 w-4 text-muted-foreground/40" />
|
| 73 |
+
)}
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Bottom info */}
|
| 78 |
+
<div className="flex items-center gap-3">
|
| 79 |
+
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
| 80 |
+
<Users className="h-3 w-3" />
|
| 81 |
+
{room.userCount}
|
| 82 |
+
</span>
|
| 83 |
+
<span className="text-xs text-muted-foreground/50">{room.id}</span>
|
| 84 |
+
</div>
|
| 85 |
+
</motion.button>
|
| 86 |
+
)
|
| 87 |
+
})
|
packages/client/src/components/Lobby/RoomListSection.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Music } from 'lucide-react'
|
| 2 |
+
import { motion } from 'motion/react'
|
| 3 |
+
import type { RoomListItem } from '@music-together/shared'
|
| 4 |
+
import { RoomCard } from './RoomCard'
|
| 5 |
+
import { Skeleton } from '@/components/ui/skeleton'
|
| 6 |
+
|
| 7 |
+
interface RoomListSectionProps {
|
| 8 |
+
rooms: RoomListItem[]
|
| 9 |
+
isLoading: boolean
|
| 10 |
+
onRoomClick: (room: RoomListItem) => void
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function RoomListSection({ rooms, isLoading, onRoomClick }: RoomListSectionProps) {
|
| 14 |
+
return (
|
| 15 |
+
<>
|
| 16 |
+
<div className="mb-4">
|
| 17 |
+
<h2 className="text-lg font-semibold text-foreground/80">
|
| 18 |
+
活跃房间
|
| 19 |
+
{!isLoading && rooms.length > 0 && (
|
| 20 |
+
<span className="ml-2 text-sm font-normal text-muted-foreground">({rooms.length})</span>
|
| 21 |
+
)}
|
| 22 |
+
</h2>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
{isLoading ? (
|
| 26 |
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
| 27 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
| 28 |
+
<div key={i} className="mt-card flex flex-col gap-3 rounded-2xl p-5">
|
| 29 |
+
<div className="space-y-2">
|
| 30 |
+
<Skeleton className="h-5 w-2/3" />
|
| 31 |
+
<Skeleton className="h-3 w-1/2" />
|
| 32 |
+
</div>
|
| 33 |
+
<div className="flex items-center gap-3">
|
| 34 |
+
<Skeleton className="h-3 w-12" />
|
| 35 |
+
<Skeleton className="h-3 w-16" />
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
))}
|
| 39 |
+
</div>
|
| 40 |
+
) : rooms.length === 0 ? (
|
| 41 |
+
<motion.div
|
| 42 |
+
initial={{ opacity: 0 }}
|
| 43 |
+
animate={{ opacity: 1 }}
|
| 44 |
+
transition={{ delay: 0.25 }}
|
| 45 |
+
className="mt-card flex flex-col items-center gap-4 rounded-2xl border-dashed bg-card/50 px-8 py-16 text-center"
|
| 46 |
+
>
|
| 47 |
+
<Music className="h-10 w-10 text-muted-foreground/25" />
|
| 48 |
+
<div>
|
| 49 |
+
<p className="text-base font-medium text-foreground/60">还没有活跃的房间</p>
|
| 50 |
+
<p className="mt-1 text-sm text-muted-foreground">创建一个房间,邀请朋友一起听歌</p>
|
| 51 |
+
</div>
|
| 52 |
+
</motion.div>
|
| 53 |
+
) : (
|
| 54 |
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
| 55 |
+
{rooms.map((room, i) => (
|
| 56 |
+
<RoomCard key={room.id} room={room} index={i} onClick={() => onRoomClick(room)} />
|
| 57 |
+
))}
|
| 58 |
+
</div>
|
| 59 |
+
)}
|
| 60 |
+
</>
|
| 61 |
+
)
|
| 62 |
+
}
|
packages/client/src/components/Lobby/UserPopover.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Camera, CircleUser, Loader2 } from 'lucide-react'
|
| 3 |
+
import { LIMITS } from '@music-together/shared'
|
| 4 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
| 5 |
+
import { Badge } from '@/components/ui/badge'
|
| 6 |
+
import { Button } from '@/components/ui/button'
|
| 7 |
+
import { Input } from '@/components/ui/input'
|
| 8 |
+
import { Separator } from '@/components/ui/separator'
|
| 9 |
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
| 10 |
+
import { resolveAvatarUrl, updateCurrentNickname, uploadCurrentAvatar } from '@/lib/profileApi'
|
| 11 |
+
import { storage } from '@/lib/storage'
|
| 12 |
+
import { useAccountStore } from '@/stores/accountStore'
|
| 13 |
+
import { toast } from 'sonner'
|
| 14 |
+
import { AccountAccessControls, AccountIdEditor } from '@/components/Overlays/Settings/AccountSection'
|
| 15 |
+
|
| 16 |
+
export function UserPopover() {
|
| 17 |
+
const profile = useAccountStore((state) => state.profile)
|
| 18 |
+
const [nickname, setNickname] = useState(profile?.nickname || storage.getNickname())
|
| 19 |
+
const [open, setOpen] = useState(false)
|
| 20 |
+
const [saving, setSaving] = useState(false)
|
| 21 |
+
const [uploading, setUploading] = useState(false)
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
setNickname(profile?.nickname || storage.getNickname())
|
| 25 |
+
}, [profile?.nickname])
|
| 26 |
+
|
| 27 |
+
const handleSave = async () => {
|
| 28 |
+
const trimmed = nickname.trim()
|
| 29 |
+
if (!trimmed || trimmed === profile?.nickname) return
|
| 30 |
+
setSaving(true)
|
| 31 |
+
try {
|
| 32 |
+
await updateCurrentNickname(trimmed)
|
| 33 |
+
toast.success('昵称已保存到服务器')
|
| 34 |
+
} catch (err) {
|
| 35 |
+
toast.error(err instanceof Error ? err.message : '昵称保存失败')
|
| 36 |
+
} finally {
|
| 37 |
+
setSaving(false)
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const handleAvatar = async (file?: File) => {
|
| 42 |
+
if (!file) return
|
| 43 |
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
| 44 |
+
toast.error('仅支持 PNG、JPEG 和 WebP 图片')
|
| 45 |
+
return
|
| 46 |
+
}
|
| 47 |
+
if (file.size > 5 * 1024 * 1024) {
|
| 48 |
+
toast.error('头像不能超过 5MB')
|
| 49 |
+
return
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
setUploading(true)
|
| 53 |
+
try {
|
| 54 |
+
const image = await new Promise<string>((resolve, reject) => {
|
| 55 |
+
const reader = new FileReader()
|
| 56 |
+
reader.onload = () => resolve(String(reader.result))
|
| 57 |
+
reader.onerror = () => reject(new Error('读取图片失败'))
|
| 58 |
+
reader.readAsDataURL(file)
|
| 59 |
+
})
|
| 60 |
+
await uploadCurrentAvatar(image)
|
| 61 |
+
toast.success('头像已保存到服务器')
|
| 62 |
+
} catch (err) {
|
| 63 |
+
toast.error(err instanceof Error ? err.message : '头像上传失败')
|
| 64 |
+
} finally {
|
| 65 |
+
setUploading(false)
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const displayName = profile?.nickname || storage.getNickname()
|
| 70 |
+
const initial = displayName?.charAt(0).toUpperCase()
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<Popover open={open} onOpenChange={setOpen}>
|
| 74 |
+
<PopoverTrigger asChild>
|
| 75 |
+
<Button
|
| 76 |
+
variant="ghost"
|
| 77 |
+
size="icon"
|
| 78 |
+
className="h-9 w-9 rounded-full border border-border/60"
|
| 79 |
+
aria-label="个人资料"
|
| 80 |
+
>
|
| 81 |
+
{profile?.avatarUrl ? (
|
| 82 |
+
<Avatar className="h-8 w-8">
|
| 83 |
+
<AvatarImage src={resolveAvatarUrl(profile.avatarUrl)} alt="" />
|
| 84 |
+
<AvatarFallback>{initial}</AvatarFallback>
|
| 85 |
+
</Avatar>
|
| 86 |
+
) : initial ? (
|
| 87 |
+
<span className="text-sm font-semibold">{initial}</span>
|
| 88 |
+
) : (
|
| 89 |
+
<CircleUser className="h-5 w-5" />
|
| 90 |
+
)}
|
| 91 |
+
</Button>
|
| 92 |
+
</PopoverTrigger>
|
| 93 |
+
<PopoverContent align="end" className="w-80">
|
| 94 |
+
<div className="space-y-4">
|
| 95 |
+
<div className="flex items-center gap-3">
|
| 96 |
+
<Avatar size="lg">
|
| 97 |
+
<AvatarImage src={resolveAvatarUrl(profile?.avatarUrl)} alt="" />
|
| 98 |
+
<AvatarFallback>{initial || '?'}</AvatarFallback>
|
| 99 |
+
</Avatar>
|
| 100 |
+
<div className="min-w-0 flex-1">
|
| 101 |
+
<p className="truncate text-sm font-medium">{displayName || '尚未设置昵称'}</p>
|
| 102 |
+
<div className="flex items-center gap-2">
|
| 103 |
+
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
| 104 |
+
{profile?.id || '保存昵称后生成账号 ID'}
|
| 105 |
+
</p>
|
| 106 |
+
{profile?.role === 'admin' && (
|
| 107 |
+
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
| 108 |
+
管理员
|
| 109 |
+
</Badge>
|
| 110 |
+
)}
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
{profile && (
|
| 114 |
+
<label>
|
| 115 |
+
<input
|
| 116 |
+
type="file"
|
| 117 |
+
accept="image/png,image/jpeg,image/webp"
|
| 118 |
+
className="sr-only"
|
| 119 |
+
onChange={(event) => void handleAvatar(event.currentTarget.files?.[0])}
|
| 120 |
+
/>
|
| 121 |
+
<Button asChild variant="ghost" size="icon" className="h-8 w-8" aria-label="上传头像">
|
| 122 |
+
<span>
|
| 123 |
+
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
|
| 124 |
+
</span>
|
| 125 |
+
</Button>
|
| 126 |
+
</label>
|
| 127 |
+
)}
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<Separator />
|
| 131 |
+
|
| 132 |
+
<div className="space-y-1.5">
|
| 133 |
+
<label htmlFor="profile-nickname" className="text-xs font-medium text-muted-foreground">
|
| 134 |
+
昵称
|
| 135 |
+
</label>
|
| 136 |
+
<div className="flex gap-2">
|
| 137 |
+
<Input
|
| 138 |
+
id="profile-nickname"
|
| 139 |
+
value={nickname}
|
| 140 |
+
onChange={(event) => setNickname(event.target.value)}
|
| 141 |
+
onKeyDown={(event) => event.key === 'Enter' && void handleSave()}
|
| 142 |
+
maxLength={LIMITS.NICKNAME_MAX_LENGTH}
|
| 143 |
+
className="h-8 text-sm"
|
| 144 |
+
/>
|
| 145 |
+
<Button size="sm" className="h-8" onClick={() => void handleSave()} disabled={saving || !nickname.trim()}>
|
| 146 |
+
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}
|
| 147 |
+
</Button>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{profile ? (
|
| 152 |
+
<>
|
| 153 |
+
<Separator />
|
| 154 |
+
<AccountIdEditor compact onComplete={() => setOpen(false)} />
|
| 155 |
+
|
| 156 |
+
<Separator />
|
| 157 |
+
<AccountAccessControls compact onComplete={() => setOpen(false)} />
|
| 158 |
+
</>
|
| 159 |
+
) : (
|
| 160 |
+
<p className="text-xs text-muted-foreground">保存昵称后将生成账号 ID,并可设置密码保护账号。</p>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
</PopoverContent>
|
| 164 |
+
</Popover>
|
| 165 |
+
)
|
| 166 |
+
}
|
packages/client/src/components/Overlays/BilibiliCollectionDialog.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ResponsiveDialog, ResponsiveDialogBody, ResponsiveDialogContent, ResponsiveDialogHeader, ResponsiveDialogTitle } from '@/components/ui/responsive-dialog'
|
| 2 |
+
import { Input } from '@/components/ui/input'
|
| 3 |
+
import { VirtualTrackList } from '@/components/VirtualTrackList'
|
| 4 |
+
import { SERVER_URL } from '@/lib/config'
|
| 5 |
+
import type { Track } from '@music-together/shared'
|
| 6 |
+
import { ListMusic, Search } from 'lucide-react'
|
| 7 |
+
import { useEffect, useMemo, useState } from 'react'
|
| 8 |
+
import { toast } from 'sonner'
|
| 9 |
+
|
| 10 |
+
interface BilibiliCollectionDialogProps {
|
| 11 |
+
track: Track | null
|
| 12 |
+
onOpenChange: (open: boolean) => void
|
| 13 |
+
onSelectTrack: (track: Track) => void
|
| 14 |
+
onNotCollection: () => void
|
| 15 |
+
isTrackAdded: (track: Track) => boolean
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function normalizeSearchText(value: string) {
|
| 19 |
+
return value.normalize('NFKC').toLocaleLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Resolves a Bilibili video on demand. If it belongs to a UGC collection,
|
| 24 |
+
* show its individual videos so users choose what to add instead of queuing
|
| 25 |
+
* the whole collection or blindly playing the first video.
|
| 26 |
+
*/
|
| 27 |
+
export function BilibiliCollectionDialog({
|
| 28 |
+
track,
|
| 29 |
+
onOpenChange,
|
| 30 |
+
onSelectTrack,
|
| 31 |
+
onNotCollection,
|
| 32 |
+
isTrackAdded,
|
| 33 |
+
}: BilibiliCollectionDialogProps) {
|
| 34 |
+
const [tracks, setTracks] = useState<Track[]>([])
|
| 35 |
+
const [title, setTitle] = useState('')
|
| 36 |
+
const [loading, setLoading] = useState(false)
|
| 37 |
+
const [query, setQuery] = useState('')
|
| 38 |
+
|
| 39 |
+
const filteredTracks = useMemo(() => {
|
| 40 |
+
const keyword = normalizeSearchText(query)
|
| 41 |
+
if (!keyword) return tracks
|
| 42 |
+
|
| 43 |
+
return tracks.filter((collectionTrack) =>
|
| 44 |
+
[collectionTrack.title, collectionTrack.album, ...collectionTrack.artist]
|
| 45 |
+
.filter(Boolean)
|
| 46 |
+
.some((value) => normalizeSearchText(value).includes(keyword)),
|
| 47 |
+
)
|
| 48 |
+
}, [query, tracks])
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
if (!track) {
|
| 52 |
+
setTracks([])
|
| 53 |
+
setTitle('')
|
| 54 |
+
setLoading(false)
|
| 55 |
+
setQuery('')
|
| 56 |
+
return
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const controller = new AbortController()
|
| 60 |
+
setTracks([])
|
| 61 |
+
setTitle('')
|
| 62 |
+
setLoading(true)
|
| 63 |
+
setQuery('')
|
| 64 |
+
|
| 65 |
+
void fetch(`${SERVER_URL}/api/music/bilibili-collection?bvid=${encodeURIComponent(track.urlId)}`, {
|
| 66 |
+
signal: controller.signal,
|
| 67 |
+
credentials: 'include',
|
| 68 |
+
})
|
| 69 |
+
.then(async (response) => {
|
| 70 |
+
if (!response.ok) throw new Error('Failed to resolve Bilibili collection')
|
| 71 |
+
return (await response.json()) as { title?: string; tracks?: Track[] }
|
| 72 |
+
})
|
| 73 |
+
.then((data) => {
|
| 74 |
+
if (controller.signal.aborted) return
|
| 75 |
+
const collectionTracks = data.tracks ?? []
|
| 76 |
+
if (collectionTracks.length <= 1) {
|
| 77 |
+
onNotCollection()
|
| 78 |
+
return
|
| 79 |
+
}
|
| 80 |
+
setTitle(data.title || 'B站合集')
|
| 81 |
+
setTracks(collectionTracks)
|
| 82 |
+
})
|
| 83 |
+
.catch(() => {
|
| 84 |
+
if (controller.signal.aborted) return
|
| 85 |
+
toast.error('读取 B 站合集失败,已按单视频处理')
|
| 86 |
+
onNotCollection()
|
| 87 |
+
})
|
| 88 |
+
.finally(() => {
|
| 89 |
+
if (!controller.signal.aborted) setLoading(false)
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
return () => controller.abort()
|
| 93 |
+
}, [track, onNotCollection])
|
| 94 |
+
|
| 95 |
+
return (
|
| 96 |
+
<ResponsiveDialog open={Boolean(track)} onOpenChange={onOpenChange}>
|
| 97 |
+
<ResponsiveDialogContent className="flex h-dvh max-h-dvh flex-col overflow-hidden sm:h-auto sm:max-h-[80vh] sm:max-w-2xl">
|
| 98 |
+
<ResponsiveDialogHeader>
|
| 99 |
+
<ResponsiveDialogTitle>{title ? `合集:${title}` : '正在读取合集…'}</ResponsiveDialogTitle>
|
| 100 |
+
</ResponsiveDialogHeader>
|
| 101 |
+
<ResponsiveDialogBody className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
| 102 |
+
<div className="relative shrink-0">
|
| 103 |
+
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
| 104 |
+
<Input
|
| 105 |
+
value={query}
|
| 106 |
+
onChange={(event) => setQuery(event.target.value)}
|
| 107 |
+
placeholder="搜索视频、作者或专辑..."
|
| 108 |
+
aria-label="搜索合集内容"
|
| 109 |
+
className="pl-9"
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
<VirtualTrackList
|
| 113 |
+
tracks={filteredTracks}
|
| 114 |
+
loading={loading}
|
| 115 |
+
hasMore={false}
|
| 116 |
+
loadingMore={false}
|
| 117 |
+
onLoadMore={() => undefined}
|
| 118 |
+
isTrackAdded={isTrackAdded}
|
| 119 |
+
onAddTrack={onSelectTrack}
|
| 120 |
+
emptyIcon={<ListMusic className="h-8 w-8" />}
|
| 121 |
+
emptyMessage={query.trim() ? '没有匹配的视频' : '合集内没有可播放的视频'}
|
| 122 |
+
/>
|
| 123 |
+
</ResponsiveDialogBody>
|
| 124 |
+
</ResponsiveDialogContent>
|
| 125 |
+
</ResponsiveDialog>
|
| 126 |
+
)
|
| 127 |
+
}
|
packages/client/src/components/Overlays/BilibiliMetadataDialog.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from '@/components/ui/button'
|
| 2 |
+
import { Input } from '@/components/ui/input'
|
| 3 |
+
import {
|
| 4 |
+
ResponsiveDialog,
|
| 5 |
+
ResponsiveDialogBody,
|
| 6 |
+
ResponsiveDialogContent,
|
| 7 |
+
ResponsiveDialogHeader,
|
| 8 |
+
ResponsiveDialogTitle,
|
| 9 |
+
} from '@/components/ui/responsive-dialog'
|
| 10 |
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
| 11 |
+
import { useSearch } from '@/hooks/useSearch'
|
| 12 |
+
import { trackKey } from '@/lib/utils'
|
| 13 |
+
import type { BilibiliMetadataSource, Track } from '@music-together/shared'
|
| 14 |
+
import { Loader2, Music2, Search } from 'lucide-react'
|
| 15 |
+
import { useEffect, useState } from 'react'
|
| 16 |
+
|
| 17 |
+
interface BilibiliMetadataDialogProps {
|
| 18 |
+
track: Track | null
|
| 19 |
+
roomId?: string
|
| 20 |
+
onOpenChange: (open: boolean) => void
|
| 21 |
+
onSelect: (metadataTrack: Track, source: BilibiliMetadataSource) => void
|
| 22 |
+
onSkip: () => void
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function BilibiliMetadataDialog({ track, roomId, onOpenChange, onSelect, onSkip }: BilibiliMetadataDialogProps) {
|
| 26 |
+
const [source, setSource] = useState<BilibiliMetadataSource>('netease')
|
| 27 |
+
const trackId = track?.id
|
| 28 |
+
const trackTitle = track?.title
|
| 29 |
+
const [query, setQuery] = useState({ trackId, value: trackTitle ?? '' })
|
| 30 |
+
const keyword = query.trackId === trackId ? query.value : (trackTitle ?? '')
|
| 31 |
+
const { results, loading, loadingMore, hasMore, hasSearched, search, loadMore, resetState } = useSearch(
|
| 32 |
+
source,
|
| 33 |
+
'song',
|
| 34 |
+
roomId,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (!trackTitle) return
|
| 39 |
+
resetState()
|
| 40 |
+
search(trackTitle)
|
| 41 |
+
}, [trackId, trackTitle, source, resetState, search])
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<ResponsiveDialog open={Boolean(track)} onOpenChange={onOpenChange}>
|
| 45 |
+
<ResponsiveDialogContent className="flex max-h-[80vh] flex-col sm:max-w-xl">
|
| 46 |
+
<ResponsiveDialogHeader>
|
| 47 |
+
<ResponsiveDialogTitle>选择歌词和封面</ResponsiveDialogTitle>
|
| 48 |
+
</ResponsiveDialogHeader>
|
| 49 |
+
<ResponsiveDialogBody className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
| 50 |
+
<Tabs value={source} onValueChange={(value) => setSource(value as BilibiliMetadataSource)}>
|
| 51 |
+
<TabsList className="grid h-9 w-full grid-cols-4">
|
| 52 |
+
<TabsTrigger value="netease" className="w-full text-xs sm:text-sm">
|
| 53 |
+
网易云音乐
|
| 54 |
+
</TabsTrigger>
|
| 55 |
+
<TabsTrigger value="tencent" className="w-full text-xs sm:text-sm">
|
| 56 |
+
QQ 音乐
|
| 57 |
+
</TabsTrigger>
|
| 58 |
+
<TabsTrigger value="kugou" className="w-full text-xs sm:text-sm">
|
| 59 |
+
酷狗音乐
|
| 60 |
+
</TabsTrigger>
|
| 61 |
+
<TabsTrigger value="kugou_concept" className="w-full text-xs sm:text-sm">
|
| 62 |
+
酷狗概念版
|
| 63 |
+
</TabsTrigger>
|
| 64 |
+
</TabsList>
|
| 65 |
+
</Tabs>
|
| 66 |
+
|
| 67 |
+
<div className="flex gap-2">
|
| 68 |
+
<Input
|
| 69 |
+
value={keyword}
|
| 70 |
+
onChange={(event) => setQuery({ trackId, value: event.target.value })}
|
| 71 |
+
onKeyDown={(event) => event.key === 'Enter' && search(keyword)}
|
| 72 |
+
placeholder="搜索歌曲或歌手..."
|
| 73 |
+
autoFocus
|
| 74 |
+
aria-label="搜索歌词和封面"
|
| 75 |
+
/>
|
| 76 |
+
<Button onClick={() => search(keyword)} disabled={loading} aria-label="搜索">
|
| 77 |
+
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
| 78 |
+
</Button>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<Button variant="outline" onClick={onSkip}>
|
| 82 |
+
直接播放 B 站视频
|
| 83 |
+
</Button>
|
| 84 |
+
|
| 85 |
+
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border">
|
| 86 |
+
{loading ? (
|
| 87 |
+
<div className="flex h-48 items-center justify-center">
|
| 88 |
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
| 89 |
+
</div>
|
| 90 |
+
) : !hasSearched || results.length === 0 ? (
|
| 91 |
+
<div className="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
| 92 |
+
<Music2 className="h-8 w-8" />
|
| 93 |
+
<span className="text-sm">{hasSearched ? '暂无匹配结果' : '搜索后选择一首歌曲'}</span>
|
| 94 |
+
</div>
|
| 95 |
+
) : (
|
| 96 |
+
<div className="divide-y">
|
| 97 |
+
{(results as Track[]).map((metadataTrack) => (
|
| 98 |
+
<button
|
| 99 |
+
key={trackKey(metadataTrack)}
|
| 100 |
+
className="hover:bg-accent flex w-full min-w-0 items-center gap-3 p-3 text-left transition-colors"
|
| 101 |
+
onClick={() => onSelect(metadataTrack, source)}
|
| 102 |
+
>
|
| 103 |
+
{metadataTrack.cover ? (
|
| 104 |
+
<img
|
| 105 |
+
src={metadataTrack.cover}
|
| 106 |
+
alt=""
|
| 107 |
+
referrerPolicy="no-referrer"
|
| 108 |
+
className="h-10 w-10 shrink-0 rounded object-cover"
|
| 109 |
+
loading="lazy"
|
| 110 |
+
/>
|
| 111 |
+
) : (
|
| 112 |
+
<div className="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded">
|
| 113 |
+
<Music2 className="text-muted-foreground h-4 w-4" />
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
<div className="min-w-0 flex-1">
|
| 117 |
+
<p className="truncate text-sm font-medium">{metadataTrack.title}</p>
|
| 118 |
+
<p className="text-muted-foreground truncate text-xs">
|
| 119 |
+
{metadataTrack.artist.join(' / ')}
|
| 120 |
+
{metadataTrack.album ? ` · ${metadataTrack.album}` : ''}
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
</button>
|
| 124 |
+
))}
|
| 125 |
+
{hasMore && (
|
| 126 |
+
<Button className="m-2 w-[calc(100%-1rem)]" variant="ghost" onClick={loadMore} disabled={loadingMore}>
|
| 127 |
+
{loadingMore ? '加载中...' : '加载更多'}
|
| 128 |
+
</Button>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
</ResponsiveDialogBody>
|
| 134 |
+
</ResponsiveDialogContent>
|
| 135 |
+
</ResponsiveDialog>
|
| 136 |
+
)
|
| 137 |
+
}
|
packages/client/src/components/Overlays/QueueDrawer.tsx
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Badge } from '@/components/ui/badge'
|
| 2 |
+
import { Button } from '@/components/ui/button'
|
| 3 |
+
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
|
| 4 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
| 5 |
+
import { cn } from '@/lib/utils'
|
| 6 |
+
import { getProxiedCoverUrl } from '@/lib/cover'
|
| 7 |
+
import { usePlayerStore } from '@/stores/playerStore'
|
| 8 |
+
import { useRoomStore } from '@/stores/roomStore'
|
| 9 |
+
import { useSocketContext } from '@/providers/socket-context'
|
| 10 |
+
import type { BilibiliMetadataSource, Track } from '@music-together/shared'
|
| 11 |
+
import { EVENTS } from '@music-together/shared'
|
| 12 |
+
import { useHasHover } from '@/hooks/useHasHover'
|
| 13 |
+
import { useIsMobile } from '@/hooks/useIsMobile'
|
| 14 |
+
import { useCallback, useContext, useEffect, useRef, useState, type MouseEvent } from 'react'
|
| 15 |
+
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 16 |
+
import { AbilityContext } from '@/providers/ability-context'
|
| 17 |
+
import { ArrowUpToLine, ChevronDown, ChevronUp, ListX, Music, Play, RefreshCw, Trash2, User, X } from 'lucide-react'
|
| 18 |
+
import { toast } from 'sonner'
|
| 19 |
+
import { MarqueeText } from '@/components/ui/marquee-text'
|
| 20 |
+
import { BilibiliMetadataDialog } from './BilibiliMetadataDialog'
|
| 21 |
+
import type { MusicSource } from '@music-together/shared'
|
| 22 |
+
|
| 23 |
+
const EMPTY_QUEUE: Track[] = []
|
| 24 |
+
|
| 25 |
+
const SOURCE_STYLE: Record<MusicSource, { label: string; className: string }> = {
|
| 26 |
+
netease: { label: '网易', className: 'text-white bg-red-500 ring-red-600/50' },
|
| 27 |
+
tencent: { label: 'QQ', className: 'text-white bg-green-500 ring-green-600/50' },
|
| 28 |
+
kugou: { label: '酷狗', className: 'text-white bg-blue-500 ring-blue-600/50' },
|
| 29 |
+
kugou_concept: { label: '概念版', className: 'text-white bg-sky-500 ring-sky-600/50' },
|
| 30 |
+
bilibili: { label: 'B站', className: 'text-white bg-pink-500 ring-pink-600/50' },
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface QueueDrawerProps {
|
| 34 |
+
open: boolean
|
| 35 |
+
onOpenChange: (open: boolean) => void
|
| 36 |
+
onRemoveFromQueue: (trackId: string) => void
|
| 37 |
+
onReorderQueue: (trackIds: string[]) => void
|
| 38 |
+
onUpdateBilibiliMetadata: (
|
| 39 |
+
trackId: string,
|
| 40 |
+
metadata:
|
| 41 |
+
| { metadataSource: BilibiliMetadataSource; lyricId?: string; picId?: string; cover: string }
|
| 42 |
+
| { clearMetadata: true },
|
| 43 |
+
) => void
|
| 44 |
+
onClearQueue: () => void
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function QueueDrawer({
|
| 48 |
+
open,
|
| 49 |
+
onOpenChange,
|
| 50 |
+
onRemoveFromQueue,
|
| 51 |
+
onReorderQueue,
|
| 52 |
+
onUpdateBilibiliMetadata,
|
| 53 |
+
onClearQueue,
|
| 54 |
+
}: QueueDrawerProps) {
|
| 55 |
+
const queue = useRoomStore((s) => s.room?.queue ?? EMPTY_QUEUE)
|
| 56 |
+
const roomId = useRoomStore((s) => s.room?.id)
|
| 57 |
+
const room = useRoomStore((s) => s.room)
|
| 58 |
+
const currentUser = useRoomStore((s) => s.currentUser)
|
| 59 |
+
const currentTrack = usePlayerStore((s) => s.currentTrack)
|
| 60 |
+
const { socket } = useSocketContext()
|
| 61 |
+
const isMobile = useIsMobile() // layout: Drawer direction, height
|
| 62 |
+
const hasHover = useHasHover() // interaction: hover vs touch
|
| 63 |
+
const isTouch = !hasHover
|
| 64 |
+
const ability = useContext(AbilityContext)
|
| 65 |
+
const canRemove = ability.can('remove', 'Queue')
|
| 66 |
+
const isTemporaryAdmin = room?.temporaryAdminUserId === currentUser?.id && !currentUser?.isServerAdmin
|
| 67 |
+
const allowTemporaryAdminTrackRemoval = room?.allowTemporaryAdminTrackRemoval ?? false
|
| 68 |
+
const allowTemporaryAdminQueueClear = room?.allowTemporaryAdminQueueClear ?? false
|
| 69 |
+
const canRemoveTrack = canRemove && (!isTemporaryAdmin || allowTemporaryAdminTrackRemoval)
|
| 70 |
+
const canClearQueue = canRemove && (!isTemporaryAdmin || allowTemporaryAdminQueueClear)
|
| 71 |
+
const canAdd = ability.can('add', 'Queue')
|
| 72 |
+
const canReorder = ability.can('reorder', 'Queue')
|
| 73 |
+
const canPlay = ability.can('play', 'Player')
|
| 74 |
+
const canVote = ability.can('vote', 'Player')
|
| 75 |
+
const [confirmClear, setConfirmClear] = useState(false)
|
| 76 |
+
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 77 |
+
// Mobile: track which item has its action toolbar visible
|
| 78 |
+
const [activeTrackId, setActiveTrackId] = useState<string | null>(null)
|
| 79 |
+
// Desktop: after clicking an action, temporarily suppress the hover toolbar until the cursor leaves the item
|
| 80 |
+
const [dismissedHoverTrackId, setDismissedHoverTrackId] = useState<string | null>(null)
|
| 81 |
+
const [metadataTrack, setMetadataTrack] = useState<Track | null>(null)
|
| 82 |
+
|
| 83 |
+
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
|
| 84 |
+
// TanStack Virtual manages mutable measurements internally and cannot be compiler-memoized.
|
| 85 |
+
// eslint-disable-next-line react-hooks/incompatible-library
|
| 86 |
+
const virtualizer = useVirtualizer({
|
| 87 |
+
count: queue.length,
|
| 88 |
+
getScrollElement: () => scrollElement,
|
| 89 |
+
estimateSize: () => 56,
|
| 90 |
+
overscan: 5,
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
// Clear the confirm-dismiss timer on unmount
|
| 94 |
+
useEffect(
|
| 95 |
+
() => () => {
|
| 96 |
+
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current)
|
| 97 |
+
},
|
| 98 |
+
[],
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
const handleClear = useCallback(() => {
|
| 102 |
+
if (!confirmClear) {
|
| 103 |
+
setConfirmClear(true)
|
| 104 |
+
// Auto-dismiss after 3s
|
| 105 |
+
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current)
|
| 106 |
+
confirmTimerRef.current = setTimeout(() => {
|
| 107 |
+
confirmTimerRef.current = null
|
| 108 |
+
setConfirmClear(false)
|
| 109 |
+
}, 3000)
|
| 110 |
+
return
|
| 111 |
+
}
|
| 112 |
+
if (confirmTimerRef.current) {
|
| 113 |
+
clearTimeout(confirmTimerRef.current)
|
| 114 |
+
confirmTimerRef.current = null
|
| 115 |
+
}
|
| 116 |
+
onClearQueue()
|
| 117 |
+
setConfirmClear(false)
|
| 118 |
+
toast.success('播放列表已清空')
|
| 119 |
+
}, [confirmClear, onClearQueue])
|
| 120 |
+
|
| 121 |
+
const handleMoveUp = (index: number) => {
|
| 122 |
+
if (index <= 0) return
|
| 123 |
+
const ids = queue.map((t) => t.id)
|
| 124 |
+
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
|
| 125 |
+
onReorderQueue(ids)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const handleMoveDown = (index: number) => {
|
| 129 |
+
if (index >= queue.length - 1) return
|
| 130 |
+
const ids = queue.map((t) => t.id)
|
| 131 |
+
;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]
|
| 132 |
+
onReorderQueue(ids)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const handlePlayTrack = (track: Track) => {
|
| 136 |
+
if (canPlay) {
|
| 137 |
+
socket.emit(EVENTS.PLAYER_PLAY, { track })
|
| 138 |
+
} else if (canVote) {
|
| 139 |
+
socket.emit(EVENTS.VOTE_START, {
|
| 140 |
+
action: 'play-track' as const,
|
| 141 |
+
payload: { trackId: track.id, trackTitle: track.title },
|
| 142 |
+
})
|
| 143 |
+
toast.info(`已发起投票:播放「${track.title}」`)
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const handleRemoveTrack = (track: Track) => {
|
| 148 |
+
if (canRemoveTrack) {
|
| 149 |
+
onRemoveFromQueue(track.id)
|
| 150 |
+
toast.success(`已移除「${track.title}」`)
|
| 151 |
+
} else if (canVote) {
|
| 152 |
+
socket.emit(EVENTS.VOTE_START, {
|
| 153 |
+
action: 'remove-track' as const,
|
| 154 |
+
payload: { trackId: track.id, trackTitle: track.title },
|
| 155 |
+
})
|
| 156 |
+
toast.info(`已发起投票:移除「${track.title}」`)
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const handleInsertAfterCurrent = (track: Track, e?: MouseEvent) => {
|
| 161 |
+
// If this was triggered from inside the floating actions, hide it immediately.
|
| 162 |
+
// - Touch: activeTrackId controls visibility
|
| 163 |
+
// - Desktop: group-hover/group-focus-within can keep it visible after DOM reorders (transform keeps hover)
|
| 164 |
+
if (e) {
|
| 165 |
+
e.stopPropagation()
|
| 166 |
+
;(e.currentTarget as HTMLButtonElement | null)?.blur()
|
| 167 |
+
}
|
| 168 |
+
if (isTouch && activeTrackId === track.id) setActiveTrackId(null)
|
| 169 |
+
if (!isTouch) setDismissedHoverTrackId(track.id)
|
| 170 |
+
const current = currentTrack
|
| 171 |
+
const currentIndex = current?.id ? queue.findIndex((t) => t.id === current.id) : -1
|
| 172 |
+
|
| 173 |
+
if (current && track.id === current.id) return
|
| 174 |
+
|
| 175 |
+
const ids = queue.map((t) => t.id)
|
| 176 |
+
const from = ids.indexOf(track.id)
|
| 177 |
+
if (from < 0) return
|
| 178 |
+
|
| 179 |
+
// 先移除再插入,避免重复
|
| 180 |
+
ids.splice(from, 1)
|
| 181 |
+
|
| 182 |
+
if (currentIndex >= 0) {
|
| 183 |
+
// 目标位置:当前播放歌曲的下方(已播放的在上方,不动)
|
| 184 |
+
// 如果被移动的歌曲在 current 之前,移除后 currentIndex 会左移一位
|
| 185 |
+
const adjustedCurrentIndex = from < currentIndex ? currentIndex - 1 : currentIndex
|
| 186 |
+
const to = adjustedCurrentIndex + 1
|
| 187 |
+
ids.splice(to, 0, track.id)
|
| 188 |
+
} else {
|
| 189 |
+
// 无 currentTrack(或 currentTrack 不在队列中)时,退化为置顶到队首
|
| 190 |
+
ids.unshift(track.id)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
onReorderQueue(ids)
|
| 194 |
+
toast.success(`已置顶「${track.title}」`)
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
return (
|
| 198 |
+
<Drawer open={open} onOpenChange={onOpenChange} direction={isMobile ? 'bottom' : 'right'}>
|
| 199 |
+
<DrawerContent className={cn('flex flex-col p-0', isMobile && 'h-[70vh]')}>
|
| 200 |
+
<DrawerHeader className="shrink-0 border-b px-4 py-3">
|
| 201 |
+
<div className="flex items-center justify-between">
|
| 202 |
+
<DrawerTitle className="flex items-center gap-2 text-base">
|
| 203 |
+
<Music className="h-4 w-4" />
|
| 204 |
+
播放列表 ({queue.length})
|
| 205 |
+
</DrawerTitle>
|
| 206 |
+
<div className="flex items-center gap-1">
|
| 207 |
+
{canClearQueue && queue.length > 0 && (
|
| 208 |
+
<Tooltip delayDuration={300}>
|
| 209 |
+
<TooltipTrigger asChild>
|
| 210 |
+
<Button
|
| 211 |
+
variant="ghost"
|
| 212 |
+
size="icon"
|
| 213 |
+
className={cn('h-7 w-7', confirmClear && 'text-destructive hover:text-destructive')}
|
| 214 |
+
onClick={handleClear}
|
| 215 |
+
aria-label="清空播放列表"
|
| 216 |
+
>
|
| 217 |
+
<ListX className="h-4 w-4" />
|
| 218 |
+
</Button>
|
| 219 |
+
</TooltipTrigger>
|
| 220 |
+
<TooltipContent>{confirmClear ? '再次点击确认清空' : '清空播放列表'}</TooltipContent>
|
| 221 |
+
</Tooltip>
|
| 222 |
+
)}
|
| 223 |
+
{!isMobile && (
|
| 224 |
+
<Button
|
| 225 |
+
variant="ghost"
|
| 226 |
+
size="icon"
|
| 227 |
+
className="h-7 w-7"
|
| 228 |
+
onClick={() => onOpenChange(false)}
|
| 229 |
+
aria-label="关闭播放列表"
|
| 230 |
+
>
|
| 231 |
+
<X className="h-4 w-4" />
|
| 232 |
+
</Button>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</DrawerHeader>
|
| 237 |
+
|
| 238 |
+
<div ref={setScrollElement} className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-2">
|
| 239 |
+
{queue.length === 0 ? (
|
| 240 |
+
<div className="flex h-40 items-center justify-center text-muted-foreground">播放列表为空</div>
|
| 241 |
+
) : (
|
| 242 |
+
<div className="w-full" style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
|
| 243 |
+
{virtualizer.getVirtualItems().map((virtualRow) => {
|
| 244 |
+
const i = virtualRow.index
|
| 245 |
+
const track = queue[i]
|
| 246 |
+
if (!track) return null
|
| 247 |
+
return (
|
| 248 |
+
<div
|
| 249 |
+
key={track.id}
|
| 250 |
+
style={{
|
| 251 |
+
position: 'absolute',
|
| 252 |
+
top: 0,
|
| 253 |
+
left: 0,
|
| 254 |
+
width: '100%',
|
| 255 |
+
height: `${virtualRow.size}px`,
|
| 256 |
+
transform: `translateY(${virtualRow.start}px)`,
|
| 257 |
+
paddingTop: '8px', // To simulate the top padding/gap
|
| 258 |
+
}}
|
| 259 |
+
>
|
| 260 |
+
<div
|
| 261 |
+
className={cn(
|
| 262 |
+
'group relative flex h-full items-center gap-2 rounded-lg px-2 transition-colors hover:bg-accent/50',
|
| 263 |
+
currentTrack?.id === track.id && 'bg-primary/10',
|
| 264 |
+
)}
|
| 265 |
+
onClick={() => {
|
| 266 |
+
if (isTouch) {
|
| 267 |
+
setActiveTrackId((prev) => (prev === track.id ? null : track.id))
|
| 268 |
+
}
|
| 269 |
+
}}
|
| 270 |
+
onMouseLeave={() => {
|
| 271 |
+
if (!isTouch && dismissedHoverTrackId === track.id) setDismissedHoverTrackId(null)
|
| 272 |
+
}}
|
| 273 |
+
>
|
| 274 |
+
{/* Index */}
|
| 275 |
+
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
|
| 276 |
+
{i + 1}
|
| 277 |
+
</span>
|
| 278 |
+
|
| 279 |
+
{/* Cover + source badge */}
|
| 280 |
+
<div className="relative shrink-0">
|
| 281 |
+
{track.cover ? (
|
| 282 |
+
<img
|
| 283 |
+
src={getProxiedCoverUrl(track.cover)}
|
| 284 |
+
alt={track.title}
|
| 285 |
+
referrerPolicy="no-referrer"
|
| 286 |
+
className="h-9 w-9 rounded object-cover"
|
| 287 |
+
onError={(e) => {
|
| 288 |
+
e.currentTarget.style.display = 'none'
|
| 289 |
+
e.currentTarget.nextElementSibling?.classList.remove('hidden')
|
| 290 |
+
}}
|
| 291 |
+
/>
|
| 292 |
+
) : null}
|
| 293 |
+
<div
|
| 294 |
+
className={cn(
|
| 295 |
+
'flex h-9 w-9 items-center justify-center rounded bg-muted',
|
| 296 |
+
track.cover && 'hidden',
|
| 297 |
+
)}
|
| 298 |
+
>
|
| 299 |
+
<Music className="h-4 w-4 text-muted-foreground" />
|
| 300 |
+
</div>
|
| 301 |
+
{track.source && SOURCE_STYLE[track.source] && (
|
| 302 |
+
<span
|
| 303 |
+
className={cn(
|
| 304 |
+
'absolute -bottom-1 -right-1 rounded px-0.5 text-[8px] font-bold leading-tight ring-1',
|
| 305 |
+
SOURCE_STYLE[track.source].className,
|
| 306 |
+
)}
|
| 307 |
+
>
|
| 308 |
+
{SOURCE_STYLE[track.source].label}
|
| 309 |
+
</span>
|
| 310 |
+
)}
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
{/* Track info */}
|
| 314 |
+
<div className="min-w-0 flex-1">
|
| 315 |
+
<MarqueeText
|
| 316 |
+
className={cn('text-sm', currentTrack?.id === track.id && 'font-medium text-primary')}
|
| 317 |
+
>
|
| 318 |
+
{track.title}
|
| 319 |
+
</MarqueeText>
|
| 320 |
+
<div className="flex min-w-0 items-center gap-1">
|
| 321 |
+
<MarqueeText className="min-w-0 flex-1 text-xs text-muted-foreground">
|
| 322 |
+
{track.artist.join(' / ')}
|
| 323 |
+
</MarqueeText>
|
| 324 |
+
{track.requestedBy && (
|
| 325 |
+
<Badge
|
| 326 |
+
variant="outline"
|
| 327 |
+
className="h-4 max-w-28 shrink-0 gap-0.5 truncate border-primary/30 bg-primary/10 px-1.5 py-0 text-[10px] font-normal text-primary"
|
| 328 |
+
>
|
| 329 |
+
<User className="h-2.5 w-2.5 shrink-0" />
|
| 330 |
+
<span className="truncate">{track.requestedBy}</span>
|
| 331 |
+
</Badge>
|
| 332 |
+
)}
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
|
| 336 |
+
{/* Actions — visible on hover (desktop) or tap (mobile) */}
|
| 337 |
+
<div
|
| 338 |
+
className={cn(
|
| 339 |
+
'absolute right-1 top-1/2 z-20 flex -translate-y-1/2 items-center gap-0.5',
|
| 340 |
+
'rounded-md border border-border/50 bg-popover px-1 py-0.5 shadow-md backdrop-blur-md',
|
| 341 |
+
'opacity-0 pointer-events-none transition-opacity',
|
| 342 |
+
'group-hover:opacity-100 group-hover:pointer-events-auto',
|
| 343 |
+
'group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
|
| 344 |
+
isTouch && activeTrackId === track.id && 'opacity-100 pointer-events-auto',
|
| 345 |
+
!isTouch && dismissedHoverTrackId === track.id && 'opacity-0 pointer-events-none',
|
| 346 |
+
)}
|
| 347 |
+
onClick={(e) => e.stopPropagation()}
|
| 348 |
+
>
|
| 349 |
+
{/* Play button — hidden for currently playing track */}
|
| 350 |
+
{currentTrack?.id !== track.id && (canPlay || canVote) && (
|
| 351 |
+
<Tooltip delayDuration={400}>
|
| 352 |
+
<TooltipTrigger asChild>
|
| 353 |
+
<Button
|
| 354 |
+
variant="ghost"
|
| 355 |
+
size="icon"
|
| 356 |
+
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
|
| 357 |
+
onClick={() => handlePlayTrack(track)}
|
| 358 |
+
aria-label={canPlay ? `播放 ${track.title}` : `投票播放 ${track.title}`}
|
| 359 |
+
>
|
| 360 |
+
<Play className="h-3 w-3" />
|
| 361 |
+
</Button>
|
| 362 |
+
</TooltipTrigger>
|
| 363 |
+
<TooltipContent side="bottom">{canPlay ? '播放' : '投票播放'}</TooltipContent>
|
| 364 |
+
</Tooltip>
|
| 365 |
+
)}
|
| 366 |
+
|
| 367 |
+
{track.source === 'bilibili' && canAdd && (
|
| 368 |
+
<Tooltip delayDuration={400}>
|
| 369 |
+
<TooltipTrigger asChild>
|
| 370 |
+
<Button
|
| 371 |
+
variant="ghost"
|
| 372 |
+
size="icon"
|
| 373 |
+
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
|
| 374 |
+
onClick={() => setMetadataTrack(track)}
|
| 375 |
+
aria-label={`重选 ${track.title} 的歌词和封面`}
|
| 376 |
+
>
|
| 377 |
+
<RefreshCw className="h-3 w-3" />
|
| 378 |
+
</Button>
|
| 379 |
+
</TooltipTrigger>
|
| 380 |
+
<TooltipContent side="bottom">重选歌词和封面</TooltipContent>
|
| 381 |
+
</Tooltip>
|
| 382 |
+
)}
|
| 383 |
+
|
| 384 |
+
{canReorder && (
|
| 385 |
+
<>
|
| 386 |
+
<Tooltip delayDuration={400}>
|
| 387 |
+
<TooltipTrigger asChild>
|
| 388 |
+
<Button
|
| 389 |
+
variant="ghost"
|
| 390 |
+
size="icon"
|
| 391 |
+
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
|
| 392 |
+
disabled={i === 0}
|
| 393 |
+
onClick={() => handleMoveUp(i)}
|
| 394 |
+
aria-label={`上移 ${track.title}`}
|
| 395 |
+
>
|
| 396 |
+
<ChevronUp className="h-3 w-3" />
|
| 397 |
+
</Button>
|
| 398 |
+
</TooltipTrigger>
|
| 399 |
+
<TooltipContent side="bottom">上移</TooltipContent>
|
| 400 |
+
</Tooltip>
|
| 401 |
+
|
| 402 |
+
<Tooltip delayDuration={400}>
|
| 403 |
+
<TooltipTrigger asChild>
|
| 404 |
+
<Button
|
| 405 |
+
variant="ghost"
|
| 406 |
+
size="icon"
|
| 407 |
+
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
|
| 408 |
+
disabled={i === queue.length - 1}
|
| 409 |
+
onClick={() => handleMoveDown(i)}
|
| 410 |
+
aria-label={`下移 ${track.title}`}
|
| 411 |
+
>
|
| 412 |
+
<ChevronDown className="h-3 w-3" />
|
| 413 |
+
</Button>
|
| 414 |
+
</TooltipTrigger>
|
| 415 |
+
<TooltipContent side="bottom">下移</TooltipContent>
|
| 416 |
+
</Tooltip>
|
| 417 |
+
|
| 418 |
+
<Tooltip delayDuration={400}>
|
| 419 |
+
<TooltipTrigger asChild>
|
| 420 |
+
<Button
|
| 421 |
+
variant="ghost"
|
| 422 |
+
size="icon"
|
| 423 |
+
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
|
| 424 |
+
onClick={(e) => handleInsertAfterCurrent(track, e)}
|
| 425 |
+
aria-label={`置顶 ${track.title}`}
|
| 426 |
+
>
|
| 427 |
+
<ArrowUpToLine className="h-3 w-3" />
|
| 428 |
+
</Button>
|
| 429 |
+
</TooltipTrigger>
|
| 430 |
+
<TooltipContent side="bottom">置顶到当前播放下方</TooltipContent>
|
| 431 |
+
</Tooltip>
|
| 432 |
+
</>
|
| 433 |
+
)}
|
| 434 |
+
|
| 435 |
+
{(canRemoveTrack || canVote) && (
|
| 436 |
+
<Tooltip delayDuration={400}>
|
| 437 |
+
<TooltipTrigger asChild>
|
| 438 |
+
<Button
|
| 439 |
+
variant="ghost"
|
| 440 |
+
size="icon"
|
| 441 |
+
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0 text-destructive hover:text-destructive"
|
| 442 |
+
onClick={() => handleRemoveTrack(track)}
|
| 443 |
+
aria-label={canRemoveTrack ? `移除 ${track.title}` : `投票移除 ${track.title}`}
|
| 444 |
+
>
|
| 445 |
+
<Trash2 className="h-3 w-3" />
|
| 446 |
+
</Button>
|
| 447 |
+
</TooltipTrigger>
|
| 448 |
+
<TooltipContent side="bottom">{canRemoveTrack ? '移除' : '投票移除'}</TooltipContent>
|
| 449 |
+
</Tooltip>
|
| 450 |
+
)}
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
</div>
|
| 454 |
+
)
|
| 455 |
+
})}
|
| 456 |
+
</div>
|
| 457 |
+
)}
|
| 458 |
+
</div>
|
| 459 |
+
</DrawerContent>
|
| 460 |
+
<BilibiliMetadataDialog
|
| 461 |
+
track={metadataTrack}
|
| 462 |
+
roomId={roomId}
|
| 463 |
+
onOpenChange={(isOpen) => !isOpen && setMetadataTrack(null)}
|
| 464 |
+
onSelect={(metadata, metadataSource) => {
|
| 465 |
+
if (!metadataTrack) return
|
| 466 |
+
onUpdateBilibiliMetadata(metadataTrack.id, {
|
| 467 |
+
metadataSource,
|
| 468 |
+
lyricId: metadata.lyricId,
|
| 469 |
+
picId: metadata.picId,
|
| 470 |
+
cover: metadata.cover || metadataTrack.cover,
|
| 471 |
+
})
|
| 472 |
+
setMetadataTrack(null)
|
| 473 |
+
}}
|
| 474 |
+
onSkip={() => {
|
| 475 |
+
if (!metadataTrack) return
|
| 476 |
+
onUpdateBilibiliMetadata(metadataTrack.id, { clearMetadata: true })
|
| 477 |
+
setMetadataTrack(null)
|
| 478 |
+
}}
|
| 479 |
+
/>
|
| 480 |
+
</Drawer>
|
| 481 |
+
)
|
| 482 |
+
}
|
packages/client/src/components/Overlays/SearchDialog.tsx
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from '@/components/ui/button'
|
| 2 |
+
import {
|
| 3 |
+
ResponsiveDialog,
|
| 4 |
+
ResponsiveDialogBody,
|
| 5 |
+
ResponsiveDialogContent,
|
| 6 |
+
ResponsiveDialogHeader,
|
| 7 |
+
ResponsiveDialogTitle,
|
| 8 |
+
} from '@/components/ui/responsive-dialog'
|
| 9 |
+
import { Input } from '@/components/ui/input'
|
| 10 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
| 11 |
+
import { VirtualTrackList, type VirtualTrackListRef } from '@/components/VirtualTrackList'
|
| 12 |
+
import { PLATFORM_ACTIVE, PLATFORM_TEXT } from '@/lib/platform'
|
| 13 |
+
import { cn, trackKey } from '@/lib/utils'
|
| 14 |
+
import { useRoomStore } from '@/stores/roomStore'
|
| 15 |
+
import { useSearch } from '@/hooks/useSearch'
|
| 16 |
+
import { useRecommendations } from '@/hooks/useRecommendations'
|
| 17 |
+
import { usePlaylist } from '@/hooks/usePlaylist'
|
| 18 |
+
import { useIsMobile } from '@/hooks/useIsMobile'
|
| 19 |
+
import { useSocketContext } from '@/providers/socket-context'
|
| 20 |
+
import { EVENTS, LIMITS } from '@music-together/shared'
|
| 21 |
+
import type { MusicSource, Track, Playlist } from '@music-together/shared'
|
| 22 |
+
import type { BilibiliMetadataSource } from '@music-together/shared'
|
| 23 |
+
import { Loader2, Music2, Search, ListMusic, Radio, RefreshCw, Sparkles } from 'lucide-react'
|
| 24 |
+
import { motion } from 'motion/react'
|
| 25 |
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react'
|
| 26 |
+
import { toast } from 'sonner'
|
| 27 |
+
import { PlaylistDetail } from './Settings/PlaylistDetail'
|
| 28 |
+
import { BilibiliCollectionDialog } from './BilibiliCollectionDialog'
|
| 29 |
+
import { BilibiliMetadataDialog } from './BilibiliMetadataDialog'
|
| 30 |
+
|
| 31 |
+
const EMPTY_QUEUE: Track[] = []
|
| 32 |
+
type BilibiliQueueAction = 'add' | 'insert'
|
| 33 |
+
type SearchMode = 'song' | 'album' | 'playlist' | 'recommend'
|
| 34 |
+
type TencentRecommendationView = 'radar' | 'playlists'
|
| 35 |
+
|
| 36 |
+
const SOURCES: { id: MusicSource; label: string }[] = [
|
| 37 |
+
{ id: 'netease', label: '网易云' },
|
| 38 |
+
{ id: 'tencent', label: 'QQ' },
|
| 39 |
+
{ id: 'kugou', label: '酷狗' },
|
| 40 |
+
{ id: 'kugou_concept', label: '概念版' },
|
| 41 |
+
{ id: 'bilibili', label: 'B站' },
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
interface SearchDialogProps {
|
| 45 |
+
open: boolean
|
| 46 |
+
onOpenChange: (open: boolean) => void
|
| 47 |
+
onAddToQueue: (track: Track) => void
|
| 48 |
+
onInsertAfterCurrent: (track: Track) => void
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface PlaylistListProps {
|
| 52 |
+
playlists: Playlist[]
|
| 53 |
+
onSelect: (playlist: Playlist) => void
|
| 54 |
+
hasMore?: boolean
|
| 55 |
+
loadingMore?: boolean
|
| 56 |
+
onLoadMore?: () => void
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function PlaylistList({ playlists, onSelect, hasMore, loadingMore, onLoadMore }: PlaylistListProps) {
|
| 60 |
+
return (
|
| 61 |
+
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border p-2">
|
| 62 |
+
<div className="flex flex-col gap-2">
|
| 63 |
+
{playlists.map((playlist, index) => (
|
| 64 |
+
<button
|
| 65 |
+
key={`${playlist.source}-${playlist.id}-${index}`}
|
| 66 |
+
className="hover:bg-accent flex w-full min-w-0 items-center gap-3 overflow-hidden rounded-lg p-2 text-left transition-colors"
|
| 67 |
+
onClick={() => onSelect(playlist)}
|
| 68 |
+
>
|
| 69 |
+
{playlist.cover ? (
|
| 70 |
+
<img
|
| 71 |
+
src={playlist.cover}
|
| 72 |
+
alt={playlist.name}
|
| 73 |
+
referrerPolicy="no-referrer"
|
| 74 |
+
className="h-12 w-12 shrink-0 rounded-md object-cover"
|
| 75 |
+
loading="lazy"
|
| 76 |
+
/>
|
| 77 |
+
) : (
|
| 78 |
+
<div className="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-md">
|
| 79 |
+
<ListMusic className="text-muted-foreground h-5 w-5" />
|
| 80 |
+
</div>
|
| 81 |
+
)}
|
| 82 |
+
<div className="min-w-0 flex-1">
|
| 83 |
+
<p className="truncate text-sm font-medium">{playlist.name}</p>
|
| 84 |
+
<p className="text-muted-foreground truncate text-xs">
|
| 85 |
+
{[
|
| 86 |
+
playlist.trackCount > 0 || !['kugou', 'kugou_concept'].includes(playlist.source)
|
| 87 |
+
? `${playlist.trackCount} 首`
|
| 88 |
+
: '',
|
| 89 |
+
playlist.creator,
|
| 90 |
+
]
|
| 91 |
+
.filter(Boolean)
|
| 92 |
+
.join(' · ')}
|
| 93 |
+
</p>
|
| 94 |
+
</div>
|
| 95 |
+
</button>
|
| 96 |
+
))}
|
| 97 |
+
{hasMore && onLoadMore && (
|
| 98 |
+
<Button variant="ghost" size="sm" className="mt-2 w-full" onClick={onLoadMore} disabled={loadingMore}>
|
| 99 |
+
{loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
| 100 |
+
{loadingMore ? '加载中...' : '加载更多'}
|
| 101 |
+
</Button>
|
| 102 |
+
)}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export function SearchDialog({ open, onOpenChange, onAddToQueue, onInsertAfterCurrent }: SearchDialogProps) {
|
| 109 |
+
const [source, setSource] = useState<MusicSource>('netease')
|
| 110 |
+
const [searchType, setSearchType] = useState<SearchMode>('song')
|
| 111 |
+
const [tencentRecommendationView, setTencentRecommendationView] = useState<TencentRecommendationView>('radar')
|
| 112 |
+
const [bilibiliMatch, setBilibiliMatch] = useState<{ track: Track; action: BilibiliQueueAction } | null>(null)
|
| 113 |
+
const [bilibiliCollectionMatch, setBilibiliCollectionMatch] = useState<{
|
| 114 |
+
track: Track
|
| 115 |
+
action: BilibiliQueueAction
|
| 116 |
+
} | null>(null)
|
| 117 |
+
const [keyword, setKeyword] = useState('')
|
| 118 |
+
const [addedIds, setAddedIds] = useState<Set<string>>(new Set())
|
| 119 |
+
const listRef = useRef<VirtualTrackListRef>(null)
|
| 120 |
+
const dialogContentRef = useRef<HTMLDivElement>(null)
|
| 121 |
+
const sourceContainerRef = useRef<HTMLDivElement>(null)
|
| 122 |
+
const [pillStyle, setPillStyle] = useState({ left: 0, width: 0 })
|
| 123 |
+
const queue = useRoomStore((s) => s.room?.queue ?? EMPTY_QUEUE)
|
| 124 |
+
const roomId = useRoomStore((s) => s.room?.id)
|
| 125 |
+
const queueKeys = useMemo(() => new Set(queue.map(trackKey)), [queue])
|
| 126 |
+
const { socket } = useSocketContext()
|
| 127 |
+
const isMobile = useIsMobile()
|
| 128 |
+
|
| 129 |
+
// Mobile browsers can pan the visual viewport when the keyboard focuses the
|
| 130 |
+
// search input, which moves even fixed drawers above the visible screen.
|
| 131 |
+
// Keep this search drawer pinned to the actual visible viewport instead.
|
| 132 |
+
useLayoutEffect(() => {
|
| 133 |
+
if (!open || !isMobile) return
|
| 134 |
+
|
| 135 |
+
const content = dialogContentRef.current
|
| 136 |
+
const viewport = window.visualViewport
|
| 137 |
+
if (!content || !viewport) return
|
| 138 |
+
|
| 139 |
+
let frame = 0
|
| 140 |
+
const syncToVisualViewport = () => {
|
| 141 |
+
cancelAnimationFrame(frame)
|
| 142 |
+
frame = requestAnimationFrame(() => {
|
| 143 |
+
content.style.setProperty('top', `${Math.max(0, viewport.offsetTop)}px`, 'important')
|
| 144 |
+
content.style.setProperty('bottom', 'auto', 'important')
|
| 145 |
+
content.style.setProperty('height', `${viewport.height}px`, 'important')
|
| 146 |
+
content.style.setProperty('max-height', `${viewport.height}px`, 'important')
|
| 147 |
+
})
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
syncToVisualViewport()
|
| 151 |
+
viewport.addEventListener('resize', syncToVisualViewport)
|
| 152 |
+
viewport.addEventListener('scroll', syncToVisualViewport)
|
| 153 |
+
|
| 154 |
+
return () => {
|
| 155 |
+
cancelAnimationFrame(frame)
|
| 156 |
+
viewport.removeEventListener('resize', syncToVisualViewport)
|
| 157 |
+
viewport.removeEventListener('scroll', syncToVisualViewport)
|
| 158 |
+
content.style.removeProperty('top')
|
| 159 |
+
content.style.removeProperty('bottom')
|
| 160 |
+
content.style.removeProperty('height')
|
| 161 |
+
content.style.removeProperty('max-height')
|
| 162 |
+
}
|
| 163 |
+
}, [open, isMobile])
|
| 164 |
+
|
| 165 |
+
// Album Detail view state
|
| 166 |
+
const [selectedAlbum, setSelectedAlbum] = useState<Playlist | null>(null)
|
| 167 |
+
const {
|
| 168 |
+
playlistTracks,
|
| 169 |
+
playlistTotal,
|
| 170 |
+
tracksLoading,
|
| 171 |
+
loadingMore: albumLoadingMore,
|
| 172 |
+
hasMoreTracks,
|
| 173 |
+
fetchPlaylistTracks,
|
| 174 |
+
loadMoreTracks,
|
| 175 |
+
fetchAllPlaylistTracks,
|
| 176 |
+
} = usePlaylist()
|
| 177 |
+
|
| 178 |
+
const { results, loading, loadingMore, hasMore, hasSearched, search, loadMore, resetState } = useSearch(
|
| 179 |
+
source,
|
| 180 |
+
searchType === 'recommend' ? 'song' : searchType,
|
| 181 |
+
roomId,
|
| 182 |
+
)
|
| 183 |
+
const {
|
| 184 |
+
recommendations,
|
| 185 |
+
loading: recommendationsLoading,
|
| 186 |
+
loadingMore: recommendationsLoadingMore,
|
| 187 |
+
loaded: recommendationsLoaded,
|
| 188 |
+
load: loadRecommendations,
|
| 189 |
+
loadMore: loadMoreRecommendations,
|
| 190 |
+
reset: resetRecommendations,
|
| 191 |
+
} = useRecommendations(roomId)
|
| 192 |
+
const activeRecommendation = useMemo(
|
| 193 |
+
() => recommendations.find((recommendation) => recommendation.platform === source),
|
| 194 |
+
[recommendations, source],
|
| 195 |
+
)
|
| 196 |
+
const visibleSources = useMemo(() => {
|
| 197 |
+
if (searchType !== 'recommend') return SOURCES
|
| 198 |
+
if (!recommendationsLoaded) return SOURCES
|
| 199 |
+
const available = new Set(recommendations.map((recommendation) => recommendation.platform))
|
| 200 |
+
return SOURCES.filter((item) => available.has(item.id))
|
| 201 |
+
}, [recommendations, recommendationsLoaded, searchType])
|
| 202 |
+
|
| 203 |
+
// Auto re-search when source or type changes
|
| 204 |
+
const prevSourceRef = useRef(source)
|
| 205 |
+
const prevTypeRef = useRef(searchType)
|
| 206 |
+
useEffect(() => {
|
| 207 |
+
const sourceChanged = prevSourceRef.current !== source
|
| 208 |
+
const typeChanged = prevTypeRef.current !== searchType
|
| 209 |
+
prevSourceRef.current = source
|
| 210 |
+
prevTypeRef.current = searchType
|
| 211 |
+
if ((sourceChanged || typeChanged) && searchType !== 'recommend' && keyword.trim()) {
|
| 212 |
+
search(keyword.trim())
|
| 213 |
+
if (searchType === 'song') listRef.current?.scrollToTop()
|
| 214 |
+
}
|
| 215 |
+
}, [source, searchType, keyword, search])
|
| 216 |
+
|
| 217 |
+
useEffect(() => {
|
| 218 |
+
if (open && searchType === 'recommend' && !recommendationsLoaded && !recommendationsLoading) {
|
| 219 |
+
loadRecommendations()
|
| 220 |
+
}
|
| 221 |
+
}, [loadRecommendations, open, recommendationsLoaded, recommendationsLoading, searchType])
|
| 222 |
+
|
| 223 |
+
useEffect(() => {
|
| 224 |
+
if (searchType !== 'recommend' || !recommendationsLoaded || recommendations.length === 0) return
|
| 225 |
+
if (!recommendations.some((recommendation) => recommendation.platform === source)) {
|
| 226 |
+
const frame = requestAnimationFrame(() => setSource(recommendations[0]!.platform))
|
| 227 |
+
return () => cancelAnimationFrame(frame)
|
| 228 |
+
}
|
| 229 |
+
}, [recommendations, recommendationsLoaded, searchType, source])
|
| 230 |
+
|
| 231 |
+
// Measure active source button position for sliding pill
|
| 232 |
+
const measurePill = useCallback(() => {
|
| 233 |
+
const container = sourceContainerRef.current
|
| 234 |
+
if (!container) return
|
| 235 |
+
const activeBtn = container.querySelector<HTMLButtonElement>(`[data-source="${source}"]`)
|
| 236 |
+
if (!activeBtn) return
|
| 237 |
+
setPillStyle({ left: activeBtn.offsetLeft, width: activeBtn.offsetWidth })
|
| 238 |
+
}, [source])
|
| 239 |
+
|
| 240 |
+
useLayoutEffect(() => {
|
| 241 |
+
measurePill()
|
| 242 |
+
}, [measurePill, visibleSources])
|
| 243 |
+
|
| 244 |
+
// Re-measure after dialog opens (DOM may not be ready on first render)
|
| 245 |
+
useEffect(() => {
|
| 246 |
+
if (open) requestAnimationFrame(measurePill)
|
| 247 |
+
}, [open, measurePill])
|
| 248 |
+
|
| 249 |
+
useEffect(() => {
|
| 250 |
+
if (open) return
|
| 251 |
+
const frame = requestAnimationFrame(() => {
|
| 252 |
+
setSelectedAlbum(null)
|
| 253 |
+
resetRecommendations()
|
| 254 |
+
})
|
| 255 |
+
return () => cancelAnimationFrame(frame)
|
| 256 |
+
}, [open, resetRecommendations])
|
| 257 |
+
|
| 258 |
+
const handleSearch = (overrideKeyword?: string) => {
|
| 259 |
+
const searchKeyword = (overrideKeyword ?? keyword).trim()
|
| 260 |
+
if (!searchKeyword) return
|
| 261 |
+
if (overrideKeyword !== undefined) setKeyword(overrideKeyword)
|
| 262 |
+
setAddedIds(new Set())
|
| 263 |
+
search(searchKeyword)
|
| 264 |
+
if (searchType === 'song') {
|
| 265 |
+
listRef.current?.scrollToTop()
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const beginBilibiliMetadataMatch = useCallback((track: Track, action: BilibiliQueueAction) => {
|
| 270 |
+
setBilibiliMatch({ track, action })
|
| 271 |
+
}, [])
|
| 272 |
+
|
| 273 |
+
const beginBilibiliCollectionMatch = useCallback((track: Track, action: BilibiliQueueAction) => {
|
| 274 |
+
setBilibiliCollectionMatch({ track, action })
|
| 275 |
+
}, [])
|
| 276 |
+
|
| 277 |
+
const handleBilibiliNotCollection = useCallback(() => {
|
| 278 |
+
if (!bilibiliCollectionMatch) return
|
| 279 |
+
const { track, action } = bilibiliCollectionMatch
|
| 280 |
+
setBilibiliCollectionMatch(null)
|
| 281 |
+
beginBilibiliMetadataMatch(track, action)
|
| 282 |
+
}, [bilibiliCollectionMatch, beginBilibiliMetadataMatch])
|
| 283 |
+
|
| 284 |
+
const handleBilibiliCollectionTrack = useCallback(
|
| 285 |
+
(track: Track) => {
|
| 286 |
+
if (!bilibiliCollectionMatch) return
|
| 287 |
+
// Close the picker before opening the metadata dialog. Both dialogs use
|
| 288 |
+
// the same portal z-index, so leaving it open would cover the matcher.
|
| 289 |
+
const { action } = bilibiliCollectionMatch
|
| 290 |
+
setBilibiliCollectionMatch(null)
|
| 291 |
+
beginBilibiliMetadataMatch(track, action)
|
| 292 |
+
},
|
| 293 |
+
[bilibiliCollectionMatch, beginBilibiliMetadataMatch],
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
const applyBilibiliMetadataMatch = useCallback(
|
| 297 |
+
(metadataTrack: Track, metadataSource: BilibiliMetadataSource) => {
|
| 298 |
+
if (!bilibiliMatch) return
|
| 299 |
+
const track = {
|
| 300 |
+
...bilibiliMatch.track,
|
| 301 |
+
metadataSource,
|
| 302 |
+
lyricId: metadataTrack.lyricId,
|
| 303 |
+
picId: metadataTrack.picId,
|
| 304 |
+
cover: metadataTrack.cover || bilibiliMatch.track.cover,
|
| 305 |
+
}
|
| 306 |
+
if (bilibiliMatch.action === 'insert') {
|
| 307 |
+
onInsertAfterCurrent(track)
|
| 308 |
+
} else {
|
| 309 |
+
onAddToQueue(track)
|
| 310 |
+
}
|
| 311 |
+
setAddedIds((prev) => new Set(prev).add(trackKey(track)))
|
| 312 |
+
setBilibiliMatch(null)
|
| 313 |
+
},
|
| 314 |
+
[bilibiliMatch, onAddToQueue, onInsertAfterCurrent],
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
const skipBilibiliMetadataMatch = useCallback(() => {
|
| 318 |
+
if (!bilibiliMatch) return
|
| 319 |
+
if (bilibiliMatch.action === 'insert') {
|
| 320 |
+
onInsertAfterCurrent(bilibiliMatch.track)
|
| 321 |
+
} else {
|
| 322 |
+
onAddToQueue(bilibiliMatch.track)
|
| 323 |
+
}
|
| 324 |
+
setAddedIds((prev) => new Set(prev).add(trackKey(bilibiliMatch.track)))
|
| 325 |
+
setBilibiliMatch(null)
|
| 326 |
+
}, [bilibiliMatch, onAddToQueue, onInsertAfterCurrent])
|
| 327 |
+
|
| 328 |
+
const handleAdd = useCallback(
|
| 329 |
+
(track: Track) => {
|
| 330 |
+
const key = trackKey(track)
|
| 331 |
+
if (queueKeys.has(key) || addedIds.has(key)) {
|
| 332 |
+
toast.info(`「${track.title}」已在队列中`)
|
| 333 |
+
return
|
| 334 |
+
}
|
| 335 |
+
if (track.source === 'bilibili') {
|
| 336 |
+
beginBilibiliCollectionMatch(track, 'add')
|
| 337 |
+
return
|
| 338 |
+
}
|
| 339 |
+
onAddToQueue(track)
|
| 340 |
+
setAddedIds((prev) => new Set(prev).add(key))
|
| 341 |
+
// Removed duplicate toast.success since onAddToQueue (from useQueue) usually already handles it
|
| 342 |
+
// or the UI handles feedback.
|
| 343 |
+
},
|
| 344 |
+
[onAddToQueue, queueKeys, addedIds, beginBilibiliCollectionMatch],
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
const handleInsertAfterCurrent = useCallback(
|
| 348 |
+
(track: Track) => {
|
| 349 |
+
const key = trackKey(track)
|
| 350 |
+
if (queueKeys.has(key) || addedIds.has(key)) {
|
| 351 |
+
toast.info(`「${track.title}」已在队列中`)
|
| 352 |
+
return
|
| 353 |
+
}
|
| 354 |
+
if (track.source === 'bilibili') {
|
| 355 |
+
beginBilibiliCollectionMatch(track, 'insert')
|
| 356 |
+
return
|
| 357 |
+
}
|
| 358 |
+
onInsertAfterCurrent(track)
|
| 359 |
+
setAddedIds((prev) => new Set(prev).add(key))
|
| 360 |
+
// Removed duplicate toast.success
|
| 361 |
+
},
|
| 362 |
+
[onInsertAfterCurrent, queueKeys, addedIds, beginBilibiliCollectionMatch],
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
const handleAddBatch = useCallback(
|
| 366 |
+
(tracks: Track[], playlistName?: string) => {
|
| 367 |
+
if (tracks.length === 0) return
|
| 368 |
+
for (let offset = 0; offset < tracks.length; offset += LIMITS.QUEUE_BATCH_MAX_SIZE) {
|
| 369 |
+
socket.emit(EVENTS.QUEUE_ADD_BATCH, {
|
| 370 |
+
tracks: tracks.slice(offset, offset + LIMITS.QUEUE_BATCH_MAX_SIZE),
|
| 371 |
+
playlistName,
|
| 372 |
+
})
|
| 373 |
+
}
|
| 374 |
+
setAddedIds((prev) => {
|
| 375 |
+
const next = new Set(prev)
|
| 376 |
+
for (const t of tracks) next.add(trackKey(t))
|
| 377 |
+
return next
|
| 378 |
+
})
|
| 379 |
+
toast.success(`已添加 ${tracks.length} 首歌曲`)
|
| 380 |
+
},
|
| 381 |
+
[socket],
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
const isTrackAdded = useCallback(
|
| 385 |
+
(track: Track) => {
|
| 386 |
+
const key = trackKey(track)
|
| 387 |
+
return addedIds.has(key) || queueKeys.has(key)
|
| 388 |
+
},
|
| 389 |
+
[addedIds, queueKeys],
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
const handleSelectAlbum = (playlist: Playlist, type?: 'album' | 'playlist') => {
|
| 393 |
+
setSelectedAlbum(playlist)
|
| 394 |
+
fetchPlaylistTracks(
|
| 395 |
+
playlist.source,
|
| 396 |
+
playlist.id,
|
| 397 |
+
playlist.trackCount,
|
| 398 |
+
type ?? (searchType === 'album' ? 'album' : 'playlist'),
|
| 399 |
+
)
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
return (
|
| 403 |
+
<ResponsiveDialog open={open} onOpenChange={onOpenChange} mobileRepositionInputs={false}>
|
| 404 |
+
<ResponsiveDialogContent
|
| 405 |
+
ref={dialogContentRef}
|
| 406 |
+
className="flex h-dvh max-h-dvh flex-col overflow-hidden sm:h-auto sm:max-h-[80vh] sm:max-w-2xl"
|
| 407 |
+
>
|
| 408 |
+
<ResponsiveDialogHeader>
|
| 409 |
+
<div className="flex items-center gap-3">
|
| 410 |
+
<ResponsiveDialogTitle className="shrink-0">
|
| 411 |
+
{selectedAlbum ? selectedAlbum.name : searchType === 'recommend' ? '推荐点歌' : '搜索点歌'}
|
| 412 |
+
</ResponsiveDialogTitle>
|
| 413 |
+
{!selectedAlbum && visibleSources.length > 0 && (
|
| 414 |
+
<div ref={sourceContainerRef} className="bg-muted/50 relative flex items-center rounded-lg p-0.5">
|
| 415 |
+
<motion.div
|
| 416 |
+
className={cn('absolute inset-y-0.5 rounded-md', PLATFORM_ACTIVE[source])}
|
| 417 |
+
animate={{ left: pillStyle.left, width: pillStyle.width }}
|
| 418 |
+
transition={{ type: 'spring', bounce: 0.15, duration: 0.3 }}
|
| 419 |
+
/>
|
| 420 |
+
{visibleSources.map((s) => (
|
| 421 |
+
<button
|
| 422 |
+
key={s.id}
|
| 423 |
+
data-source={s.id}
|
| 424 |
+
className={cn(
|
| 425 |
+
'relative z-10 rounded-md px-2.5 py-0.5 text-xs font-medium transition-colors',
|
| 426 |
+
source === s.id ? PLATFORM_TEXT[s.id] : 'text-muted-foreground hover:text-foreground',
|
| 427 |
+
)}
|
| 428 |
+
onClick={() => {
|
| 429 |
+
setSource(s.id)
|
| 430 |
+
if (
|
| 431 |
+
(s.id === 'bilibili' || s.id === 'kugou_concept') &&
|
| 432 |
+
(searchType === 'album' || searchType === 'playlist')
|
| 433 |
+
) {
|
| 434 |
+
setSearchType('song')
|
| 435 |
+
}
|
| 436 |
+
resetState()
|
| 437 |
+
setAddedIds(new Set())
|
| 438 |
+
}}
|
| 439 |
+
>
|
| 440 |
+
{s.label}
|
| 441 |
+
</button>
|
| 442 |
+
))}
|
| 443 |
+
</div>
|
| 444 |
+
)}
|
| 445 |
+
</div>
|
| 446 |
+
</ResponsiveDialogHeader>
|
| 447 |
+
|
| 448 |
+
<ResponsiveDialogBody className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
| 449 |
+
{selectedAlbum ? (
|
| 450 |
+
<PlaylistDetail
|
| 451 |
+
playlist={selectedAlbum}
|
| 452 |
+
tracks={playlistTracks}
|
| 453 |
+
loading={tracksLoading}
|
| 454 |
+
loadingMore={albumLoadingMore}
|
| 455 |
+
hasMore={hasMoreTracks}
|
| 456 |
+
total={playlistTotal}
|
| 457 |
+
onBack={() => setSelectedAlbum(null)}
|
| 458 |
+
onAddTrack={handleAdd}
|
| 459 |
+
onInsertAfterCurrent={handleInsertAfterCurrent}
|
| 460 |
+
onAddAll={handleAddBatch}
|
| 461 |
+
onLoadAll={fetchAllPlaylistTracks}
|
| 462 |
+
onLoadMore={loadMoreTracks}
|
| 463 |
+
/>
|
| 464 |
+
) : (
|
| 465 |
+
<>
|
| 466 |
+
<Tabs
|
| 467 |
+
value={searchType}
|
| 468 |
+
onValueChange={(value) => {
|
| 469 |
+
setSearchType(value as SearchMode)
|
| 470 |
+
resetState()
|
| 471 |
+
setAddedIds(new Set())
|
| 472 |
+
}}
|
| 473 |
+
>
|
| 474 |
+
<TabsList className="w-full">
|
| 475 |
+
<TabsTrigger value="song" className="flex-1 text-xs sm:text-sm">
|
| 476 |
+
单曲
|
| 477 |
+
</TabsTrigger>
|
| 478 |
+
<TabsTrigger value="recommend" className="flex-1 gap-1 text-xs sm:text-sm">
|
| 479 |
+
<Sparkles className="h-3.5 w-3.5" />
|
| 480 |
+
推荐
|
| 481 |
+
</TabsTrigger>
|
| 482 |
+
{source !== 'bilibili' && source !== 'kugou_concept' && (
|
| 483 |
+
<>
|
| 484 |
+
<TabsTrigger value="album" className="flex-1 text-xs sm:text-sm">
|
| 485 |
+
专辑
|
| 486 |
+
</TabsTrigger>
|
| 487 |
+
<TabsTrigger value="playlist" className="flex-1 text-xs sm:text-sm">
|
| 488 |
+
歌单
|
| 489 |
+
</TabsTrigger>
|
| 490 |
+
</>
|
| 491 |
+
)}
|
| 492 |
+
</TabsList>
|
| 493 |
+
</Tabs>
|
| 494 |
+
|
| 495 |
+
{searchType === 'recommend' ? (
|
| 496 |
+
<div className="bg-muted/40 flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
| 497 |
+
<div className="flex min-w-0 items-center gap-2 text-sm">
|
| 498 |
+
<Sparkles className={cn('h-4 w-4 shrink-0', PLATFORM_TEXT[source])} />
|
| 499 |
+
<span className="truncate">展示当前账号在平台上的原生推荐内容</span>
|
| 500 |
+
</div>
|
| 501 |
+
<Button
|
| 502 |
+
variant="ghost"
|
| 503 |
+
size="sm"
|
| 504 |
+
onClick={() => loadRecommendations()}
|
| 505 |
+
disabled={recommendationsLoading || recommendationsLoadingMore}
|
| 506 |
+
aria-label="刷新推荐"
|
| 507 |
+
>
|
| 508 |
+
<RefreshCw className={cn('h-4 w-4', recommendationsLoading && 'animate-spin')} />
|
| 509 |
+
</Button>
|
| 510 |
+
</div>
|
| 511 |
+
) : (
|
| 512 |
+
<div className="flex gap-2">
|
| 513 |
+
<Input
|
| 514 |
+
placeholder={
|
| 515 |
+
source === 'bilibili'
|
| 516 |
+
? '搜索视频,或粘贴 B站链接 / BV号...'
|
| 517 |
+
: searchType === 'song'
|
| 518 |
+
? '搜索歌曲、歌手...'
|
| 519 |
+
: searchType === 'album'
|
| 520 |
+
? '搜索专辑...'
|
| 521 |
+
: '搜索歌单...'
|
| 522 |
+
}
|
| 523 |
+
value={keyword}
|
| 524 |
+
onChange={(e) => setKeyword(e.target.value)}
|
| 525 |
+
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
| 526 |
+
className="flex-1"
|
| 527 |
+
autoFocus
|
| 528 |
+
aria-label="搜索关键词"
|
| 529 |
+
/>
|
| 530 |
+
<Button onClick={() => handleSearch()} disabled={loading} aria-label="搜索">
|
| 531 |
+
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
| 532 |
+
</Button>
|
| 533 |
+
</div>
|
| 534 |
+
)}
|
| 535 |
+
|
| 536 |
+
{/* Results area — virtual scrolling with auto-load */}
|
| 537 |
+
{searchType === 'recommend' ? (
|
| 538 |
+
!recommendationsLoaded || recommendationsLoading ? (
|
| 539 |
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
|
| 540 |
+
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
| 541 |
+
</div>
|
| 542 |
+
) : recommendations.length === 0 ? (
|
| 543 |
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
|
| 544 |
+
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-2 px-6 text-center">
|
| 545 |
+
<Sparkles className="h-8 w-8" />
|
| 546 |
+
<span className="text-sm">请先在设置中登录音乐平台,再查看平台推荐</span>
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
) : source === 'bilibili' ? (
|
| 550 |
+
<VirtualTrackList
|
| 551 |
+
ref={listRef}
|
| 552 |
+
tracks={activeRecommendation?.tracks ?? []}
|
| 553 |
+
loading={false}
|
| 554 |
+
hasMore={false}
|
| 555 |
+
loadingMore={false}
|
| 556 |
+
onLoadMore={() => undefined}
|
| 557 |
+
isTrackAdded={isTrackAdded}
|
| 558 |
+
onAddTrack={handleAdd}
|
| 559 |
+
onInsertAfterCurrent={handleInsertAfterCurrent}
|
| 560 |
+
onArtistClick={(artist) => {
|
| 561 |
+
setSearchType('song')
|
| 562 |
+
handleSearch(artist)
|
| 563 |
+
}}
|
| 564 |
+
emptyIcon={<Sparkles className="h-8 w-8" />}
|
| 565 |
+
emptyMessage={
|
| 566 |
+
activeRecommendation?.unavailableReason === 'upstream_unavailable'
|
| 567 |
+
? '平台推荐暂时不可用,请刷新重试'
|
| 568 |
+
: '平台暂时没有返回推荐内容'
|
| 569 |
+
}
|
| 570 |
+
/>
|
| 571 |
+
) : source === 'tencent' ? (
|
| 572 |
+
<Tabs
|
| 573 |
+
value={tencentRecommendationView}
|
| 574 |
+
onValueChange={(value) => setTencentRecommendationView(value as TencentRecommendationView)}
|
| 575 |
+
className="min-h-0 flex-1 gap-2"
|
| 576 |
+
>
|
| 577 |
+
<TabsList className="grid w-full grid-cols-2">
|
| 578 |
+
<TabsTrigger value="radar">
|
| 579 |
+
<Radio />
|
| 580 |
+
雷达歌曲
|
| 581 |
+
</TabsTrigger>
|
| 582 |
+
<TabsTrigger value="playlists">
|
| 583 |
+
<ListMusic />
|
| 584 |
+
推荐歌单
|
| 585 |
+
</TabsTrigger>
|
| 586 |
+
</TabsList>
|
| 587 |
+
<TabsContent value="radar" className="flex min-h-0 flex-1 flex-col">
|
| 588 |
+
<VirtualTrackList
|
| 589 |
+
ref={listRef}
|
| 590 |
+
tracks={activeRecommendation?.tracks ?? []}
|
| 591 |
+
loading={false}
|
| 592 |
+
hasMore={activeRecommendation?.pagination?.tracks?.hasMore ?? false}
|
| 593 |
+
loadingMore={recommendationsLoadingMore}
|
| 594 |
+
onLoadMore={loadMoreRecommendations}
|
| 595 |
+
isTrackAdded={isTrackAdded}
|
| 596 |
+
onAddTrack={handleAdd}
|
| 597 |
+
onInsertAfterCurrent={handleInsertAfterCurrent}
|
| 598 |
+
onArtistClick={(artist) => {
|
| 599 |
+
setSearchType('song')
|
| 600 |
+
handleSearch(artist)
|
| 601 |
+
}}
|
| 602 |
+
emptyIcon={<Radio className="h-8 w-8" />}
|
| 603 |
+
emptyMessage={
|
| 604 |
+
activeRecommendation?.unavailableReason === 'upstream_unavailable'
|
| 605 |
+
? 'QQ 雷达暂时不可用,请刷新重试'
|
| 606 |
+
: 'QQ 雷达暂时没有返回歌曲'
|
| 607 |
+
}
|
| 608 |
+
/>
|
| 609 |
+
</TabsContent>
|
| 610 |
+
<TabsContent value="playlists" className="flex min-h-0 flex-1 flex-col">
|
| 611 |
+
{(activeRecommendation?.playlists?.length ?? 0) > 0 ? (
|
| 612 |
+
<PlaylistList
|
| 613 |
+
playlists={activeRecommendation?.playlists ?? []}
|
| 614 |
+
onSelect={(playlist) => handleSelectAlbum(playlist, 'playlist')}
|
| 615 |
+
hasMore={activeRecommendation?.pagination?.playlists?.hasMore ?? false}
|
| 616 |
+
loadingMore={recommendationsLoadingMore}
|
| 617 |
+
onLoadMore={loadMoreRecommendations}
|
| 618 |
+
/>
|
| 619 |
+
) : (
|
| 620 |
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
|
| 621 |
+
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-2 px-6 text-center">
|
| 622 |
+
<ListMusic className="h-8 w-8" />
|
| 623 |
+
<span className="text-sm">
|
| 624 |
+
{activeRecommendation?.unavailableReason === 'upstream_unavailable'
|
| 625 |
+
? 'QQ 推荐歌单暂时不可用,请刷新重试'
|
| 626 |
+
: 'QQ 暂时没有返回推荐歌单'}
|
| 627 |
+
</span>
|
| 628 |
+
</div>
|
| 629 |
+
</div>
|
| 630 |
+
)}
|
| 631 |
+
</TabsContent>
|
| 632 |
+
</Tabs>
|
| 633 |
+
) : (activeRecommendation?.playlists?.length ?? 0) > 0 ? (
|
| 634 |
+
<PlaylistList
|
| 635 |
+
playlists={activeRecommendation?.playlists ?? []}
|
| 636 |
+
onSelect={(playlist) => handleSelectAlbum(playlist, 'playlist')}
|
| 637 |
+
/>
|
| 638 |
+
) : (
|
| 639 |
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
|
| 640 |
+
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-2 px-6 text-center">
|
| 641 |
+
<Sparkles className="h-8 w-8" />
|
| 642 |
+
<span className="text-sm">
|
| 643 |
+
{activeRecommendation?.unavailableReason === 'upstream_unavailable'
|
| 644 |
+
? '平台推荐暂时不可用,请刷新重试'
|
| 645 |
+
: '平台暂时没有返回推荐歌单'}
|
| 646 |
+
</span>
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
)
|
| 650 |
+
) : hasSearched ? (
|
| 651 |
+
searchType === 'song' ? (
|
| 652 |
+
<VirtualTrackList
|
| 653 |
+
ref={listRef}
|
| 654 |
+
tracks={results as Track[]}
|
| 655 |
+
loading={loading}
|
| 656 |
+
hasMore={hasMore}
|
| 657 |
+
loadingMore={loadingMore}
|
| 658 |
+
onLoadMore={loadMore}
|
| 659 |
+
isTrackAdded={isTrackAdded}
|
| 660 |
+
onAddTrack={handleAdd}
|
| 661 |
+
onInsertAfterCurrent={handleInsertAfterCurrent}
|
| 662 |
+
onArtistClick={(artist) => {
|
| 663 |
+
setSearchType('song')
|
| 664 |
+
handleSearch(artist)
|
| 665 |
+
}}
|
| 666 |
+
emptyIcon={<Music2 className="h-8 w-8" />}
|
| 667 |
+
emptyMessage={
|
| 668 |
+
source === 'bilibili' ? '暂无结果,请检查链接、BV号或更换关键词' : '暂无结果,换个关键词试试'
|
| 669 |
+
}
|
| 670 |
+
/>
|
| 671 |
+
) : loading && results.length === 0 ? (
|
| 672 |
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
|
| 673 |
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
| 674 |
+
</div>
|
| 675 |
+
) : results.length === 0 ? (
|
| 676 |
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
|
| 677 |
+
<div className="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
| 678 |
+
<Music2 className="h-8 w-8" />
|
| 679 |
+
<span className="text-sm">暂无结果,换个关键词试试</span>
|
| 680 |
+
</div>
|
| 681 |
+
</div>
|
| 682 |
+
) : (
|
| 683 |
+
<PlaylistList
|
| 684 |
+
playlists={results as Playlist[]}
|
| 685 |
+
onSelect={handleSelectAlbum}
|
| 686 |
+
hasMore={hasMore}
|
| 687 |
+
loadingMore={loadingMore}
|
| 688 |
+
onLoadMore={loadMore}
|
| 689 |
+
/>
|
| 690 |
+
)
|
| 691 |
+
) : (
|
| 692 |
+
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border">
|
| 693 |
+
<div className="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
| 694 |
+
<Music2 className="h-8 w-8" />
|
| 695 |
+
<span className="text-sm">输入关键词开始搜索</span>
|
| 696 |
+
</div>
|
| 697 |
+
</div>
|
| 698 |
+
)}
|
| 699 |
+
</>
|
| 700 |
+
)}
|
| 701 |
+
</ResponsiveDialogBody>
|
| 702 |
+
</ResponsiveDialogContent>
|
| 703 |
+
|
| 704 |
+
<BilibiliMetadataDialog
|
| 705 |
+
track={bilibiliMatch?.track ?? null}
|
| 706 |
+
roomId={roomId}
|
| 707 |
+
onOpenChange={(isOpen) => !isOpen && setBilibiliMatch(null)}
|
| 708 |
+
onSelect={applyBilibiliMetadataMatch}
|
| 709 |
+
onSkip={skipBilibiliMetadataMatch}
|
| 710 |
+
/>
|
| 711 |
+
<BilibiliCollectionDialog
|
| 712 |
+
track={bilibiliCollectionMatch?.track ?? null}
|
| 713 |
+
onOpenChange={(isOpen) => !isOpen && setBilibiliCollectionMatch(null)}
|
| 714 |
+
onSelectTrack={handleBilibiliCollectionTrack}
|
| 715 |
+
onNotCollection={handleBilibiliNotCollection}
|
| 716 |
+
isTrackAdded={isTrackAdded}
|
| 717 |
+
/>
|
| 718 |
+
</ResponsiveDialog>
|
| 719 |
+
)
|
| 720 |
+
}
|
packages/client/src/components/Overlays/Settings/AccountSection.tsx
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Badge } from '@/components/ui/badge'
|
| 2 |
+
import { Button } from '@/components/ui/button'
|
| 3 |
+
import { Input } from '@/components/ui/input'
|
| 4 |
+
import { Separator } from '@/components/ui/separator'
|
| 5 |
+
import { loginIdentity, logoutIdentity, setInitialPassword, updateAccountId } from '@/lib/identityAuth'
|
| 6 |
+
import { useSocketContext } from '@/providers/socket-context'
|
| 7 |
+
import { useAccountStore } from '@/stores/accountStore'
|
| 8 |
+
import { Check, Copy, KeyRound, Loader2, LogIn, LogOut, Pencil, ShieldCheck, X } from 'lucide-react'
|
| 9 |
+
import { useEffect, useState } from 'react'
|
| 10 |
+
import { toast } from 'sonner'
|
| 11 |
+
|
| 12 |
+
const ACCOUNT_ID_PATTERN = /^[a-z0-9_-]{3,32}$/
|
| 13 |
+
|
| 14 |
+
export function AccountIdEditor({ compact = false, onComplete }: { compact?: boolean; onComplete?: () => void }) {
|
| 15 |
+
const { socket } = useSocketContext()
|
| 16 |
+
const profile = useAccountStore((state) => state.profile)
|
| 17 |
+
const [editing, setEditing] = useState(!compact)
|
| 18 |
+
const [accountId, setAccountId] = useState(profile?.id ?? '')
|
| 19 |
+
const [currentPassword, setCurrentPassword] = useState('')
|
| 20 |
+
const [saving, setSaving] = useState(false)
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
setAccountId(profile?.id ?? '')
|
| 24 |
+
}, [profile?.id])
|
| 25 |
+
|
| 26 |
+
const copyAccountId = async () => {
|
| 27 |
+
if (!profile?.id) return
|
| 28 |
+
await navigator.clipboard.writeText(profile.id)
|
| 29 |
+
toast.success('账号 ID 已复制')
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const cancel = () => {
|
| 33 |
+
setAccountId(profile?.id ?? '')
|
| 34 |
+
setCurrentPassword('')
|
| 35 |
+
setEditing(false)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const save = async () => {
|
| 39 |
+
const normalized = accountId.trim().toLowerCase()
|
| 40 |
+
if (!ACCOUNT_ID_PATTERN.test(normalized) || normalized === profile?.id) return
|
| 41 |
+
setSaving(true)
|
| 42 |
+
try {
|
| 43 |
+
await updateAccountId(socket, normalized, currentPassword || undefined)
|
| 44 |
+
setCurrentPassword('')
|
| 45 |
+
setEditing(!compact)
|
| 46 |
+
toast.success('账号 ID 已修改')
|
| 47 |
+
onComplete?.()
|
| 48 |
+
} catch (error) {
|
| 49 |
+
toast.error(error instanceof Error ? error.message : '账号 ID 修改失败')
|
| 50 |
+
} finally {
|
| 51 |
+
setSaving(false)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (compact && !editing) {
|
| 56 |
+
return (
|
| 57 |
+
<div className="flex items-center justify-between gap-3">
|
| 58 |
+
<div className="min-w-0">
|
| 59 |
+
<span className="text-xs font-medium text-muted-foreground">账号 ID</span>
|
| 60 |
+
<code className="block truncate text-[11px]">{profile?.id ?? '初始化中'}</code>
|
| 61 |
+
</div>
|
| 62 |
+
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => setEditing(true)}>
|
| 63 |
+
<Pencil className="h-4 w-4" />
|
| 64 |
+
<span className="sr-only">修改账号 ID</span>
|
| 65 |
+
</Button>
|
| 66 |
+
</div>
|
| 67 |
+
)
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const normalized = accountId.trim().toLowerCase()
|
| 71 |
+
const canSave =
|
| 72 |
+
ACCOUNT_ID_PATTERN.test(normalized) &&
|
| 73 |
+
normalized !== profile?.id &&
|
| 74 |
+
(!profile?.hasPassword || currentPassword.length > 0)
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div className="space-y-2">
|
| 78 |
+
<div className="flex items-center justify-between gap-2">
|
| 79 |
+
<label
|
| 80 |
+
htmlFor={compact ? 'compact-account-id' : 'account-id'}
|
| 81 |
+
className="text-xs font-medium text-muted-foreground"
|
| 82 |
+
>
|
| 83 |
+
账号 ID
|
| 84 |
+
</label>
|
| 85 |
+
{!compact && (
|
| 86 |
+
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => void copyAccountId()}>
|
| 87 |
+
<Copy className="h-3.5 w-3.5" />
|
| 88 |
+
<span className="sr-only">复制账号 ID</span>
|
| 89 |
+
</Button>
|
| 90 |
+
)}
|
| 91 |
+
</div>
|
| 92 |
+
<Input
|
| 93 |
+
id={compact ? 'compact-account-id' : 'account-id'}
|
| 94 |
+
value={accountId}
|
| 95 |
+
onChange={(event) => setAccountId(event.target.value.toLowerCase())}
|
| 96 |
+
placeholder="3-32 位小写字母、数字、_ 或 -"
|
| 97 |
+
maxLength={32}
|
| 98 |
+
className="h-8 font-mono text-xs"
|
| 99 |
+
/>
|
| 100 |
+
{profile?.hasPassword && (
|
| 101 |
+
<Input
|
| 102 |
+
type="password"
|
| 103 |
+
value={currentPassword}
|
| 104 |
+
onChange={(event) => setCurrentPassword(event.target.value)}
|
| 105 |
+
onKeyDown={(event) => event.key === 'Enter' && canSave && void save()}
|
| 106 |
+
placeholder="当前密码"
|
| 107 |
+
maxLength={128}
|
| 108 |
+
className="h-8"
|
| 109 |
+
/>
|
| 110 |
+
)}
|
| 111 |
+
<div className="flex justify-end gap-2">
|
| 112 |
+
{compact && (
|
| 113 |
+
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={cancel} disabled={saving}>
|
| 114 |
+
<X className="h-4 w-4" />
|
| 115 |
+
取消
|
| 116 |
+
</Button>
|
| 117 |
+
)}
|
| 118 |
+
<Button type="button" size="sm" className="h-8" onClick={() => void save()} disabled={saving || !canSave}>
|
| 119 |
+
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
| 120 |
+
保存 ID
|
| 121 |
+
</Button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export function AccountAccessControls({ compact = false, onComplete }: { compact?: boolean; onComplete?: () => void }) {
|
| 128 |
+
const { socket } = useSocketContext()
|
| 129 |
+
const profile = useAccountStore((state) => state.profile)
|
| 130 |
+
const [newPassword, setNewPassword] = useState('')
|
| 131 |
+
const [accountId, setAccountId] = useState('')
|
| 132 |
+
const [loginPassword, setLoginPassword] = useState('')
|
| 133 |
+
const [busy, setBusy] = useState(false)
|
| 134 |
+
|
| 135 |
+
const run = async (action: () => Promise<unknown>, success: string) => {
|
| 136 |
+
setBusy(true)
|
| 137 |
+
try {
|
| 138 |
+
await action()
|
| 139 |
+
setNewPassword('')
|
| 140 |
+
setLoginPassword('')
|
| 141 |
+
toast.success(success)
|
| 142 |
+
onComplete?.()
|
| 143 |
+
} catch (error) {
|
| 144 |
+
toast.error(error instanceof Error ? error.message : '账号操作失败')
|
| 145 |
+
} finally {
|
| 146 |
+
setBusy(false)
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if (profile?.hasPassword) {
|
| 151 |
+
return (
|
| 152 |
+
<div className="flex items-center justify-between gap-3">
|
| 153 |
+
<div className="flex min-w-0 items-center gap-2 text-sm">
|
| 154 |
+
<ShieldCheck className="h-4 w-4 shrink-0 text-emerald-600" />
|
| 155 |
+
<span className="truncate">账号已启用密码</span>
|
| 156 |
+
</div>
|
| 157 |
+
<Button
|
| 158 |
+
type="button"
|
| 159 |
+
variant="outline"
|
| 160 |
+
size="sm"
|
| 161 |
+
disabled={busy}
|
| 162 |
+
onClick={() => void run(() => logoutIdentity(socket), '已退出账号并切换到访客身份')}
|
| 163 |
+
>
|
| 164 |
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="h-4 w-4" />}
|
| 165 |
+
退出
|
| 166 |
+
</Button>
|
| 167 |
+
</div>
|
| 168 |
+
)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
return (
|
| 172 |
+
<div className={compact ? 'space-y-3' : 'space-y-5'}>
|
| 173 |
+
<div className="space-y-2">
|
| 174 |
+
<div className="flex items-center gap-2 text-sm font-medium">
|
| 175 |
+
<KeyRound className="h-4 w-4" />
|
| 176 |
+
保护当前账号
|
| 177 |
+
</div>
|
| 178 |
+
<div className="flex gap-2">
|
| 179 |
+
<Input
|
| 180 |
+
type="password"
|
| 181 |
+
value={newPassword}
|
| 182 |
+
onChange={(event) => setNewPassword(event.target.value)}
|
| 183 |
+
placeholder="设置密码(至少 8 位)"
|
| 184 |
+
minLength={8}
|
| 185 |
+
maxLength={128}
|
| 186 |
+
className="h-8"
|
| 187 |
+
/>
|
| 188 |
+
<Button
|
| 189 |
+
type="button"
|
| 190 |
+
size="sm"
|
| 191 |
+
className="h-8"
|
| 192 |
+
disabled={busy || newPassword.length < 8}
|
| 193 |
+
onClick={() => void run(() => setInitialPassword(newPassword), '账号密码已设置')}
|
| 194 |
+
>
|
| 195 |
+
设置
|
| 196 |
+
</Button>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<Separator />
|
| 201 |
+
|
| 202 |
+
<div className="space-y-2">
|
| 203 |
+
<div className="flex items-center gap-2 text-sm font-medium">
|
| 204 |
+
<LogIn className="h-4 w-4" />
|
| 205 |
+
登录已有账号
|
| 206 |
+
</div>
|
| 207 |
+
<Input
|
| 208 |
+
value={accountId}
|
| 209 |
+
onChange={(event) => setAccountId(event.target.value)}
|
| 210 |
+
placeholder="账号 ID"
|
| 211 |
+
className="h-8 font-mono text-xs"
|
| 212 |
+
/>
|
| 213 |
+
<div className="flex gap-2">
|
| 214 |
+
<Input
|
| 215 |
+
type="password"
|
| 216 |
+
value={loginPassword}
|
| 217 |
+
onChange={(event) => setLoginPassword(event.target.value)}
|
| 218 |
+
onKeyDown={(event) => {
|
| 219 |
+
if (event.key === 'Enter' && accountId.trim() && loginPassword) {
|
| 220 |
+
void run(() => loginIdentity(socket, accountId, loginPassword), '账号登录成功')
|
| 221 |
+
}
|
| 222 |
+
}}
|
| 223 |
+
placeholder="密码"
|
| 224 |
+
className="h-8"
|
| 225 |
+
/>
|
| 226 |
+
<Button
|
| 227 |
+
type="button"
|
| 228 |
+
size="sm"
|
| 229 |
+
className="h-8"
|
| 230 |
+
disabled={busy || !accountId.trim() || !loginPassword}
|
| 231 |
+
onClick={() => void run(() => loginIdentity(socket, accountId, loginPassword), '账号登录成功')}
|
| 232 |
+
>
|
| 233 |
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : '登录'}
|
| 234 |
+
</Button>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
)
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
export function AccountSection() {
|
| 242 |
+
const profile = useAccountStore((state) => state.profile)
|
| 243 |
+
|
| 244 |
+
if (!profile) {
|
| 245 |
+
return (
|
| 246 |
+
<div className="mx-auto w-full max-w-xl space-y-2">
|
| 247 |
+
<h3 className="text-base font-semibold">账号</h3>
|
| 248 |
+
<p className="text-sm text-muted-foreground">请先在个人资料中保存昵称,系统随后会生成账号 ID。</p>
|
| 249 |
+
</div>
|
| 250 |
+
)
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
return (
|
| 254 |
+
<div className="mx-auto w-full max-w-xl space-y-6">
|
| 255 |
+
<div>
|
| 256 |
+
<div className="flex items-center justify-between gap-3">
|
| 257 |
+
<div>
|
| 258 |
+
<h3 className="text-base font-semibold">账号</h3>
|
| 259 |
+
<p className="mt-1 text-xs text-muted-foreground">
|
| 260 |
+
{profile?.hasPassword ? '已登录账号' : '当前为访客身份'}
|
| 261 |
+
</p>
|
| 262 |
+
</div>
|
| 263 |
+
{profile?.role === 'admin' && <Badge variant="secondary">服务器管理员</Badge>}
|
| 264 |
+
</div>
|
| 265 |
+
<Separator className="mt-3" />
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<AccountIdEditor />
|
| 269 |
+
|
| 270 |
+
<AccountAccessControls />
|
| 271 |
+
</div>
|
| 272 |
+
)
|
| 273 |
+
}
|
packages/client/src/components/Overlays/Settings/AdminSection.tsx
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Badge } from '@/components/ui/badge'
|
| 2 |
+
import { Button } from '@/components/ui/button'
|
| 3 |
+
import { Input } from '@/components/ui/input'
|
| 4 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
| 5 |
+
import { Slider } from '@/components/ui/slider'
|
| 6 |
+
import { Switch } from '@/components/ui/switch'
|
| 7 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
| 8 |
+
import { useSocketEvent } from '@/hooks/useSocketEvent'
|
| 9 |
+
import { requestJson } from '@/lib/identityAuth'
|
| 10 |
+
import { useAccountStore } from '@/stores/accountStore'
|
| 11 |
+
import { SERVER_URL } from '@/lib/config'
|
| 12 |
+
import { EVENTS, type AudioProxyPolicy, type ColorPreset, type GlobalBackgroundSettings } from '@music-together/shared'
|
| 13 |
+
import { DoorClosed, ImagePlus, Link2, Loader2, Network, RefreshCw, Trash2, Upload, Users, X } from 'lucide-react'
|
| 14 |
+
import { useCallback, useEffect, useState } from 'react'
|
| 15 |
+
import { toast } from 'sonner'
|
| 16 |
+
import { SettingRow } from './SettingRow'
|
| 17 |
+
|
| 18 |
+
interface AdminUser {
|
| 19 |
+
id: string
|
| 20 |
+
nickname: string
|
| 21 |
+
avatarUrl: string | null
|
| 22 |
+
role: 'user' | 'admin'
|
| 23 |
+
hasPassword: boolean
|
| 24 |
+
createdAt: number
|
| 25 |
+
updatedAt: number
|
| 26 |
+
lastSeenAt: number
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
interface AdminRoom {
|
| 30 |
+
id: string
|
| 31 |
+
name: string
|
| 32 |
+
creatorId: string
|
| 33 |
+
userCount: number
|
| 34 |
+
hasPassword: boolean
|
| 35 |
+
hidden: boolean
|
| 36 |
+
permanent: boolean
|
| 37 |
+
currentTrackTitle: string | null
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function AdminSection() {
|
| 41 |
+
const currentUserId = useAccountStore((state) => state.profile?.id)
|
| 42 |
+
const [users, setUsers] = useState<AdminUser[]>([])
|
| 43 |
+
const [rooms, setRooms] = useState<AdminRoom[]>([])
|
| 44 |
+
const [audioProxyPolicy, setAudioProxyPolicy] = useState<AudioProxyPolicy>({
|
| 45 |
+
kugouForceProxy: true,
|
| 46 |
+
})
|
| 47 |
+
const [globalBackground, setGlobalBackground] = useState<GlobalBackgroundSettings>({
|
| 48 |
+
backgroundUrl: null,
|
| 49 |
+
glassOverlay: false,
|
| 50 |
+
colorPreset: 'gold',
|
| 51 |
+
backgroundBrightness: 60,
|
| 52 |
+
autoTint: false,
|
| 53 |
+
})
|
| 54 |
+
const [passwords, setPasswords] = useState<Record<string, string>>({})
|
| 55 |
+
const [loading, setLoading] = useState(true)
|
| 56 |
+
const [workingId, setWorkingId] = useState<string | null>(null)
|
| 57 |
+
const [updatingProxyPolicy, setUpdatingProxyPolicy] = useState(false)
|
| 58 |
+
const [updatingBackground, setUpdatingBackground] = useState(false)
|
| 59 |
+
const [backgroundUrlInput, setBackgroundUrlInput] = useState('')
|
| 60 |
+
|
| 61 |
+
const load = useCallback(async () => {
|
| 62 |
+
setLoading(true)
|
| 63 |
+
try {
|
| 64 |
+
const [userData, roomData, proxyPolicy, background] = await Promise.all([
|
| 65 |
+
requestJson<{ users: AdminUser[] }>('/api/admin/users'),
|
| 66 |
+
requestJson<{ rooms: AdminRoom[] }>('/api/admin/rooms'),
|
| 67 |
+
requestJson<AudioProxyPolicy>('/api/admin/audio-proxy-policy'),
|
| 68 |
+
requestJson<GlobalBackgroundSettings>('/api/admin/background'),
|
| 69 |
+
])
|
| 70 |
+
setUsers(userData.users)
|
| 71 |
+
setRooms(roomData.rooms)
|
| 72 |
+
setAudioProxyPolicy(proxyPolicy)
|
| 73 |
+
setGlobalBackground(background)
|
| 74 |
+
} catch (error) {
|
| 75 |
+
toast.error(error instanceof Error ? error.message : '管理员数据加载失败')
|
| 76 |
+
} finally {
|
| 77 |
+
setLoading(false)
|
| 78 |
+
}
|
| 79 |
+
}, [])
|
| 80 |
+
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
void load()
|
| 83 |
+
}, [load])
|
| 84 |
+
|
| 85 |
+
useSocketEvent(EVENTS.SERVER_AUDIO_PROXY_POLICY, setAudioProxyPolicy)
|
| 86 |
+
|
| 87 |
+
const updateAudioProxyPolicy = async (checked: boolean) => {
|
| 88 |
+
setUpdatingProxyPolicy(true)
|
| 89 |
+
try {
|
| 90 |
+
const policy = await requestJson<AudioProxyPolicy>('/api/admin/audio-proxy-policy', {
|
| 91 |
+
method: 'PATCH',
|
| 92 |
+
body: JSON.stringify({ kugouForceProxy: checked }),
|
| 93 |
+
})
|
| 94 |
+
setAudioProxyPolicy(policy)
|
| 95 |
+
toast.success('音频代理策略已更新')
|
| 96 |
+
} catch (error) {
|
| 97 |
+
toast.error(error instanceof Error ? error.message : '音频代理策略更新失败')
|
| 98 |
+
} finally {
|
| 99 |
+
setUpdatingProxyPolicy(false)
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const uploadBackground = async (file?: File) => {
|
| 104 |
+
if (!file) return
|
| 105 |
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
| 106 |
+
toast.error('仅支持 PNG、JPEG 和 WebP 图片')
|
| 107 |
+
return
|
| 108 |
+
}
|
| 109 |
+
if (file.size > 6 * 1024 * 1024) {
|
| 110 |
+
toast.error('背景图片不能超过 6MB')
|
| 111 |
+
return
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
setUpdatingBackground(true)
|
| 115 |
+
try {
|
| 116 |
+
const image = await new Promise<string>((resolve, reject) => {
|
| 117 |
+
const reader = new FileReader()
|
| 118 |
+
reader.onload = () => resolve(String(reader.result))
|
| 119 |
+
reader.onerror = () => reject(new Error('读取图片失败'))
|
| 120 |
+
reader.readAsDataURL(file)
|
| 121 |
+
})
|
| 122 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', {
|
| 123 |
+
method: 'POST',
|
| 124 |
+
body: JSON.stringify({ image }),
|
| 125 |
+
})
|
| 126 |
+
setGlobalBackground(settings)
|
| 127 |
+
toast.success('全局背景已更新')
|
| 128 |
+
} catch (error) {
|
| 129 |
+
toast.error(error instanceof Error ? error.message : '背景上传失败')
|
| 130 |
+
} finally {
|
| 131 |
+
setUpdatingBackground(false)
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const removeBackground = async () => {
|
| 136 |
+
if (!window.confirm('确定移除全局背景吗?')) return
|
| 137 |
+
setUpdatingBackground(true)
|
| 138 |
+
try {
|
| 139 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', { method: 'DELETE' })
|
| 140 |
+
setGlobalBackground(settings)
|
| 141 |
+
toast.success('全局背景已移除')
|
| 142 |
+
} catch (error) {
|
| 143 |
+
toast.error(error instanceof Error ? error.message : '背景移除失败')
|
| 144 |
+
} finally {
|
| 145 |
+
setUpdatingBackground(false)
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const updateGlassOverlay = async (glassOverlay: boolean) => {
|
| 150 |
+
setUpdatingBackground(true)
|
| 151 |
+
try {
|
| 152 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', {
|
| 153 |
+
method: 'PATCH',
|
| 154 |
+
body: JSON.stringify({ glassOverlay }),
|
| 155 |
+
})
|
| 156 |
+
setGlobalBackground(settings)
|
| 157 |
+
toast.success(glassOverlay ? '毛玻璃遮罩已开启' : '毛玻璃遮罩已关闭')
|
| 158 |
+
} catch (error) {
|
| 159 |
+
toast.error(error instanceof Error ? error.message : '毛玻璃设置更新失败')
|
| 160 |
+
} finally {
|
| 161 |
+
setUpdatingBackground(false)
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const updateColorPreset = async (colorPreset: ColorPreset) => {
|
| 166 |
+
setUpdatingBackground(true)
|
| 167 |
+
try {
|
| 168 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', {
|
| 169 |
+
method: 'PATCH',
|
| 170 |
+
body: JSON.stringify({ colorPreset }),
|
| 171 |
+
})
|
| 172 |
+
setGlobalBackground(settings)
|
| 173 |
+
toast.success('全局配色已更新')
|
| 174 |
+
} catch (error) {
|
| 175 |
+
toast.error(error instanceof Error ? error.message : '全局配色更新失败')
|
| 176 |
+
} finally {
|
| 177 |
+
setUpdatingBackground(false)
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const updateBackgroundBrightness = async (backgroundBrightness: number) => {
|
| 182 |
+
setUpdatingBackground(true)
|
| 183 |
+
try {
|
| 184 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', {
|
| 185 |
+
method: 'PATCH',
|
| 186 |
+
body: JSON.stringify({ backgroundBrightness }),
|
| 187 |
+
})
|
| 188 |
+
setGlobalBackground(settings)
|
| 189 |
+
} catch (error) {
|
| 190 |
+
toast.error(error instanceof Error ? error.message : '背景亮度更新失败')
|
| 191 |
+
} finally {
|
| 192 |
+
setUpdatingBackground(false)
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const updateAutoTint = async (autoTint: boolean) => {
|
| 197 |
+
setUpdatingBackground(true)
|
| 198 |
+
try {
|
| 199 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', {
|
| 200 |
+
method: 'PATCH',
|
| 201 |
+
body: JSON.stringify({ autoTint }),
|
| 202 |
+
})
|
| 203 |
+
setGlobalBackground(settings)
|
| 204 |
+
toast.success(autoTint ? '自动适配背景色调已开启' : '自动适配背景色调已关闭')
|
| 205 |
+
} catch (error) {
|
| 206 |
+
toast.error(error instanceof Error ? error.message : '自动色调设置更新失败')
|
| 207 |
+
} finally {
|
| 208 |
+
setUpdatingBackground(false)
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
const applyBackgroundUrl = async () => {
|
| 213 |
+
const imageUrl = backgroundUrlInput.trim()
|
| 214 |
+
if (!imageUrl) return
|
| 215 |
+
try {
|
| 216 |
+
const parsedUrl = new URL(imageUrl)
|
| 217 |
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) throw new Error('图片 URL 必须使用 HTTP 或 HTTPS')
|
| 218 |
+
} catch (error) {
|
| 219 |
+
toast.error(error instanceof Error ? error.message : '图片 URL 格式无效')
|
| 220 |
+
return
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
setUpdatingBackground(true)
|
| 224 |
+
try {
|
| 225 |
+
const settings = await requestJson<GlobalBackgroundSettings>('/api/admin/background', {
|
| 226 |
+
method: 'POST',
|
| 227 |
+
body: JSON.stringify({ imageUrl }),
|
| 228 |
+
})
|
| 229 |
+
setGlobalBackground(settings)
|
| 230 |
+
setBackgroundUrlInput('')
|
| 231 |
+
toast.success('全局背景已从 URL 更新')
|
| 232 |
+
} catch (error) {
|
| 233 |
+
toast.error(error instanceof Error ? error.message : '图片 URL 获取失败')
|
| 234 |
+
} finally {
|
| 235 |
+
setUpdatingBackground(false)
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
const backgroundPreview = globalBackground.backgroundUrl
|
| 240 |
+
? globalBackground.backgroundUrl.startsWith('/uploads/')
|
| 241 |
+
? `${SERVER_URL}${globalBackground.backgroundUrl}`
|
| 242 |
+
: globalBackground.backgroundUrl
|
| 243 |
+
: null
|
| 244 |
+
|
| 245 |
+
const deleteUser = async (user: AdminUser) => {
|
| 246 |
+
if (!window.confirm(`确定删除账号 ${user.id}?`)) return
|
| 247 |
+
setWorkingId(user.id)
|
| 248 |
+
try {
|
| 249 |
+
await requestJson<void>(`/api/admin/users/${encodeURIComponent(user.id)}`, { method: 'DELETE' })
|
| 250 |
+
toast.success('账号已删除')
|
| 251 |
+
await load()
|
| 252 |
+
} catch (error) {
|
| 253 |
+
toast.error(error instanceof Error ? error.message : '删除失败')
|
| 254 |
+
} finally {
|
| 255 |
+
setWorkingId(null)
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const resetPassword = async (user: AdminUser) => {
|
| 260 |
+
const password = passwords[user.id] ?? ''
|
| 261 |
+
if (password.length < 8) return
|
| 262 |
+
setWorkingId(user.id)
|
| 263 |
+
try {
|
| 264 |
+
await requestJson<void>(`/api/admin/users/${encodeURIComponent(user.id)}/reset-password`, {
|
| 265 |
+
method: 'POST',
|
| 266 |
+
body: JSON.stringify({ password }),
|
| 267 |
+
})
|
| 268 |
+
setPasswords((current) => ({ ...current, [user.id]: '' }))
|
| 269 |
+
toast.success('密码已重置')
|
| 270 |
+
} catch (error) {
|
| 271 |
+
toast.error(error instanceof Error ? error.message : '密码重置失败')
|
| 272 |
+
} finally {
|
| 273 |
+
setWorkingId(null)
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
const dissolveRoom = async (room: AdminRoom) => {
|
| 278 |
+
if (!window.confirm(`确定解散房间 ${room.name}(${room.id})?`)) return
|
| 279 |
+
setWorkingId(room.id)
|
| 280 |
+
try {
|
| 281 |
+
await requestJson<void>(`/api/admin/rooms/${encodeURIComponent(room.id)}/dissolve`, { method: 'POST' })
|
| 282 |
+
toast.success('房间已解散')
|
| 283 |
+
await load()
|
| 284 |
+
} catch (error) {
|
| 285 |
+
toast.error(error instanceof Error ? error.message : '解散房间失败')
|
| 286 |
+
} finally {
|
| 287 |
+
setWorkingId(null)
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
return (
|
| 292 |
+
<div className="space-y-4">
|
| 293 |
+
<div className="flex items-center justify-between gap-3">
|
| 294 |
+
<div>
|
| 295 |
+
<h3 className="text-base font-semibold">服务器管理</h3>
|
| 296 |
+
<p className="mt-1 text-xs text-muted-foreground">
|
| 297 |
+
账号 {users.length} · 活跃房间 {rooms.length}
|
| 298 |
+
</p>
|
| 299 |
+
</div>
|
| 300 |
+
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => void load()}>
|
| 301 |
+
<RefreshCw className={loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
|
| 302 |
+
<span className="sr-only">刷新</span>
|
| 303 |
+
</Button>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
<Tabs defaultValue="users">
|
| 307 |
+
<TabsList className="w-full">
|
| 308 |
+
<TabsTrigger value="users">
|
| 309 |
+
<Users />
|
| 310 |
+
账号
|
| 311 |
+
</TabsTrigger>
|
| 312 |
+
<TabsTrigger value="rooms">
|
| 313 |
+
<DoorClosed />
|
| 314 |
+
房间
|
| 315 |
+
</TabsTrigger>
|
| 316 |
+
<TabsTrigger value="background">
|
| 317 |
+
<ImagePlus />
|
| 318 |
+
背景
|
| 319 |
+
</TabsTrigger>
|
| 320 |
+
<TabsTrigger value="proxy">
|
| 321 |
+
<Network />
|
| 322 |
+
代理
|
| 323 |
+
</TabsTrigger>
|
| 324 |
+
</TabsList>
|
| 325 |
+
|
| 326 |
+
<TabsContent value="users" className="space-y-0 divide-y rounded-md border">
|
| 327 |
+
{loading ? (
|
| 328 |
+
<div className="flex h-24 items-center justify-center">
|
| 329 |
+
<Loader2 className="h-5 w-5 animate-spin" />
|
| 330 |
+
</div>
|
| 331 |
+
) : users.length === 0 ? (
|
| 332 |
+
<p className="p-6 text-center text-sm text-muted-foreground">暂无账号</p>
|
| 333 |
+
) : (
|
| 334 |
+
users.map((user) => (
|
| 335 |
+
<div key={user.id} className="space-y-3 p-3">
|
| 336 |
+
<div className="flex items-start justify-between gap-3">
|
| 337 |
+
<div className="min-w-0">
|
| 338 |
+
<div className="flex items-center gap-2">
|
| 339 |
+
<span className="truncate text-sm font-medium">{user.nickname || '未设置昵称'}</span>
|
| 340 |
+
{user.role === 'admin' && <Badge variant="secondary">管理员</Badge>}
|
| 341 |
+
{user.hasPassword && <Badge variant="outline">有密码</Badge>}
|
| 342 |
+
</div>
|
| 343 |
+
<code className="block truncate text-[11px] text-muted-foreground">{user.id}</code>
|
| 344 |
+
</div>
|
| 345 |
+
<Button
|
| 346 |
+
type="button"
|
| 347 |
+
variant="ghost"
|
| 348 |
+
size="icon"
|
| 349 |
+
className="h-8 w-8 text-destructive"
|
| 350 |
+
disabled={workingId === user.id || currentUserId === user.id}
|
| 351 |
+
onClick={() => void deleteUser(user)}
|
| 352 |
+
>
|
| 353 |
+
<Trash2 className="h-4 w-4" />
|
| 354 |
+
<span className="sr-only">删除账号</span>
|
| 355 |
+
</Button>
|
| 356 |
+
</div>
|
| 357 |
+
<div className="flex gap-2">
|
| 358 |
+
<Input
|
| 359 |
+
type="password"
|
| 360 |
+
value={passwords[user.id] ?? ''}
|
| 361 |
+
onChange={(event) => setPasswords((current) => ({ ...current, [user.id]: event.target.value }))}
|
| 362 |
+
placeholder="新密码(至少 8 位)"
|
| 363 |
+
className="h-8"
|
| 364 |
+
/>
|
| 365 |
+
<Button
|
| 366 |
+
type="button"
|
| 367 |
+
variant="outline"
|
| 368 |
+
size="sm"
|
| 369 |
+
className="h-8"
|
| 370 |
+
disabled={workingId === user.id || (passwords[user.id]?.length ?? 0) < 8}
|
| 371 |
+
onClick={() => void resetPassword(user)}
|
| 372 |
+
>
|
| 373 |
+
重置密码
|
| 374 |
+
</Button>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
))
|
| 378 |
+
)}
|
| 379 |
+
</TabsContent>
|
| 380 |
+
|
| 381 |
+
<TabsContent value="rooms" className="space-y-0 divide-y rounded-md border">
|
| 382 |
+
{loading ? (
|
| 383 |
+
<div className="flex h-24 items-center justify-center">
|
| 384 |
+
<Loader2 className="h-5 w-5 animate-spin" />
|
| 385 |
+
</div>
|
| 386 |
+
) : rooms.length === 0 ? (
|
| 387 |
+
<p className="p-6 text-center text-sm text-muted-foreground">暂无活跃房间</p>
|
| 388 |
+
) : (
|
| 389 |
+
rooms.map((room) => (
|
| 390 |
+
<div key={room.id} className="flex items-center justify-between gap-3 p-3">
|
| 391 |
+
<div className="min-w-0">
|
| 392 |
+
<div className="flex items-center gap-2">
|
| 393 |
+
<span className="truncate text-sm font-medium">{room.name}</span>
|
| 394 |
+
<Badge variant="outline">{room.userCount} 人</Badge>
|
| 395 |
+
{room.hasPassword && <Badge variant="secondary">密码</Badge>}
|
| 396 |
+
<Badge variant={room.hidden ? 'secondary' : 'outline'}>{room.hidden ? '隐藏' : '公开'}</Badge>
|
| 397 |
+
<Badge variant={room.permanent ? 'secondary' : 'outline'}>{room.permanent ? '永久' : '临时'}</Badge>
|
| 398 |
+
</div>
|
| 399 |
+
<p className="truncate text-xs text-muted-foreground">
|
| 400 |
+
{room.id}
|
| 401 |
+
{room.currentTrackTitle ? ` · ${room.currentTrackTitle}` : ''}
|
| 402 |
+
</p>
|
| 403 |
+
</div>
|
| 404 |
+
<Button
|
| 405 |
+
type="button"
|
| 406 |
+
variant="destructive"
|
| 407 |
+
size="sm"
|
| 408 |
+
disabled={workingId === room.id}
|
| 409 |
+
onClick={() => void dissolveRoom(room)}
|
| 410 |
+
>
|
| 411 |
+
解散
|
| 412 |
+
</Button>
|
| 413 |
+
</div>
|
| 414 |
+
))
|
| 415 |
+
)}
|
| 416 |
+
</TabsContent>
|
| 417 |
+
|
| 418 |
+
<TabsContent value="background" className="rounded-md border p-3">
|
| 419 |
+
{loading ? (
|
| 420 |
+
<div className="flex h-24 items-center justify-center">
|
| 421 |
+
<Loader2 className="h-5 w-5 animate-spin" />
|
| 422 |
+
</div>
|
| 423 |
+
) : (
|
| 424 |
+
<div className="space-y-3">
|
| 425 |
+
<div className="overflow-hidden rounded-md border bg-muted/40">
|
| 426 |
+
{backgroundPreview ? (
|
| 427 |
+
<img src={backgroundPreview} alt="当前全局背景" className="h-32 w-full object-cover" />
|
| 428 |
+
) : (
|
| 429 |
+
<div className="flex h-32 flex-col items-center justify-center gap-2 text-muted-foreground">
|
| 430 |
+
<ImagePlus className="h-5 w-5" />
|
| 431 |
+
<span className="text-xs">未设置全局背景</span>
|
| 432 |
+
</div>
|
| 433 |
+
)}
|
| 434 |
+
</div>
|
| 435 |
+
<p className="text-xs leading-relaxed text-muted-foreground">
|
| 436 |
+
所有首页和房间页使用同一张背景图。上传后会压缩为 WebP 并保存到服务器数据目录。
|
| 437 |
+
</p>
|
| 438 |
+
<div className="flex gap-2">
|
| 439 |
+
<label>
|
| 440 |
+
<input
|
| 441 |
+
type="file"
|
| 442 |
+
accept="image/png,image/jpeg,image/webp"
|
| 443 |
+
className="sr-only"
|
| 444 |
+
disabled={updatingBackground}
|
| 445 |
+
onChange={(event) => {
|
| 446 |
+
void uploadBackground(event.currentTarget.files?.[0])
|
| 447 |
+
event.currentTarget.value = ''
|
| 448 |
+
}}
|
| 449 |
+
/>
|
| 450 |
+
<Button asChild type="button" size="sm" disabled={updatingBackground}>
|
| 451 |
+
<span>
|
| 452 |
+
{updatingBackground ? (
|
| 453 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 454 |
+
) : (
|
| 455 |
+
<Upload className="h-4 w-4" />
|
| 456 |
+
)}
|
| 457 |
+
上传背景
|
| 458 |
+
</span>
|
| 459 |
+
</Button>
|
| 460 |
+
</label>
|
| 461 |
+
{backgroundPreview && (
|
| 462 |
+
<Button
|
| 463 |
+
type="button"
|
| 464 |
+
variant="outline"
|
| 465 |
+
size="sm"
|
| 466 |
+
disabled={updatingBackground}
|
| 467 |
+
onClick={() => void removeBackground()}
|
| 468 |
+
>
|
| 469 |
+
<X className="h-4 w-4" />
|
| 470 |
+
移除
|
| 471 |
+
</Button>
|
| 472 |
+
)}
|
| 473 |
+
</div>
|
| 474 |
+
<div className="flex items-center justify-between gap-3 rounded-md border p-2">
|
| 475 |
+
<div>
|
| 476 |
+
<p className="text-sm font-medium">颜色预设</p>
|
| 477 |
+
<p className="text-xs text-muted-foreground">与背景搭配的全站强调色</p>
|
| 478 |
+
</div>
|
| 479 |
+
<Select
|
| 480 |
+
value={globalBackground.colorPreset}
|
| 481 |
+
disabled={updatingBackground}
|
| 482 |
+
onValueChange={(value) => void updateColorPreset(value as ColorPreset)}
|
| 483 |
+
>
|
| 484 |
+
<SelectTrigger className="w-32">
|
| 485 |
+
<SelectValue />
|
| 486 |
+
</SelectTrigger>
|
| 487 |
+
<SelectContent>
|
| 488 |
+
<SelectItem value="gold">金色暖调</SelectItem>
|
| 489 |
+
<SelectItem value="ocean">海洋青绿</SelectItem>
|
| 490 |
+
<SelectItem value="rose">玫瑰粉</SelectItem>
|
| 491 |
+
<SelectItem value="violet">夜幕紫</SelectItem>
|
| 492 |
+
<SelectItem value="sunset">落日橙红</SelectItem>
|
| 493 |
+
<SelectItem value="mint">薄荷青柠</SelectItem>
|
| 494 |
+
<SelectItem value="mono">黑白银灰</SelectItem>
|
| 495 |
+
</SelectContent>
|
| 496 |
+
</Select>
|
| 497 |
+
</div>
|
| 498 |
+
<div className="flex items-center gap-2 rounded-md border p-2">
|
| 499 |
+
<Switch
|
| 500 |
+
checked={globalBackground.glassOverlay}
|
| 501 |
+
disabled={updatingBackground}
|
| 502 |
+
onCheckedChange={(checked) => void updateGlassOverlay(checked)}
|
| 503 |
+
aria-label="毛玻璃遮罩"
|
| 504 |
+
/>
|
| 505 |
+
<div className="min-w-0">
|
| 506 |
+
<p className="text-sm font-medium">毛玻璃遮罩</p>
|
| 507 |
+
<p className="text-xs text-muted-foreground">让背景更柔和,减少对页面内容的干扰</p>
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
<div className="space-y-2 rounded-md border p-2">
|
| 511 |
+
<div className="flex items-center justify-between gap-3">
|
| 512 |
+
<div>
|
| 513 |
+
<p className="text-sm font-medium">背景亮度</p>
|
| 514 |
+
<p className="text-xs text-muted-foreground">只调整背景图片,不影响文字和卡片</p>
|
| 515 |
+
</div>
|
| 516 |
+
<span className="text-xs tabular-nums text-muted-foreground">
|
| 517 |
+
{globalBackground.backgroundBrightness}%
|
| 518 |
+
</span>
|
| 519 |
+
</div>
|
| 520 |
+
<Slider
|
| 521 |
+
value={[globalBackground.backgroundBrightness]}
|
| 522 |
+
min={20}
|
| 523 |
+
max={100}
|
| 524 |
+
step={1}
|
| 525 |
+
disabled={updatingBackground}
|
| 526 |
+
onValueChange={([value]) => {
|
| 527 |
+
if (typeof value === 'number') {
|
| 528 |
+
setGlobalBackground((current) => ({ ...current, backgroundBrightness: value }))
|
| 529 |
+
}
|
| 530 |
+
}}
|
| 531 |
+
onValueCommit={([value]) => {
|
| 532 |
+
if (typeof value === 'number') void updateBackgroundBrightness(value)
|
| 533 |
+
}}
|
| 534 |
+
aria-label="背景亮度"
|
| 535 |
+
/>
|
| 536 |
+
</div>
|
| 537 |
+
<div className="flex items-center gap-2 rounded-md border p-2">
|
| 538 |
+
<Switch
|
| 539 |
+
checked={globalBackground.autoTint}
|
| 540 |
+
disabled={updatingBackground || !backgroundPreview}
|
| 541 |
+
onCheckedChange={(checked) => void updateAutoTint(checked)}
|
| 542 |
+
aria-label="自动适配背景色调"
|
| 543 |
+
/>
|
| 544 |
+
<div className="min-w-0">
|
| 545 |
+
<p className="text-sm font-medium">自动适配背景色调</p>
|
| 546 |
+
<p className="text-xs text-muted-foreground">从背景图片提取平均色,叠加到背景光效中</p>
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
<div className="flex flex-col gap-2 sm:flex-row">
|
| 550 |
+
<Input
|
| 551 |
+
value={backgroundUrlInput}
|
| 552 |
+
onChange={(event) => setBackgroundUrlInput(event.target.value)}
|
| 553 |
+
placeholder="图片 API / 图片 URL(返回图片文件)"
|
| 554 |
+
disabled={updatingBackground}
|
| 555 |
+
onKeyDown={(event) => {
|
| 556 |
+
if (event.key === 'Enter') void applyBackgroundUrl()
|
| 557 |
+
}}
|
| 558 |
+
/>
|
| 559 |
+
<Button
|
| 560 |
+
type="button"
|
| 561 |
+
variant="outline"
|
| 562 |
+
disabled={updatingBackground || !backgroundUrlInput.trim()}
|
| 563 |
+
onClick={() => void applyBackgroundUrl()}
|
| 564 |
+
>
|
| 565 |
+
{updatingBackground ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />}
|
| 566 |
+
使用 URL
|
| 567 |
+
</Button>
|
| 568 |
+
</div>
|
| 569 |
+
</div>
|
| 570 |
+
)}
|
| 571 |
+
</TabsContent>
|
| 572 |
+
|
| 573 |
+
<TabsContent value="proxy" className="rounded-md border">
|
| 574 |
+
{loading ? (
|
| 575 |
+
<div className="flex h-24 items-center justify-center">
|
| 576 |
+
<Loader2 className="h-5 w-5 animate-spin" />
|
| 577 |
+
</div>
|
| 578 |
+
) : (
|
| 579 |
+
<>
|
| 580 |
+
<div className="border-b bg-muted/40 p-3 text-xs leading-relaxed text-muted-foreground">
|
| 581 |
+
B 站始终通过服务器代理播放,Cookie 仅保留在服务端。酷狗开关仅影响原生客户端;Web 端始终使用服务器代理。
|
| 582 |
+
</div>
|
| 583 |
+
<div className="divide-y px-3">
|
| 584 |
+
<SettingRow
|
| 585 |
+
label="酷狗强制服务器代理"
|
| 586 |
+
description="同时控制标准版和概念版;关闭后明文资源优先直连,加密资源仍由服务器代理解密"
|
| 587 |
+
>
|
| 588 |
+
<Switch
|
| 589 |
+
checked={audioProxyPolicy.kugouForceProxy}
|
| 590 |
+
disabled={updatingProxyPolicy}
|
| 591 |
+
onCheckedChange={(checked) => void updateAudioProxyPolicy(checked)}
|
| 592 |
+
aria-label="酷狗强制服务器代理"
|
| 593 |
+
/>
|
| 594 |
+
</SettingRow>
|
| 595 |
+
</div>
|
| 596 |
+
</>
|
| 597 |
+
)}
|
| 598 |
+
</TabsContent>
|
| 599 |
+
</Tabs>
|
| 600 |
+
</div>
|
| 601 |
+
)
|
| 602 |
+
}
|
packages/client/src/components/Overlays/Settings/AppearanceSection.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
| 2 |
+
import { Separator } from '@/components/ui/separator'
|
| 3 |
+
import { Slider } from '@/components/ui/slider'
|
| 4 |
+
import { useSettingsStore } from '@/stores/settingsStore'
|
| 5 |
+
import { SettingRow } from './SettingRow'
|
| 6 |
+
|
| 7 |
+
export function AppearanceSection() {
|
| 8 |
+
const s = useSettingsStore()
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="space-y-6">
|
| 12 |
+
{/* ---- 背景渲染 ---- */}
|
| 13 |
+
<div>
|
| 14 |
+
<h3 className="text-base font-semibold">背景渲染</h3>
|
| 15 |
+
<Separator className="mt-2 mb-4" />
|
| 16 |
+
|
| 17 |
+
<SettingRow
|
| 18 |
+
label="帧率"
|
| 19 |
+
description="更高帧率更流畅,但消耗更多性能"
|
| 20 |
+
onReset={s.bgFps !== s.bgFpsDefault ? s.resetBgFps : undefined}
|
| 21 |
+
>
|
| 22 |
+
<Select value={String(s.bgFps)} onValueChange={(v) => s.setBgFps(parseInt(v, 10))}>
|
| 23 |
+
<SelectTrigger className="w-24">
|
| 24 |
+
<SelectValue />
|
| 25 |
+
</SelectTrigger>
|
| 26 |
+
<SelectContent>
|
| 27 |
+
<SelectItem value="15">15 FPS</SelectItem>
|
| 28 |
+
<SelectItem value="30">30 FPS</SelectItem>
|
| 29 |
+
<SelectItem value="60">60 FPS</SelectItem>
|
| 30 |
+
</SelectContent>
|
| 31 |
+
</Select>
|
| 32 |
+
</SettingRow>
|
| 33 |
+
|
| 34 |
+
<SettingRow
|
| 35 |
+
label="流动速度"
|
| 36 |
+
description={`当前: ${s.bgFlowSpeed.toFixed(1)}`}
|
| 37 |
+
onReset={s.bgFlowSpeed !== s.bgFlowSpeedDefault ? s.resetBgFlowSpeed : undefined}
|
| 38 |
+
>
|
| 39 |
+
<Slider
|
| 40 |
+
value={[s.bgFlowSpeed * 10]}
|
| 41 |
+
min={5}
|
| 42 |
+
max={50}
|
| 43 |
+
step={5}
|
| 44 |
+
onValueChange={(v) => s.setBgFlowSpeed(v[0] / 10)}
|
| 45 |
+
className="w-32"
|
| 46 |
+
/>
|
| 47 |
+
</SettingRow>
|
| 48 |
+
|
| 49 |
+
<SettingRow
|
| 50 |
+
label="渲染精度"
|
| 51 |
+
description="更低精度更省性能"
|
| 52 |
+
onReset={s.bgRenderScale !== s.bgRenderScaleDefault ? s.resetBgRenderScale : undefined}
|
| 53 |
+
>
|
| 54 |
+
<Select value={String(s.bgRenderScale)} onValueChange={(v) => s.setBgRenderScale(parseFloat(v))}>
|
| 55 |
+
<SelectTrigger className="w-24">
|
| 56 |
+
<SelectValue />
|
| 57 |
+
</SelectTrigger>
|
| 58 |
+
<SelectContent>
|
| 59 |
+
<SelectItem value="0.25">25%</SelectItem>
|
| 60 |
+
<SelectItem value="0.5">50%</SelectItem>
|
| 61 |
+
<SelectItem value="0.75">75%</SelectItem>
|
| 62 |
+
<SelectItem value="1">100%</SelectItem>
|
| 63 |
+
</SelectContent>
|
| 64 |
+
</Select>
|
| 65 |
+
</SettingRow>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
)
|
| 69 |
+
}
|
packages/client/src/components/Overlays/Settings/LoginSection.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Badge } from '@/components/ui/badge'
|
| 2 |
+
import { Button } from '@/components/ui/button'
|
| 3 |
+
import { getVipDisplayLabel } from '@/lib/platform'
|
| 4 |
+
import type { MusicSource, MyPlatformAuth, PlatformAuthStatus } from '@music-together/shared'
|
| 5 |
+
import { Crown, Gift, KeyRound, Loader2, LogOut, ScanLine } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
interface LoginSectionProps {
|
| 8 |
+
platform: MusicSource
|
| 9 |
+
status?: PlatformAuthStatus
|
| 10 |
+
myStatus?: MyPlatformAuth
|
| 11 |
+
/** localStorage has a cookie for this platform but server hasn't confirmed yet */
|
| 12 |
+
isVerifying?: boolean
|
| 13 |
+
onQrLogin: () => void
|
| 14 |
+
onCookieLogin: () => void
|
| 15 |
+
onLogout: () => void
|
| 16 |
+
compactLabel?: string
|
| 17 |
+
onClaimConceptVip?: () => void
|
| 18 |
+
isClaimingConceptVip?: boolean
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function LoginSection({
|
| 22 |
+
platform,
|
| 23 |
+
status,
|
| 24 |
+
myStatus,
|
| 25 |
+
isVerifying,
|
| 26 |
+
onQrLogin,
|
| 27 |
+
onCookieLogin,
|
| 28 |
+
onLogout,
|
| 29 |
+
compactLabel,
|
| 30 |
+
onClaimConceptVip,
|
| 31 |
+
isClaimingConceptVip,
|
| 32 |
+
}: LoginSectionProps) {
|
| 33 |
+
const loggedInCount = status?.loggedInCount ?? 0
|
| 34 |
+
const hasVip = status?.hasVip ?? false
|
| 35 |
+
const maxVipType = status?.maxVipType ?? 0
|
| 36 |
+
const isMyLoggedIn = myStatus?.loggedIn ?? false
|
| 37 |
+
const displayedVipType = isMyLoggedIn ? (myStatus?.vipType ?? 0) : maxVipType
|
| 38 |
+
const displayedVipLabel = getVipDisplayLabel(
|
| 39 |
+
displayedVipType,
|
| 40 |
+
isMyLoggedIn ? myStatus?.vipLabel : status?.maxVipLabel,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="flex items-center justify-between gap-2 overflow-hidden rounded-lg border p-3">
|
| 45 |
+
<div className="min-w-0 flex-1 space-y-0.5">
|
| 46 |
+
<div className="flex min-w-0 items-center gap-2">
|
| 47 |
+
{compactLabel && <span className="text-muted-foreground shrink-0 text-xs">{compactLabel}</span>}
|
| 48 |
+
{isMyLoggedIn && myStatus?.nickname ? (
|
| 49 |
+
<span className="truncate text-sm font-medium">{myStatus.nickname}</span>
|
| 50 |
+
) : isVerifying ? (
|
| 51 |
+
<span className="text-muted-foreground flex shrink-0 items-center gap-1.5 text-sm">
|
| 52 |
+
<Loader2 className="h-3 w-3 animate-spin" />
|
| 53 |
+
验证登录中…
|
| 54 |
+
</span>
|
| 55 |
+
) : (
|
| 56 |
+
<span className="text-muted-foreground shrink-0 text-sm">未登录</span>
|
| 57 |
+
)}
|
| 58 |
+
{displayedVipType > 0 && (
|
| 59 |
+
<Badge variant="secondary" className="shrink-0 gap-1 text-xs">
|
| 60 |
+
<Crown className="h-3 w-3" />
|
| 61 |
+
{displayedVipLabel}
|
| 62 |
+
</Badge>
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
<p className="text-muted-foreground truncate text-xs">
|
| 66 |
+
{loggedInCount > 0 ? `房间内 ${loggedInCount} 人已登录${hasVip ? ',VIP 可用' : ''}` : '房间暂无人登录此平台'}
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="flex shrink-0 items-center gap-1.5">
|
| 71 |
+
{!isMyLoggedIn && !isVerifying ? (
|
| 72 |
+
<>
|
| 73 |
+
{(platform === 'netease' ||
|
| 74 |
+
platform === 'kugou' ||
|
| 75 |
+
platform === 'kugou_concept' ||
|
| 76 |
+
platform === 'tencent' ||
|
| 77 |
+
platform === 'bilibili') && (
|
| 78 |
+
<Button variant="outline" size="icon" className="h-8 w-8" onClick={onQrLogin} title="扫码登录">
|
| 79 |
+
<ScanLine className="h-3.5 w-3.5" />
|
| 80 |
+
</Button>
|
| 81 |
+
)}
|
| 82 |
+
<Button variant="outline" size="icon" className="h-8 w-8" onClick={onCookieLogin} title="Cookie 登录">
|
| 83 |
+
<KeyRound className="h-3.5 w-3.5" />
|
| 84 |
+
</Button>
|
| 85 |
+
</>
|
| 86 |
+
) : isMyLoggedIn ? (
|
| 87 |
+
<>
|
| 88 |
+
{platform === 'kugou_concept' && onClaimConceptVip && (
|
| 89 |
+
<Button
|
| 90 |
+
variant="outline"
|
| 91 |
+
size="sm"
|
| 92 |
+
className="h-8 gap-1 px-2 text-xs"
|
| 93 |
+
onClick={onClaimConceptVip}
|
| 94 |
+
disabled={isClaimingConceptVip}
|
| 95 |
+
title="手动领取酷狗官方每日概念版权益"
|
| 96 |
+
>
|
| 97 |
+
{isClaimingConceptVip ? (
|
| 98 |
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
| 99 |
+
) : (
|
| 100 |
+
<Gift className="h-3.5 w-3.5" />
|
| 101 |
+
)}
|
| 102 |
+
领权益
|
| 103 |
+
</Button>
|
| 104 |
+
)}
|
| 105 |
+
<Button variant="ghost" size="icon" className="text-destructive h-8 w-8" onClick={onLogout} title="登出">
|
| 106 |
+
<LogOut className="h-3.5 w-3.5" />
|
| 107 |
+
</Button>
|
| 108 |
+
</>
|
| 109 |
+
) : null}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
)
|
| 113 |
+
}
|
packages/client/src/components/Overlays/Settings/LyricsSection.tsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Input } from '@/components/ui/input'
|
| 2 |
+
import { NumericInput } from '@/components/ui/numeric-input'
|
| 3 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
| 4 |
+
import { Separator } from '@/components/ui/separator'
|
| 5 |
+
import { Slider } from '@/components/ui/slider'
|
| 6 |
+
import { Switch } from '@/components/ui/switch'
|
| 7 |
+
import { useSettingsStore } from '@/stores/settingsStore'
|
| 8 |
+
import { SettingRow } from './SettingRow'
|
| 9 |
+
|
| 10 |
+
export function LyricsSection() {
|
| 11 |
+
const s = useSettingsStore()
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="space-y-6">
|
| 15 |
+
{/* ---- 歌词源 ---- */}
|
| 16 |
+
<div>
|
| 17 |
+
<h3 className="text-base font-semibold">歌词源</h3>
|
| 18 |
+
<Separator className="mt-2 mb-4" />
|
| 19 |
+
|
| 20 |
+
<SettingRow
|
| 21 |
+
label="TTML 在线逐词歌词"
|
| 22 |
+
description="启用 AMLL 逐词歌词库(仅网易云/QQ音乐)"
|
| 23 |
+
onReset={s.ttmlEnabled !== s.ttmlEnabledDefault ? s.resetTtmlEnabled : undefined}
|
| 24 |
+
>
|
| 25 |
+
<Switch checked={s.ttmlEnabled} onCheckedChange={s.setTtmlEnabled} />
|
| 26 |
+
</SettingRow>
|
| 27 |
+
|
| 28 |
+
{s.ttmlEnabled && (
|
| 29 |
+
<SettingRow
|
| 30 |
+
label="TTML 歌词库地址"
|
| 31 |
+
description="URL 模板,%s 会被替换为歌曲 ID,QQ 音乐自动替换 ncm-lyrics 为 qq-lyrics"
|
| 32 |
+
onReset={s.ttmlDbUrl !== s.ttmlDbUrlDefault ? s.resetTtmlDbUrl : undefined}
|
| 33 |
+
>
|
| 34 |
+
<Input
|
| 35 |
+
value={s.ttmlDbUrl}
|
| 36 |
+
onChange={(e) => s.setTtmlDbUrl(e.target.value)}
|
| 37 |
+
placeholder="https://amlldb.bikonoo.com/ncm-lyrics/%s.ttml"
|
| 38 |
+
className="w-64 text-xs"
|
| 39 |
+
/>
|
| 40 |
+
</SettingRow>
|
| 41 |
+
)}
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{/* ---- 歌词布局 ---- */}
|
| 45 |
+
<div>
|
| 46 |
+
<h3 className="text-base font-semibold">歌词布局</h3>
|
| 47 |
+
<Separator className="mt-2 mb-4" />
|
| 48 |
+
|
| 49 |
+
<SettingRow
|
| 50 |
+
label="对齐锚点"
|
| 51 |
+
description="当前歌词行在视口中的锚定方式"
|
| 52 |
+
onReset={s.lyricAlignAnchor !== s.lyricAlignAnchorDefault ? s.resetLyricAlignAnchor : undefined}
|
| 53 |
+
>
|
| 54 |
+
<Select
|
| 55 |
+
value={s.lyricAlignAnchor}
|
| 56 |
+
onValueChange={(v) => s.setLyricAlignAnchor(v as 'top' | 'center' | 'bottom')}
|
| 57 |
+
>
|
| 58 |
+
<SelectTrigger className="w-28">
|
| 59 |
+
<SelectValue />
|
| 60 |
+
</SelectTrigger>
|
| 61 |
+
<SelectContent>
|
| 62 |
+
<SelectItem value="top">顶部</SelectItem>
|
| 63 |
+
<SelectItem value="center">居中</SelectItem>
|
| 64 |
+
<SelectItem value="bottom">底部</SelectItem>
|
| 65 |
+
</SelectContent>
|
| 66 |
+
</Select>
|
| 67 |
+
</SettingRow>
|
| 68 |
+
|
| 69 |
+
<SettingRow
|
| 70 |
+
label="对齐位置"
|
| 71 |
+
description={`当前: ${Math.round(s.lyricAlignPosition * 100)}%`}
|
| 72 |
+
onReset={s.lyricAlignPosition !== s.lyricAlignPositionDefault ? s.resetLyricAlignPosition : undefined}
|
| 73 |
+
>
|
| 74 |
+
<Slider
|
| 75 |
+
value={[s.lyricAlignPosition * 100]}
|
| 76 |
+
min={0}
|
| 77 |
+
max={100}
|
| 78 |
+
step={5}
|
| 79 |
+
onValueChange={(v) => s.setLyricAlignPosition(v[0] / 100)}
|
| 80 |
+
className="w-32"
|
| 81 |
+
/>
|
| 82 |
+
</SettingRow>
|
| 83 |
+
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{/* ---- 歌词动画 ---- */}
|
| 87 |
+
<div>
|
| 88 |
+
<h3 className="text-base font-semibold">歌词动画</h3>
|
| 89 |
+
<Separator className="mt-2 mb-4" />
|
| 90 |
+
|
| 91 |
+
<SettingRow
|
| 92 |
+
label="弹簧动画"
|
| 93 |
+
description="歌词行切换时的弹簧物理效果"
|
| 94 |
+
onReset={s.lyricEnableSpring !== s.lyricEnableSpringDefault ? s.resetLyricEnableSpring : undefined}
|
| 95 |
+
>
|
| 96 |
+
<Switch checked={s.lyricEnableSpring} onCheckedChange={s.setLyricEnableSpring} />
|
| 97 |
+
</SettingRow>
|
| 98 |
+
|
| 99 |
+
<SettingRow
|
| 100 |
+
label="模糊效果"
|
| 101 |
+
description="非当前行歌词模糊"
|
| 102 |
+
onReset={s.lyricEnableBlur !== s.lyricEnableBlurDefault ? s.resetLyricEnableBlur : undefined}
|
| 103 |
+
>
|
| 104 |
+
<Switch checked={s.lyricEnableBlur} onCheckedChange={s.setLyricEnableBlur} />
|
| 105 |
+
</SettingRow>
|
| 106 |
+
|
| 107 |
+
<SettingRow
|
| 108 |
+
label="缩放效果"
|
| 109 |
+
description="当前行歌词放大突出显示"
|
| 110 |
+
onReset={s.lyricEnableScale !== s.lyricEnableScaleDefault ? s.resetLyricEnableScale : undefined}
|
| 111 |
+
>
|
| 112 |
+
<Switch checked={s.lyricEnableScale} onCheckedChange={s.setLyricEnableScale} />
|
| 113 |
+
</SettingRow>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{/* ---- 歌词字体 ---- */}
|
| 117 |
+
<div>
|
| 118 |
+
<h3 className="text-base font-semibold">歌词字体</h3>
|
| 119 |
+
<Separator className="mt-2 mb-4" />
|
| 120 |
+
|
| 121 |
+
<SettingRow
|
| 122 |
+
label="字体粗细"
|
| 123 |
+
description="字体粗细值(100-900)"
|
| 124 |
+
onReset={s.lyricFontWeight !== s.lyricFontWeightDefault ? s.resetLyricFontWeight : undefined}
|
| 125 |
+
>
|
| 126 |
+
<NumericInput value={s.lyricFontWeight} onChange={s.setLyricFontWeight} min={100} max={900} />
|
| 127 |
+
</SettingRow>
|
| 128 |
+
|
| 129 |
+
<SettingRow
|
| 130 |
+
label="字体大小"
|
| 131 |
+
description="主歌词字号比例(10-200)"
|
| 132 |
+
onReset={s.lyricFontSize !== s.lyricFontSizeDefault ? s.resetLyricFontSize : undefined}
|
| 133 |
+
>
|
| 134 |
+
<NumericInput value={s.lyricFontSize} onChange={s.setLyricFontSize} />
|
| 135 |
+
</SettingRow>
|
| 136 |
+
|
| 137 |
+
<SettingRow
|
| 138 |
+
label="翻译字体大小"
|
| 139 |
+
description="翻译歌词相对主歌词的字号比例(10-200)"
|
| 140 |
+
onReset={
|
| 141 |
+
s.lyricTranslationFontSize !== s.lyricTranslationFontSizeDefault
|
| 142 |
+
? s.resetLyricTranslationFontSize
|
| 143 |
+
: undefined
|
| 144 |
+
}
|
| 145 |
+
>
|
| 146 |
+
<NumericInput value={s.lyricTranslationFontSize} onChange={s.setLyricTranslationFontSize} />
|
| 147 |
+
</SettingRow>
|
| 148 |
+
|
| 149 |
+
<SettingRow
|
| 150 |
+
label="罗马音字体大小"
|
| 151 |
+
description="罗马音歌词相对主歌词的字号比例(10-200)"
|
| 152 |
+
onReset={s.lyricRomanFontSize !== s.lyricRomanFontSizeDefault ? s.resetLyricRomanFontSize : undefined}
|
| 153 |
+
>
|
| 154 |
+
<NumericInput value={s.lyricRomanFontSize} onChange={s.setLyricRomanFontSize} />
|
| 155 |
+
</SettingRow>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
)
|
| 159 |
+
}
|