QSLY commited on
Commit
00a912e
·
1 Parent(s): 0cf25be

deploy: build Hugging Face Space from source

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +11 -0
  2. .env.example +44 -0
  3. .gitattributes +13 -0
  4. .github/workflows/build.yml +34 -0
  5. .gitignore +15 -0
  6. .prettierrc +7 -0
  7. Dockerfile +41 -1
  8. LICENSE +661 -0
  9. README.en.md +188 -0
  10. docs/PROJECT_ARCHITECTURE.md +44 -0
  11. docs/UPDATE_NOTES.md +137 -0
  12. docs/architecture/coding-standards.md +79 -0
  13. docs/architecture/data-flow.md +307 -0
  14. docs/architecture/dependencies.md +77 -0
  15. docs/architecture/deployment.md +64 -0
  16. docs/architecture/design-patterns.md +204 -0
  17. docs/architecture/dev-guide.md +74 -0
  18. docs/architecture/directory-structure.md +219 -0
  19. docs/architecture/ui-design.md +150 -0
  20. package.json +30 -0
  21. packages/client/.env.development +2 -0
  22. packages/client/.gitignore +24 -0
  23. packages/client/README.md +73 -0
  24. packages/client/components.json +23 -0
  25. packages/client/eslint.config.js +23 -0
  26. packages/client/index.html +14 -0
  27. packages/client/package.json +68 -0
  28. packages/client/public/logo.svg +6 -0
  29. packages/client/src/App.tsx +92 -0
  30. packages/client/src/components/Chat/ChatMessage.tsx +46 -0
  31. packages/client/src/components/Chat/ChatPanel.tsx +141 -0
  32. packages/client/src/components/GlobalBackground.tsx +121 -0
  33. packages/client/src/components/InteractionGate.tsx +113 -0
  34. packages/client/src/components/Lobby/ActionCards.tsx +71 -0
  35. packages/client/src/components/Lobby/CreateRoomDialog.tsx +120 -0
  36. packages/client/src/components/Lobby/HeroSection.tsx +23 -0
  37. packages/client/src/components/Lobby/NicknameDialog.tsx +75 -0
  38. packages/client/src/components/Lobby/PasswordDialog.tsx +81 -0
  39. packages/client/src/components/Lobby/RoomCard.tsx +87 -0
  40. packages/client/src/components/Lobby/RoomListSection.tsx +62 -0
  41. packages/client/src/components/Lobby/UserPopover.tsx +166 -0
  42. packages/client/src/components/Overlays/BilibiliCollectionDialog.tsx +127 -0
  43. packages/client/src/components/Overlays/BilibiliMetadataDialog.tsx +137 -0
  44. packages/client/src/components/Overlays/QueueDrawer.tsx +482 -0
  45. packages/client/src/components/Overlays/SearchDialog.tsx +720 -0
  46. packages/client/src/components/Overlays/Settings/AccountSection.tsx +273 -0
  47. packages/client/src/components/Overlays/Settings/AdminSection.tsx +602 -0
  48. packages/client/src/components/Overlays/Settings/AppearanceSection.tsx +69 -0
  49. packages/client/src/components/Overlays/Settings/LoginSection.tsx +113 -0
  50. 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
- FROM ghcr.io/liuyunlingnai/music-together:latest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ | ![Web desktop screenshot 1](screenshots/1.png) | ![Web desktop screenshot 2](screenshots/2.png) | ![Web desktop screenshot 3](screenshots/3.png) | ![Web desktop screenshot 4](screenshots/4.png) | ![Web desktop screenshot 5](screenshots/5.png) |
34
+
35
+ ### Web Mobile
36
+
37
+ | 1 | 2 | 3 | 4 | 5 |
38
+ | --- | --- | --- | --- | --- |
39
+ | ![Web mobile screenshot 1](screenshots/1_m.png) | ![Web mobile screenshot 2](screenshots/2_m.png) | ![Web mobile screenshot 3](screenshots/3_m.png) | ![Web mobile screenshot 4](screenshots/4_m.png) | ![Web mobile screenshot 5](screenshots/5_m.png) |
40
+
41
+ ### Native Android Client
42
+
43
+ | Launch | 1 | 2 | 3 | 4 |
44
+ | --- | --- | --- | --- | --- |
45
+ | ![Android launch screen](screenshots/0_a.jpg) | ![Android screenshot 1](screenshots/1_a.jpg) | ![Android screenshot 2](screenshots/2_a.jpg) | ![Android screenshot 3](screenshots/3_a.jpg) | ![Android screenshot 4](screenshots/4_a.jpg) |
46
+
47
+ ### Windows Desktop Client
48
+
49
+ | 1 | 2 | 3 | 4 | 5 |
50
+ | --- | --- | --- | --- | --- |
51
+ | ![Windows desktop screenshot 1](screenshots/1_w.png) | ![Windows desktop screenshot 2](screenshots/2_w.png) | ![Windows desktop screenshot 3](screenshots/3_w.png) | ![Windows desktop screenshot 4](screenshots/4_w.png) | ![Windows desktop screenshot 5](screenshots/5_w.png) |
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
+ }