@doitian/dsh-music 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/panel.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="zh-CN">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -58,14 +58,7 @@
58
58
  #account img { width: 22px; height: 22px; border-radius: 50%; }
59
59
 
60
60
  main { display: flex; flex: 1; min-height: 0; }
61
- .left { flex: 1 1 46%; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
62
- .right { flex: 1 1 54%; min-width: 0; display: flex; flex-direction: column; }
63
-
64
- .tabs { display: flex; gap: 4px; padding: 9px 12px 0; }
65
- .tabs button { border-radius: 999px; }
66
- .tabs button.on { background: color-mix(in srgb, var(--accent) 16%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
67
- .searchbar { display: flex; gap: 6px; padding: 9px 12px; }
68
- .searchbar input { flex: 1; min-width: 0; }
61
+ .player { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
69
62
 
70
63
  .scroll { overflow: auto; padding: 0 8px 16px; flex: 1; }
71
64
  .scroll::-webkit-scrollbar { width: 9px; }
@@ -85,11 +78,16 @@
85
78
  .row .dur { color: var(--muted); font-variant-numeric: tabular-nums; flex: none; font-size: 12px; }
86
79
  .row .acts { display: none; gap: 4px; flex: none; }
87
80
  .row:hover .acts { display: flex; }
88
- .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(126px, 1fr)); gap: 10px; padding: 4px 8px; }
89
- .card { cursor: pointer; }
90
- .card img { width: 100%; aspect-ratio: 1; border-radius: 8px; object-fit: cover; background: var(--panel); }
91
- .card .name { font-size: 12px; margin-top: 5px; line-height: 1.35; max-height: 2.7em; overflow: hidden; }
92
- .card .sub { color: var(--muted); font-size: 11px; }
81
+ /* The list's own like control. A liked track keeps its filled heart
82
+ visible; the hollow one only appears on hover, so an unrated list is
83
+ not a wall of outlines. */
84
+ .row .heart {
85
+ background: none; border: 0; padding: 0; width: 16px; flex: none;
86
+ font-size: 14px; line-height: 1; color: var(--muted); opacity: 0;
87
+ transition: opacity 0.12s;
88
+ }
89
+ .row:hover .heart { opacity: 1; }
90
+ .row .heart.on { opacity: 1; color: var(--accent); }
93
91
 
94
92
  .now { padding: 14px; border-bottom: 1px solid var(--line); }
95
93
  .now-top { display: flex; gap: 14px; }
@@ -104,18 +102,55 @@
104
102
  .controls { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
105
103
  .controls .grow { flex: 1; }
106
104
  .icon { font-size: 15px; line-height: 1; }
105
+ /* A filled heart means the track is liked on the account. */
106
+ #like.on, #dislike.on {
107
+ color: #fff;
108
+ background: var(--accent);
109
+ border-color: var(--accent);
110
+ }
107
111
  #volume { flex: 0 0 88px; }
108
112
  .mode { font-size: 12px; }
113
+ /* The trip to NetEase is not instant on a cold cache; a click has to look
114
+ like it landed before the rows come back. */
115
+ button:disabled { cursor: default; }
109
116
 
110
117
  .section-title { padding: 12px 14px 4px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; }
111
- .lyrics { max-height: 34%; overflow: auto; padding: 0 14px 12px; }
118
+ .section-title.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
119
+ .section-title.head button {
120
+ background: none; border: 0; margin: 0; padding: 0; font: inherit; color: var(--muted);
121
+ text-transform: inherit; letter-spacing: inherit; cursor: pointer;
122
+ }
123
+ .section-title.head button:hover { color: var(--fg); }
124
+ /* The pane is a glance at the current line, not a page: four lines of
125
+ synced text, scrolled for the rest, so the queue keeps the space.
126
+ `--lyrics-lines` is the single knob (the padding below is the `+ 12px`). */
127
+ .lyrics {
128
+ --lyrics-lines: 4;
129
+ max-height: calc(var(--lyrics-lines) * 1.5em + var(--lyrics-lines) * 4px + 12px);
130
+ line-height: 1.5; overflow: auto; padding: 0 14px 12px;
131
+ }
132
+ .lyrics.collapsed { display: none; }
112
133
  .lyrics p { margin: 0; padding: 2px 0; color: var(--muted); transition: color 0.15s; }
113
134
  .lyrics p.on { color: var(--fg); font-weight: 600; }
114
135
  .queue { flex: 1; min-height: 0; display: flex; flex-direction: column; }
115
136
  .queue .scroll { padding-top: 0; }
116
137
  .dj-status { margin: 0 14px 12px; padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); font-size: 12px; }
117
138
  .dj-status b { color: var(--accent); }
118
- .curator-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
139
+ /* The DJ model picker is a set-once setting, so the card keeps only its
140
+ header row and the queue and the lyrics pane keep the space. The header
141
+ is the click target; the chevron inside it is the keyboard one. */
142
+ .model-head { display: flex; align-items: baseline; gap: 8px; cursor: pointer; }
143
+ .model-head .model-now {
144
+ flex: 1; min-width: 0; text-align: right; color: var(--muted);
145
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
146
+ }
147
+ .model-head .toggle {
148
+ background: none; border: 0; margin: 0; padding: 0 2px; font: inherit;
149
+ color: var(--muted); cursor: pointer;
150
+ }
151
+ .model-head .toggle:hover { color: var(--fg); }
152
+ .model-head .warn { color: var(--accent); }
153
+ .dj-model-body.collapsed { display: none; }
119
154
  .row-inline { display: flex; gap: 6px; margin-top: 6px; align-items: center; }
120
155
  .row-inline select, .row-inline input { flex: 1 1 0; min-width: 0; font-size: 12px; padding: 4px 6px; }
121
156
  .row-inline button { font-size: 12px; padding: 4px 8px; }
@@ -140,33 +175,20 @@
140
175
  <body>
141
176
  <div id="app">
142
177
  <header>
143
- <div class="brand">音乐<small>music.163.com</small></div>
178
+ <div class="brand"><span data-i18n="brand">Music</span><small>music.163.com</small></div>
144
179
  <div class="spacer"></div>
145
- <input id="dj-prompt" placeholder="AI DJ 心情 / mood, e.g. rainy afternoon jazz" />
146
- <button id="dj-toggle" title="Continuous AI DJ: keep the queue stocked">AI DJ</button>
180
+ <input id="dj-prompt" data-i18n-ph="djPromptPh" placeholder="AI DJ mood, e.g. rainy afternoon jazz" />
181
+ <button id="dj-toggle" data-i18n-title="djToggleTitle" title="Continuous AI DJ: keep the queue stocked">AI DJ</button>
147
182
  <div id="account"></div>
148
183
  </header>
149
184
 
150
185
  <main>
151
- <section class="left">
152
- <div class="tabs">
153
- <button data-tab="search" class="on">搜索 Search</button>
154
- <button data-tab="charts">榜单 Charts</button>
155
- <button data-tab="foryou">推荐 For you</button>
156
- </div>
157
- <div class="searchbar" id="searchbar">
158
- <input id="q" placeholder="搜索歌曲、歌手、专辑 / search songs" />
159
- <button id="go" class="primary">搜索</button>
160
- </div>
161
- <div class="scroll" id="browser"></div>
162
- </section>
163
-
164
- <section class="right">
186
+ <section class="player">
165
187
  <div class="now">
166
188
  <div class="now-top">
167
189
  <img id="cover" alt="" />
168
190
  <div style="min-width: 0; flex: 1">
169
- <div class="now-title" id="np-title">未播放 / nothing playing</div>
191
+ <div class="now-title" id="np-title" data-i18n="nothingPlaying">Nothing playing</div>
170
192
  <div class="now-artists" id="np-artists"></div>
171
193
  <div class="now-album" id="np-album"></div>
172
194
  </div>
@@ -177,57 +199,70 @@
177
199
  <span id="t-dur">0:00</span>
178
200
  </div>
179
201
  <div class="controls">
180
- <button id="prev" title="上一首 previous">⏮</button>
181
- <button id="play" class="primary" title="播放/暂停 play/pause">▶</button>
182
- <button id="next" title="下一首 next">⏭</button>
183
- <button id="mode" class="mode" title="播放模式 mode">列表</button>
184
- <select id="quality" class="mode" title="流媒体音质 / streaming quality"></select>
202
+ <button id="prev" data-i18n-title="prev" title="Previous">⏮</button>
203
+ <button id="play" class="primary" data-i18n-title="playPause" title="Play/pause">▶</button>
204
+ <button id="next" data-i18n-title="next" title="Next">⏭</button>
205
+ <button id="mode" class="mode" data-i18n-title="modeTitle" data-i18n="modeList" title="Playback mode">List</button>
206
+ <select id="quality" class="mode" data-i18n-title="qualityTitle" title="Streaming quality"></select>
185
207
  <span id="quality-info" class="mode" style="color: var(--muted)"></span>
186
208
  <span class="grow"></span>
187
- <button id="like" title="喜欢 like">♡</button>
188
- <button id="dislike" title="不喜欢 dislike">✕</button>
189
- <button id="mute" title="静音 mute">🔊</button>
209
+ <!-- Labels are state-dependent (a filled heart un-likes), so the
210
+ script owns text and title rather than data-i18n. -->
211
+ <button id="like" title="Like">♡</button>
212
+ <button id="dislike" title="Dislike">✕</button>
213
+ <button id="mute" data-i18n-title="mute" title="Mute">🔊</button>
190
214
  <input type="range" id="volume" min="0" max="1" step="0.01" value="0.8" />
191
215
  </div>
192
216
  </div>
193
217
 
194
- <div class="section-title">歌词 Lyrics</div>
218
+ <div class="section-title head">
219
+ <span data-i18n="lyrics">Lyrics</span>
220
+ <!-- Label and title are state-dependent, so the script owns them
221
+ (applyStaticStrings cannot know which way the pane is now). -->
222
+ <button id="lyrics-toggle" type="button" aria-expanded="true">Hide</button>
223
+ </div>
195
224
  <div class="lyrics" id="lyrics"><p class="hint">—</p></div>
196
225
 
197
- <div class="section-title">播放队列 Queue <span id="counts"></span></div>
226
+ <div class="section-title"><span data-i18n="queue">Queue</span> <span id="counts"></span></div>
198
227
  <div class="queue"><div class="scroll" id="queue"></div></div>
199
228
  <div id="dj-status"></div>
200
229
 
201
230
  <div class="dj-status" id="dj-model">
202
- <div class="curator-head">
203
- <span>AI DJ 模型 / curator model</span>
204
- <span id="dj-model-source" style="color: var(--muted)"></span>
205
- </div>
206
- <div class="row-inline">
207
- <select id="dj-provider" title="provider route"></select>
208
- <select id="dj-model-select" title="model"></select>
231
+ <!-- One always-visible line: the setting that matters at a glance is
232
+ the effective route, and the form below it is folded away until
233
+ asked for. -->
234
+ <div class="model-head" id="dj-model-head">
235
+ <span data-i18n="djModel">DJ model</span>
236
+ <span class="model-now"><span id="dj-model-route"></span><span id="dj-model-source"></span><span class="warn" id="dj-model-warn"></span></span>
237
+ <button id="dj-model-toggle" class="toggle" type="button" aria-controls="dj-model-body" aria-expanded="false"></button>
209
238
  </div>
210
- <div class="row-inline">
211
- <input id="dj-model-custom" placeholder="自定义 model id / custom model id" style="display: none" />
212
- </div>
213
- <div class="row-inline">
214
- <button id="dj-model-save" class="primary">保存 Save</button>
215
- <button id="dj-model-test">测试 Test</button>
216
- <button id="dj-model-clear" title="回退到 profile 配置 / revert to the profile patch">回退 Revert</button>
239
+ <div class="dj-model-body collapsed" id="dj-model-body">
240
+ <div class="row-inline">
241
+ <select id="dj-provider" title="provider route"></select>
242
+ <select id="dj-model-select" title="model"></select>
243
+ </div>
244
+ <div class="row-inline">
245
+ <input id="dj-model-custom" data-i18n-ph="customModelPh" placeholder="Custom model id" style="display: none" />
246
+ </div>
247
+ <div class="row-inline">
248
+ <button id="dj-model-save" class="primary" data-i18n="save">Save</button>
249
+ <button id="dj-model-test" data-i18n="test">Test</button>
250
+ <button id="dj-model-clear" data-i18n="revert" data-i18n-title="revertTitle" title="Revert to the profile patch">Revert</button>
251
+ </div>
252
+ <div id="dj-model-status" style="color: var(--muted)"></div>
217
253
  </div>
218
- <div id="dj-model-status" style="color: var(--muted)"></div>
219
254
  </div>
220
255
  </section>
221
256
  </main>
222
257
  </div>
223
258
 
224
259
  <dialog id="login">
225
- <div style="font-weight: 650">网易云音乐登录 / Sign in</div>
260
+ <div style="font-weight: 650" data-i18n="loginTitle">NetEase Cloud Music sign in</div>
226
261
  <canvas id="qr" width="220" height="220"></canvas>
227
- <div class="hint" id="qr-state">正在获取二维码… / fetching QR…</div>
262
+ <div class="hint" id="qr-state">Fetching QR…</div>
228
263
  <div class="row-actions">
229
- <button id="qr-refresh">刷新 Refresh</button>
230
- <button id="qr-close" class="primary">关闭 Close</button>
264
+ <button id="qr-refresh" data-i18n="refresh">Refresh</button>
265
+ <button id="qr-close" class="primary" data-i18n="close">Close</button>
231
266
  </div>
232
267
  </dialog>
233
268
 
@@ -240,11 +275,276 @@
240
275
  const API = BASE + '/api';
241
276
  const $ = (id) => document.getElementById(id);
242
277
 
278
+ // ------------------------------------------------------------------ i18n
279
+ /**
280
+ * Locale, without ctx.
281
+ *
282
+ * This page is served as a plain document, not a client module, so the
283
+ * host's `ctx.locale` service is out of reach. The shell keeps its own
284
+ * `<html lang>` in sync with the active locale, though, and this frame
285
+ * is same-origin with the shell — reading the parent's `lang`, and
286
+ * observing it for changes, follows the Settings language live. When
287
+ * the page is opened standalone there is no parent to follow, so the
288
+ * browser's own language decides.
289
+ */
290
+ const DICT = {
291
+ zh: {
292
+ brand: '音乐',
293
+ djPromptPh: 'AI DJ 心情,例如 rainy afternoon jazz',
294
+ djToggleTitle: '持续 AI DJ:自动补歌',
295
+ nothingPlaying: '未播放',
296
+ prev: '上一首',
297
+ playPause: '播放/暂停',
298
+ next: '下一首',
299
+ modeTitle: '播放模式',
300
+ modeList: '列表',
301
+ modeSingle: '单曲',
302
+ modeShuffle: '随机',
303
+ qualityTitle: '流媒体音质',
304
+ like: '喜欢',
305
+ unlike: '取消喜欢',
306
+ dislike: '不喜欢',
307
+ undislike: '取消不喜欢',
308
+ likeNeedsSignIn: '登录后才能在网易云标记喜欢',
309
+ mute: '静音',
310
+ lyrics: '歌词',
311
+ lyricsHide: '收起',
312
+ lyricsShow: '展开',
313
+ lyricsToggleHide: '收起歌词面板',
314
+ lyricsToggleShow: '展开歌词面板',
315
+ queue: '播放队列',
316
+ djModel: 'AI DJ 模型',
317
+ djModelToggleShow: '展开模型设置',
318
+ djModelToggleHide: '收起模型设置',
319
+ customModelPh: '自定义 model id',
320
+ save: '保存',
321
+ test: '测试',
322
+ revert: '回退',
323
+ revertTitle: '回退到 profile 配置',
324
+ loginTitle: '网易云音乐登录',
325
+ qrFetching: '正在获取二维码…',
326
+ qrScan: '用网易云音乐 App 扫码',
327
+ refresh: '刷新',
328
+ close: '关闭',
329
+ signin: '登录',
330
+ loginWaiting: '等待扫码',
331
+ loginScanned: '已扫码,请在手机上确认',
332
+ loginExpired: '二维码已过期,已刷新',
333
+ loginSuccess: '登录成功',
334
+ loginIdle: '未开始',
335
+ pollFailed: '轮询失败:',
336
+ resuming: '正在恢复播放…',
337
+ djPlanning: 'AI DJ 正在选曲…',
338
+ modelTierUnavailable: '模型层不可用',
339
+ djStocking: '持续补歌中',
340
+ gestureText: '浏览器需要你先与页面交互一次才会播放声音',
341
+ gesturePlay: '点击播放',
342
+ gestureNote: '在窗口任意处点击也可以',
343
+ queueEmpty: '队列为空,用 AI DJ 或让助手补歌',
344
+ remove: '移除',
345
+ removed: '已移除',
346
+ loading: '加载中…',
347
+ instrumental: '纯音乐,无歌词',
348
+ other: '其他…',
349
+ noProvider: '(无 provider)',
350
+ srcPanel: '面板',
351
+ srcConfig: '配置',
352
+ srcAgentDefault: '会话模型',
353
+ srcDiscovered: '自动',
354
+ hostPredatesLong:
355
+ '宿主模块早于本功能:请完全退出并重新启动 Harness(关闭窗口不够,它会留在托盘里)',
356
+ hostPredatesShort: '宿主模块早于本功能',
357
+ pickProviderModel: '请先选择 provider 与 model',
358
+ saved: '已保存 {route}',
359
+ reverted: '已回退到 profile 配置',
360
+ testing: '测试中…',
361
+ heuristics: '已用启发式',
362
+ downgraded: '已降级',
363
+ qualitySet: '音质已设为 {level}',
364
+ liked: '已喜欢',
365
+ unliked: '已取消喜欢',
366
+ disliked: '不再推荐',
367
+ djOn: 'AI DJ 已开启',
368
+ djOff: 'AI DJ 已关闭',
369
+ connectFailed: '连接失败:',
370
+ 'quality.jymaster': '超清母带',
371
+ 'quality.dolby': '杜比全景声',
372
+ 'quality.sky': '沉浸环绕声',
373
+ 'quality.jyeffect': '沉浸声',
374
+ 'quality.hires': 'Hi-Res 无损',
375
+ 'quality.lossless': '无损',
376
+ 'quality.exhigh': '极高 320 kbps',
377
+ 'quality.higher': '较高 192 kbps',
378
+ 'quality.standard': '标准 128 kbps',
379
+ },
380
+ en: {
381
+ brand: 'Music',
382
+ djPromptPh: 'AI DJ mood, e.g. rainy afternoon jazz',
383
+ djToggleTitle: 'Continuous AI DJ: keep the queue stocked',
384
+ nothingPlaying: 'Nothing playing',
385
+ prev: 'Previous',
386
+ playPause: 'Play/pause',
387
+ next: 'Next',
388
+ modeTitle: 'Playback mode',
389
+ modeList: 'List',
390
+ modeSingle: 'Single',
391
+ modeShuffle: 'Shuffle',
392
+ qualityTitle: 'Streaming quality',
393
+ like: 'Like',
394
+ unlike: 'Unlike',
395
+ dislike: 'Dislike',
396
+ undislike: 'Undislike',
397
+ likeNeedsSignIn: 'Sign in to NetEase to like tracks',
398
+ mute: 'Mute',
399
+ lyrics: 'Lyrics',
400
+ lyricsHide: 'Hide',
401
+ lyricsShow: 'Show',
402
+ lyricsToggleHide: 'Collapse the lyrics pane',
403
+ lyricsToggleShow: 'Expand the lyrics pane',
404
+ queue: 'Queue',
405
+ djModel: 'DJ model',
406
+ djModelToggleShow: 'Show the model settings',
407
+ djModelToggleHide: 'Hide the model settings',
408
+ customModelPh: 'Custom model id',
409
+ save: 'Save',
410
+ test: 'Test',
411
+ revert: 'Revert',
412
+ revertTitle: 'Revert to the profile patch',
413
+ loginTitle: 'NetEase Cloud Music sign in',
414
+ qrFetching: 'Fetching QR…',
415
+ qrScan: 'Scan with the NetEase app',
416
+ refresh: 'Refresh',
417
+ close: 'Close',
418
+ signin: 'Sign in',
419
+ loginWaiting: 'Waiting for scan',
420
+ loginScanned: 'Scanned — confirm on your phone',
421
+ loginExpired: 'QR expired; a fresh one is shown',
422
+ loginSuccess: 'Signed in',
423
+ loginIdle: 'Not started',
424
+ pollFailed: 'Poll failed: ',
425
+ resuming: 'Resuming…',
426
+ djPlanning: 'AI DJ is planning…',
427
+ modelTierUnavailable: 'model tier unavailable',
428
+ djStocking: 'keeping the queue stocked',
429
+ gestureText: 'Chromium needs one interaction with this window before it will play audio',
430
+ gesturePlay: 'Play',
431
+ gestureNote: 'clicking anywhere works too',
432
+ queueEmpty: 'Queue empty — use the AI DJ or ask the assistant to add songs',
433
+ remove: 'Remove',
434
+ removed: 'Removed',
435
+ loading: 'Loading…',
436
+ instrumental: 'Instrumental',
437
+ other: 'Other…',
438
+ noProvider: '(no provider)',
439
+ srcPanel: 'panel',
440
+ srcConfig: 'profile',
441
+ srcAgentDefault: 'session model',
442
+ srcDiscovered: 'auto',
443
+ hostPredatesLong:
444
+ 'The host module predates this feature — fully quit and relaunch the harness (closing the window is not enough; it stays running in the tray)',
445
+ hostPredatesShort: 'Host module predates this feature',
446
+ pickProviderModel: 'Pick a provider and a model first',
447
+ saved: 'Saved {route}',
448
+ reverted: 'Reverted to the profile config',
449
+ testing: 'Testing…',
450
+ heuristics: 'heuristics used',
451
+ downgraded: 'downgraded',
452
+ qualitySet: 'Quality set to {level}',
453
+ liked: 'Liked',
454
+ unliked: 'Like removed',
455
+ disliked: 'Disliked',
456
+ djOn: 'AI DJ on',
457
+ djOff: 'AI DJ off',
458
+ connectFailed: 'Cannot reach the host: ',
459
+ 'quality.jymaster': 'Master',
460
+ 'quality.dolby': 'Dolby Atmos',
461
+ 'quality.sky': 'Immersive',
462
+ 'quality.jyeffect': 'Spatial',
463
+ 'quality.hires': 'Hi-Res lossless',
464
+ 'quality.lossless': 'Lossless',
465
+ 'quality.exhigh': '320 kbps',
466
+ 'quality.higher': '192 kbps',
467
+ 'quality.standard': '128 kbps',
468
+ },
469
+ };
470
+ let LANG = 'en';
471
+
472
+ function detectLang() {
473
+ try {
474
+ const lang = window.parent?.document?.documentElement?.lang;
475
+ if (lang) return String(lang).toLowerCase().startsWith('zh') ? 'zh' : 'en';
476
+ } catch { /* standalone or cross-origin */ }
477
+ try {
478
+ return String(navigator.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en';
479
+ } catch { return 'en'; }
480
+ }
481
+
482
+ /** Translate a key, interpolating `{name}` placeholders from `vars`. */
483
+ function t(key, vars) {
484
+ let text = DICT[LANG]?.[key] ?? DICT.en[key] ?? key;
485
+ if (vars) {
486
+ for (const name of Object.keys(vars)) {
487
+ text = text.split('{' + name + '}').join(String(vars[name]));
488
+ }
489
+ }
490
+ return text;
491
+ }
492
+
493
+ /** Static markup carries `data-i18n*` attributes; this applies them. */
494
+ function applyStaticStrings() {
495
+ for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
496
+ for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
497
+ for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
498
+ }
499
+
500
+ /** Re-apply every user-visible string after a language switch. */
501
+ function applyLocale() {
502
+ LANG = detectLang();
503
+ try {
504
+ document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
505
+ } catch { /* headless */ }
506
+ applyStaticStrings();
507
+ // The toggles' labels and titles depend on the collapse state, not on a
508
+ // fixed key, so they are painted rather than carried as data-i18n.
509
+ paintLyricsToggle();
510
+ paintDjModelToggle();
511
+ // The gesture hint carries markup; drop it so render() rebuilds it in
512
+ // the new language.
513
+ $('gesture')?.remove();
514
+ render();
515
+ // The taste titles carry no `data-i18n`, and `render()` needs a state
516
+ // document, so they are painted here as well — otherwise a zh shell
517
+ // shows them in English until the first poll lands.
518
+ paintTaste();
519
+ paintDjModelHeader();
520
+ paintDjModelToggle();
521
+ paintQualityOptions();
522
+ if (modelRoutes.length) renderModelOptions();
523
+ }
524
+
525
+ /** Follow the shell's language while this page lives inside it. */
526
+ function watchShellLocale() {
527
+ try {
528
+ const root = window.parent?.document?.documentElement;
529
+ if (!root || typeof MutationObserver !== 'function') return;
530
+ new MutationObserver(applyLocale).observe(root, { attributes: true, attributeFilter: ['lang'] });
531
+ } catch { /* standalone */ }
532
+ }
533
+
243
534
  /** Last state document from the host. */
244
535
  let state = null;
245
536
  let lyric = null;
246
537
  let lyricLine = -1;
247
- let tab = 'search';
538
+ /** Track whose lyrics belong in the pane; drives the fetch, per track. */
539
+ let lyricTrackId = null;
540
+ /** Bumped per request so a slow reply for a skipped track is discarded. */
541
+ let lyricRequest = 0;
542
+ /** Local-storage key for whether the lyrics pane is collapsed. */
543
+ const LYRICS_PREF = 'dsh-music.lyrics.collapsed';
544
+ let lyricsCollapsed = readLyricsPref();
545
+ /** Local-storage key for whether the DJ model form is unfolded. */
546
+ const DJ_MODEL_PREF = 'dsh-music.djmodel.expanded';
547
+ let djModelExpanded = readDjModelPref();
248
548
  let volumePostAt = 0;
249
549
 
250
550
  const fmt = (ms) => {
@@ -271,10 +571,26 @@
271
571
  const text = await response.text();
272
572
  let data;
273
573
  try { data = text ? JSON.parse(text) : {}; } catch { data = { error: text.slice(0, 200) }; }
274
- if (!response.ok) throw new Error(data.error || data.message || 'HTTP ' + response.status);
574
+ if (!response.ok) {
575
+ const error = new Error(data.error || data.message || 'HTTP ' + response.status);
576
+ // The host's machine-usable half. This page localizes around it
577
+ // (login does the same) instead of showing a raw English sentence.
578
+ if (data.code) error.code = data.code;
579
+ throw error;
580
+ }
275
581
  return data;
276
582
  }
277
583
 
584
+ /**
585
+ * Tell the host a user-facing failure in the page's own language when the
586
+ * reason is one the page knows, and let the host's words through when it
587
+ * is not (a NetEase refusal carries its own explanation).
588
+ */
589
+ function failureText(error) {
590
+ if (error.code === 'ANONYMOUS') return t('likeNeedsSignIn');
591
+ return error.message;
592
+ }
593
+
278
594
  // ------------------------------------------------------------- transport
279
595
  /**
280
596
  * Who plays the audio.
@@ -393,8 +709,6 @@
393
709
  }
394
710
  audio.src = BASE + '/stream/' + track.id + '?level=' + encodeURIComponent(level);
395
711
  audio.load();
396
- // Lyrics belong to the track, so only fetch them on a real change.
397
- if (!sameTrack) loadLyrics(track.id);
398
712
  }
399
713
 
400
714
  function apply(next) {
@@ -526,7 +840,7 @@
526
840
  attempt();
527
841
  setTimeout(attempt, 200);
528
842
  setTimeout(attempt, 800);
529
- toast('正在恢复播放… / resuming…');
843
+ toast(t('resuming'));
530
844
  }
531
845
 
532
846
  // ---------------------------------------------------------------- render
@@ -535,11 +849,26 @@
535
849
  const track = state.current;
536
850
  $('cover').src = track?.picUrl || '';
537
851
  $('cover').style.visibility = track?.picUrl ? 'visible' : 'hidden';
538
- $('np-title').textContent = track ? track.name : '未播放 / nothing playing';
852
+ $('np-title').textContent = track ? track.name : t('nothingPlaying');
539
853
  $('np-artists').textContent = track ? (track.artists || []).join(' / ') : '';
540
854
  $('np-album').textContent = track ? track.album || '' : '';
855
+ // Lyrics belong to the track, and this page fetches them itself on
856
+ // every poll — not from the local transport's `applySource`, which
857
+ // only ever runs when this page owns the audio element. In the normal
858
+ // case the shell engine plays, so that path is dead and the pane
859
+ // stayed empty forever.
860
+ const trackId = track?.id ?? null;
861
+ if (trackId !== lyricTrackId) {
862
+ lyricTrackId = trackId;
863
+ if (trackId) loadLyrics(trackId);
864
+ else {
865
+ lyric = null;
866
+ lyricLine = -1;
867
+ $('lyrics').innerHTML = '<p class="hint">—</p>';
868
+ }
869
+ }
541
870
  $('play').textContent = state.playing ? '⏸' : '▶';
542
- $('mode').textContent = { list: '列表', single: '单曲', shuffle: '随机' }[state.mode] || '列表';
871
+ $('mode').textContent = { list: t('modeList'), single: t('modeSingle'), shuffle: t('modeShuffle') }[state.mode] || t('modeList');
543
872
  $('counts').textContent = state.counts ? `(${state.counts.queued})` : '';
544
873
  $('dj-toggle').classList.toggle('on', Boolean(state.dj?.enabled));
545
874
  // The mood brief is persisted player setting, not part of the DJ status
@@ -550,18 +879,18 @@
550
879
  const account = state.account;
551
880
  $('account').innerHTML = account
552
881
  ? `${account.avatar ? `<img src="${esc(account.avatar)}" alt="">` : ''}<span>${esc(account.nickname)}${account.vip ? ' · VIP' : ''}</span>`
553
- : `<button id="signin">登录 Sign in</button>`;
882
+ : `<button id="signin">${esc(t('signin'))}</button>`;
554
883
  const signin = $('signin');
555
884
  if (signin) signin.onclick = openLogin;
556
885
 
557
886
  // DJ status
558
887
  const dj = state.dj || {};
559
888
  $('dj-status').innerHTML = dj.enabled || dj.lastPlan
560
- ? `<div class="dj-status">${dj.busy ? 'AI DJ 正在选曲… / planning…' : dj.error ? `AI DJ: ${esc(dj.error)}` : ''}${
889
+ ? `<div class="dj-status">${dj.busy ? esc(t('djPlanning')) : dj.error ? `AI DJ: ${esc(dj.error)}` : ''}${
561
890
  dj.lastPlan
562
891
  ? `<div><b>${esc(dj.lastPlan.source === 'model' ? 'AI DJ' : 'AI DJ (heuristic)')}</b>${dj.lastPlan.route ? ` <span style="color:var(--muted)">${esc(dj.lastPlan.route)}</span>` : ''} +${dj.lastPlan.added}: ${esc(dj.lastPlan.vibe || '')}</div><div style="color:var(--muted)">${esc((dj.lastPlan.names || []).slice(-3).join(' · '))}</div>`
563
892
  : ''
564
- }${dj.modelError && !dj.lastPlan?.route ? `<div style="color:var(--muted)">模型层不可用 / model tier unavailable: ${esc(dj.modelError)}</div>` : ''}${dj.enabled ? '<div style="color:var(--muted);margin-top:4px">持续补歌中 / keeping the queue stocked</div>' : ''}</div>`
893
+ }${dj.modelError && !dj.lastPlan?.route ? `<div style="color:var(--muted)">${esc(t('modelTierUnavailable'))}: ${esc(dj.modelError)}</div>` : ''}${dj.enabled ? `<div style="color:var(--muted);margin-top:4px">${esc(t('djStocking'))}</div>` : ''}</div>`
565
894
  : '';
566
895
 
567
896
  if (autoplayBlocked()) {
@@ -569,7 +898,7 @@
569
898
  const el = document.createElement('div');
570
899
  el.id = 'gesture';
571
900
  el.className = 'need-gesture';
572
- el.innerHTML = '浏览器需要你先与页面交互一次才会播放声音 / Chromium needs one interaction with this window before it will play audio — <button id="gesture-go" class="primary">点击播放 / Play</button> <span style="color:var(--muted)">在窗口任意处点击也可以 / clicking anywhere works too</span>';
901
+ el.innerHTML = `${esc(t('gestureText'))} — <button id="gesture-go" class="primary">${esc(t('gesturePlay'))}</button> <span style="color:var(--muted)">${esc(t('gestureNote'))}</span>`;
573
902
  $('dj-status').before(el);
574
903
  el.querySelector('#gesture-go').onclick = resumeWithGesture;
575
904
  }
@@ -594,31 +923,133 @@
594
923
  paintQualityInfo();
595
924
 
596
925
  renderQueue();
926
+ paintTaste();
597
927
  paintProgress();
598
928
  }
599
929
 
930
+ /**
931
+ * Rows the queue list shows at most. The host keeps the full queue (and
932
+ * the play history backing the stats), but the panel only paints a
933
+ * sliding window around the cursor so old, already-played tracks drop
934
+ * out of view instead of piling up.
935
+ */
936
+ const MAX_VISIBLE_ROWS = 30;
937
+
600
938
  function renderQueue() {
601
939
  if (!state) return;
602
940
  const queue = state.queue || [];
603
941
  if (queue.length === 0) {
604
- $('queue').innerHTML = '<div class="empty">队列为空,搜索或打开 AI DJ / queue empty</div>';
942
+ $('queue').innerHTML = `<div class="empty">${esc(t('queueEmpty'))}</div>`;
605
943
  return;
606
944
  }
945
+ // A window of at most MAX_VISIBLE_ROWS rows that always includes the
946
+ // current track: up to four played rows above it for context, the rest
947
+ // upcoming. Row numbers stay absolute, so trimmed rows read as a gap
948
+ // in the numbering, and the counts line names the visible range.
949
+ const cursor = state.index < 0 ? 0 : state.index;
950
+ const start = Math.max(0, Math.min(cursor - 4, queue.length - MAX_VISIBLE_ROWS));
951
+ const end = Math.min(queue.length, start + MAX_VISIBLE_ROWS);
952
+ if (end - start < queue.length) {
953
+ $('counts').textContent = `(${start + 1}–${end} / ${queue.length})`;
954
+ }
607
955
  $('queue').innerHTML = queue
608
- .map((track, index) => {
956
+ .slice(start, end)
957
+ .map((track, offset) => {
958
+ const index = start + offset;
609
959
  const playing = index === state.index;
610
960
  return `<div class="row ${playing ? 'playing' : ''}" data-jump="${index}">
611
961
  <span class="idx">${playing ? '▶' : index + 1}</span>
612
962
  <img src="${esc(track.picUrl || '')}" alt="" loading="lazy">
613
963
  <span class="meta"><span class="name">${esc(track.name)}</span><span class="sub">${esc((track.artists || []).join(' / '))}</span></span>
614
964
  ${track.vip ? '<span class="tag">VIP</span>' : ''}
965
+ <button class="heart ${track.liked ? 'on' : ''}" data-like="${index}" title="${esc(t(track.liked ? 'unlike' : 'like'))}">${track.liked ? '♥' : '♡'}</button>
615
966
  <span class="dur">${fmt(track.duration)}</span>
616
- <span class="acts"><button data-remove="${index}" title="移除">✕</button></span>
967
+ <span class="acts"><button data-remove="${index}" title="${esc(t('remove'))}">✕</button></span>
617
968
  </div>`;
618
969
  })
619
970
  .join('');
620
971
  }
621
972
 
973
+ /**
974
+ * The transport row's taste controls mirror the current track.
975
+ *
976
+ * A filled heart is the account's like and a filled ✕ the local dislike,
977
+ * and a click moves the track to the *other* level — so the label has to
978
+ * say which direction, which is why the titles are painted here rather
979
+ * than carried as `data-i18n` on the markup.
980
+ */
981
+ function paintTaste() {
982
+ const track = state?.current;
983
+ const liked = Boolean(track?.liked);
984
+ const disliked = Boolean(track?.disliked);
985
+ $('like').textContent = liked ? '♥' : '♡';
986
+ $('like').classList.toggle('on', liked);
987
+ $('like').title = t(liked ? 'unlike' : 'like');
988
+ $('dislike').classList.toggle('on', disliked);
989
+ $('dislike').title = t(disliked ? 'undislike' : 'dislike');
990
+ }
991
+
992
+ /**
993
+ * Take one track out of the queue.
994
+ *
995
+ * This is the queue endpoint, not `/taste`: the host drops the row and, in
996
+ * the same request, reads the removal as a dislike of a track that carries
997
+ * no level yet — so the row does not come straight back from the DJ's next
998
+ * batch. `/taste` alone only writes the level, which leaves the row in the
999
+ * list, and its NetEase half can be refused, which must never keep a row
1000
+ * the click asked to remove.
1001
+ *
1002
+ * @returns {Promise<boolean>} whether the row really left the queue, so the
1003
+ * caller's confirm describes the removal the click asked for and not the
1004
+ * judgement the host makes of it.
1005
+ */
1006
+ async function removeFromList(index, button) {
1007
+ const track = state?.queue?.[index];
1008
+ if (!track) return false;
1009
+ // The trip to NetEase is not instant on a cold cache, so a second click
1010
+ // mid-flight is locked out, exactly as the transport switchers do.
1011
+ if (button) button.disabled = true;
1012
+ try {
1013
+ state = await api('/queue', { method: 'POST', body: { action: 'remove', index } });
1014
+ // Read the answer off the new queue: an index that named a row someone
1015
+ // else had already dropped must not claim a removal that did not happen.
1016
+ return !(state.queue || []).some((entry) => entry.id === track.id);
1017
+ } finally {
1018
+ // `render()` replaces the row markup, so this node may be detached —
1019
+ // harmless, and it keeps a button the host refused usable.
1020
+ if (button) button.disabled = false;
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Move one track to a taste level on the host and adopt its answer.
1026
+ *
1027
+ * `liked` and `none` change the NetEase account and need a session, which
1028
+ * the host reports as `code: ANONYMOUS`; `disliked` is local, so it always
1029
+ * applies. Returns whether the level really moved, so a caller's toast
1030
+ * cannot claim a like NetEase refused.
1031
+ */
1032
+ async function sendTaste(track, level, button) {
1033
+ // The trip to NetEase is not instant on a cold cache. This guards
1034
+ // against a second click mid-flight; the row still updates at the end,
1035
+ // in the same frame as the toast, so the lag is honest rather than
1036
+ // promised.
1037
+ if (button) button.disabled = true;
1038
+ try {
1039
+ state = await api('/taste', { method: 'POST', body: { trackId: track.id, level } });
1040
+ render();
1041
+ applyTransport();
1042
+ return true;
1043
+ } catch (error) {
1044
+ toast(failureText(error));
1045
+ return false;
1046
+ } finally {
1047
+ // `render()` replaces the row markup, so this node may be detached —
1048
+ // harmless, and it keeps the transport buttons (which are not) usable.
1049
+ if (button) button.disabled = false;
1050
+ }
1051
+ }
1052
+
622
1053
  function paintProgress() {
623
1054
  if (!state || !state.current) return;
624
1055
  const position = positionMs();
@@ -631,13 +1062,49 @@
631
1062
  }
632
1063
 
633
1064
  // ---------------------------------------------------------------- lyrics
1065
+ /** Collapsed preference, remembered across reloads when storage is open. */
1066
+ function readLyricsPref() {
1067
+ try { return localStorage.getItem(LYRICS_PREF) === '1'; } catch { return false; }
1068
+ }
1069
+
1070
+ function saveLyricsPref(collapsed) {
1071
+ try { localStorage.setItem(LYRICS_PREF, collapsed ? '1' : '0'); } catch { /* storage denied */ }
1072
+ }
1073
+
1074
+ /** Reflect `lyricsCollapsed` in the pane and in the header button. */
1075
+ function paintLyricsToggle() {
1076
+ $('lyrics').classList.toggle('collapsed', lyricsCollapsed);
1077
+ const button = $('lyrics-toggle');
1078
+ button.textContent = t(lyricsCollapsed ? 'lyricsShow' : 'lyricsHide');
1079
+ button.title = t(lyricsCollapsed ? 'lyricsToggleShow' : 'lyricsToggleHide');
1080
+ button.setAttribute('aria-expanded', lyricsCollapsed ? 'false' : 'true');
1081
+ }
1082
+
1083
+ $('lyrics-toggle').onclick = () => {
1084
+ lyricsCollapsed = !lyricsCollapsed;
1085
+ saveLyricsPref(lyricsCollapsed);
1086
+ paintLyricsToggle();
1087
+ // A hidden pane has no scroll position to keep, so re-expanding puts
1088
+ // the highlighted line back in view instead of waiting for the next
1089
+ // line change to scroll there.
1090
+ if (!lyricsCollapsed) {
1091
+ lyricLine = -1;
1092
+ paintLyrics();
1093
+ }
1094
+ };
1095
+
634
1096
  async function loadLyrics(id) {
1097
+ const request = ++lyricRequest;
635
1098
  lyric = null;
636
- $('lyrics').innerHTML = '<p class="hint">加载中… / loading…</p>';
1099
+ lyricLine = -1;
1100
+ $('lyrics').innerHTML = `<p class="hint">${esc(t('loading'))}</p>`;
637
1101
  try {
638
1102
  const data = await api('/lyric/' + id);
1103
+ // Skipping tracks faster than NetEase answers would otherwise let an
1104
+ // earlier reply land on the newer track's pane.
1105
+ if (request !== lyricRequest) return;
639
1106
  if (data.noLyric || !data.lrc) {
640
- $('lyrics').innerHTML = '<p class="hint">纯音乐,无歌词 / instrumental</p>';
1107
+ $('lyrics').innerHTML = `<p class="hint">${esc(t('instrumental'))}</p>`;
641
1108
  return;
642
1109
  }
643
1110
  const lines = [];
@@ -654,6 +1121,7 @@
654
1121
  lyric = lines;
655
1122
  $('lyrics').innerHTML = lines.map((line, index) => `<p data-line="${index}">${esc(line.text || '♪')}</p>`).join('');
656
1123
  } catch (error) {
1124
+ if (request !== lyricRequest) return;
657
1125
  $('lyrics').innerHTML = `<p class="hint">${esc(error.message)}</p>`;
658
1126
  }
659
1127
  }
@@ -674,66 +1142,6 @@
674
1142
  if (current) current.scrollIntoView({ block: 'center', behavior: 'smooth' });
675
1143
  }
676
1144
 
677
- // --------------------------------------------------------------- browser
678
- function trackRow(track, index, context) {
679
- return `<div class="row" data-play="${track.id}">
680
- <span class="idx">${index + 1}</span>
681
- <img src="${esc(track.picUrl || '')}" alt="" loading="lazy">
682
- <span class="meta"><span class="name">${esc(track.name)}</span><span class="sub">${esc((track.artists || []).join(' / '))}${track.album ? ' · ' + esc(track.album) : ''}</span></span>
683
- ${track.vip ? '<span class="tag">VIP</span>' : ''}
684
- <span class="dur">${fmt(track.duration)}</span>
685
- <span class="acts"><button data-queue="${track.id}" title="加入队列">+</button></span>
686
- </div>`;
687
- }
688
-
689
- async function renderBrowser() {
690
- const el = $('browser');
691
- if (tab === 'search') {
692
- if (!el.dataset.done) el.innerHTML = '<div class="empty">搜索歌曲开始播放 / search to begin</div>';
693
- return;
694
- }
695
- el.dataset.done = '';
696
- el.innerHTML = '<div class="empty">加载中… / loading…</div>';
697
- try {
698
- if (tab === 'charts') {
699
- const [charts, forYou] = await Promise.all([api('/charts'), api('/playlists')]);
700
- el.innerHTML =
701
- '<div class="section-title" style="padding-left:8px">官方榜单 Charts</div><div class="card-grid">' +
702
- charts.map((chart) => `<div class="card" data-chart="${chart.id}"><img src="${esc(chart.cover || '')}" alt="" loading="lazy"><div class="name">${esc(chart.name)}</div><div class="sub">${esc(chart.updateFrequency || '')}</div></div>`).join('') +
703
- '</div><div class="section-title" style="padding-left:8px">推荐歌单 Playlists</div><div class="card-grid">' +
704
- forYou.map((list) => `<div class="card" data-list="${list.id}"><img src="${esc(list.cover || '')}" alt="" loading="lazy"><div class="name">${esc(list.name)}</div><div class="sub">${list.trackCount} 首</div></div>`).join('') +
705
- '</div>';
706
- } else {
707
- const fresh = await api('/new');
708
- el.innerHTML = '<div class="section-title" style="padding-left:8px">新歌推荐 New songs</div>' + fresh.map((track, index) => trackRow(track, index)).join('');
709
- }
710
- } catch (error) {
711
- el.innerHTML = `<div class="empty">${esc(error.message)}</div>`;
712
- }
713
- }
714
-
715
- async function doSearch() {
716
- const query = $('q').value.trim();
717
- if (!query) return;
718
- tab = 'search';
719
- document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'search'));
720
- $('browser').innerHTML = '<div class="empty">搜索中… / searching…</div>';
721
- try {
722
- const data = await api('/search?q=' + encodeURIComponent(query));
723
- $('browser').innerHTML = data.tracks.length
724
- ? `<div class="section-title" style="padding-left:8px">${data.total} 个结果</div>` + data.tracks.map((track, index) => trackRow(track, index)).join('')
725
- : '<div class="empty">没有结果 / no results</div>';
726
- } catch (error) {
727
- $('browser').innerHTML = `<div class="empty">${esc(error.message)}</div>`;
728
- }
729
- }
730
-
731
- async function playTracks(tracks, startIndex) {
732
- state = await api('/play', { method: 'POST', body: { tracks, startIndex: startIndex || 0 } });
733
- render();
734
- pokeEngine();
735
- }
736
-
737
1145
  // -------------------------------------------------------------- QR login
738
1146
  function drawQr(text) {
739
1147
  const canvas = $('qr');
@@ -761,15 +1169,33 @@
761
1169
  }
762
1170
  }
763
1171
 
1172
+ /**
1173
+ * The QR state line. The host's `state` field is stable vocabulary, so
1174
+ * it localizes here; free-form host messages (agent-facing, bilingual)
1175
+ * are the fallback for anything unknown, and a poll failure carries the
1176
+ * raw error in `error`.
1177
+ */
1178
+ function loginMessage(data) {
1179
+ if (data.error) return t('pollFailed') + data.error;
1180
+ const key = {
1181
+ waiting: 'loginWaiting',
1182
+ scanned: 'loginScanned',
1183
+ expired: 'loginExpired',
1184
+ success: 'loginSuccess',
1185
+ idle: 'loginIdle',
1186
+ }[data.state];
1187
+ return key ? t(key) : data.message || data.state;
1188
+ }
1189
+
764
1190
  let qrTimer;
765
1191
  async function openLogin() {
766
1192
  const dialog = $('login');
767
1193
  dialog.showModal();
768
- $('qr-state').textContent = '正在获取二维码… / fetching QR…';
1194
+ $('qr-state').textContent = t('qrFetching');
769
1195
  try {
770
1196
  const data = await api('/login/qr', { method: 'POST', body: {} });
771
1197
  drawQr(data.loginUrl);
772
- $('qr-state').textContent = '用网易云音乐 App 扫码 / scan with the NetEase app';
1198
+ $('qr-state').textContent = t('qrScan');
773
1199
  } catch (error) {
774
1200
  $('qr-state').textContent = error.message;
775
1201
  }
@@ -783,12 +1209,12 @@
783
1209
  if (data.state === 'success') {
784
1210
  clearInterval(qrTimer);
785
1211
  $('login').close();
786
- toast('登录成功 / signed in');
1212
+ toast(t('loginSuccess'));
787
1213
  await refresh();
788
1214
  return;
789
1215
  }
790
1216
  if (data.loginUrl) drawQr(data.loginUrl);
791
- $('qr-state').textContent = data.message || data.state;
1217
+ $('qr-state').textContent = loginMessage(data);
792
1218
  } catch (error) {
793
1219
  $('qr-state').textContent = error.message;
794
1220
  }
@@ -798,21 +1224,23 @@
798
1224
  $('qr-close').onclick = () => { clearInterval(qrTimer); $('login').close(); };
799
1225
  $('login').addEventListener('close', () => clearInterval(qrTimer));
800
1226
 
801
- // ---------------------------------------------------------- curator model
1227
+ // ---------------------------------------------------------- DJ model
802
1228
  /**
803
- * The curator picker.
1229
+ * The DJ model picker.
804
1230
  *
805
1231
  * Routes and their catalogued models come from the host's LLM service.
806
1232
  * The catalogue is advisory — core resolution accepts unlisted ids — so
807
- * "其他… / Other…" reveals a text field for any id the route serves. A
1233
+ * the "Other…" entry reveals a text field for any id the route serves. A
808
1234
  * saved choice persists in the session document and takes precedence over
809
- * the profile patch, which "回退 Revert" falls back to.
1235
+ * the profile patch, which the "Revert" button falls back to.
810
1236
  */
811
- let curatorRoutes = [];
812
- let curatorSelected = { provider: null, model: null };
813
- let curatorConfigured = { provider: null, model: null };
1237
+ let modelRoutes = [];
1238
+ let modelSelected = { provider: null, model: null };
1239
+ let modelConfigured = { provider: null, model: null };
1240
+ /** Raw source key from the host; localized on every paint. */
1241
+ let modelSourceRaw = '';
814
1242
  const CUSTOM = '__custom__';
815
- const CURATOR_CONTROLS = [
1243
+ const MODEL_CONTROLS = [
816
1244
  'dj-provider',
817
1245
  'dj-model-select',
818
1246
  'dj-model-custom',
@@ -822,23 +1250,76 @@
822
1250
  ];
823
1251
 
824
1252
  /** Enable or disable every picker control at once. */
825
- function setCuratorEnabled(enabled) {
826
- for (const id of CURATOR_CONTROLS) {
1253
+ function setModelControls(enabled) {
1254
+ for (const id of MODEL_CONTROLS) {
827
1255
  const node = $(id);
828
1256
  if (node) node.disabled = !enabled;
829
1257
  }
830
1258
  }
831
1259
 
1260
+ /** Unfolded preference, remembered across reloads when storage is open. */
1261
+ function readDjModelPref() {
1262
+ try { return localStorage.getItem(DJ_MODEL_PREF) === '1'; } catch { return false; }
1263
+ }
1264
+
1265
+ function saveDjModelPref(expanded) {
1266
+ try { localStorage.setItem(DJ_MODEL_PREF, expanded ? '1' : '0'); } catch { /* storage denied */ }
1267
+ }
1268
+
1269
+ /** Reflect `djModelExpanded` in the card and in its chevron. */
1270
+ function paintDjModelToggle() {
1271
+ $('dj-model-body').classList.toggle('collapsed', !djModelExpanded);
1272
+ const button = $('dj-model-toggle');
1273
+ button.textContent = djModelExpanded ? '▾' : '▸';
1274
+ button.title = t(djModelExpanded ? 'djModelToggleHide' : 'djModelToggleShow');
1275
+ button.setAttribute('aria-expanded', djModelExpanded ? 'true' : 'false');
1276
+ }
1277
+
1278
+ /**
1279
+ * The folded card's one line: the effective route and, after it, where
1280
+ * that choice came from. The route leads because it is the answer to the
1281
+ * question the folded card raises — "which model is the DJ using?".
1282
+ */
1283
+ function paintDjModelHeader() {
1284
+ const { provider, model } = modelSelected ?? {};
1285
+ $('dj-model-route').textContent = provider ? (model ? `${provider}/${model}` : provider) : '';
1286
+ paintModelSource();
1287
+ }
1288
+
1289
+ /**
1290
+ * The form's status line, mirrored as a marker on the header.
1291
+ *
1292
+ * A route error or a host that predates this feature would otherwise be
1293
+ * invisible while the form is folded, which is exactly when the picker
1294
+ * looks like it is simply missing.
1295
+ */
1296
+ function paintModelStatus(text) {
1297
+ const message = text ?? '';
1298
+ $('dj-model-status').textContent = message;
1299
+ const warn = $('dj-model-warn');
1300
+ warn.textContent = message ? ' ⚠' : '';
1301
+ warn.title = message;
1302
+ }
1303
+
1304
+ // The header is the click target and the chevron sits inside it, so a
1305
+ // click on the button bubbles here once rather than toggling twice.
1306
+ $('dj-model-head').onclick = () => {
1307
+ djModelExpanded = !djModelExpanded;
1308
+ saveDjModelPref(djModelExpanded);
1309
+ paintDjModelToggle();
1310
+ };
1311
+ paintDjModelToggle();
1312
+
832
1313
  function renderModelOptions() {
833
1314
  const provider = $('dj-provider').value;
834
- const route = curatorRoutes.find((entry) => entry.provider === provider);
1315
+ const route = modelRoutes.find((entry) => entry.provider === provider);
835
1316
  const models = route?.models ?? [];
836
1317
  const select = $('dj-model-select');
837
1318
  select.innerHTML =
838
1319
  models.map((model) => `<option value="${esc(model.id)}">${esc(model.name || model.id)}</option>`).join('') +
839
- `<option value="${CUSTOM}">其他… / Other…</option>`;
1320
+ `<option value="${CUSTOM}">${esc(t('other'))}</option>`;
840
1321
 
841
- const pinned = curatorSelected.provider === provider ? curatorSelected.model : null;
1322
+ const pinned = modelSelected.provider === provider ? modelSelected.model : null;
842
1323
  const custom = $('dj-model-custom');
843
1324
  if (pinned && models.some((model) => model.id === pinned)) {
844
1325
  select.value = pinned;
@@ -851,46 +1332,65 @@
851
1332
  } else {
852
1333
  custom.style.display = 'none';
853
1334
  }
854
- if (route?.error) $('dj-model-status').textContent = route.error;
1335
+ if (route?.error) paintModelStatus(route.error);
855
1336
  }
856
1337
 
857
- async function loadCurator() {
1338
+ /**
1339
+ * Where the effective model choice comes from, localized.
1340
+ *
1341
+ * It trails the route on the folded card, so the separator belongs to it:
1342
+ * with no route to trail, a leading "·" would dangle.
1343
+ */
1344
+ function paintModelSource() {
1345
+ const key = {
1346
+ panel: 'srcPanel',
1347
+ config: 'srcConfig',
1348
+ 'agent-default': 'srcAgentDefault',
1349
+ discovered: 'srcDiscovered',
1350
+ }[modelSourceRaw];
1351
+ const label = key ? t(key) : modelSourceRaw || '';
1352
+ const route = $('dj-model-route').textContent;
1353
+ $('dj-model-source').textContent = label && route ? ` · ${label}` : label;
1354
+ }
1355
+
1356
+ async function loadModelPicker() {
858
1357
  try {
859
1358
  const data = await api('/dj/models');
860
- setCuratorEnabled(true);
861
- curatorRoutes = data.routes ?? [];
862
- curatorSelected = data.selected ?? { provider: null, model: null };
863
- curatorConfigured = data.configured ?? { provider: null, model: null };
1359
+ setModelControls(true);
1360
+ modelRoutes = data.routes ?? [];
1361
+ modelSelected = data.selected ?? { provider: null, model: null };
1362
+ modelConfigured = data.configured ?? { provider: null, model: null };
864
1363
 
865
- const providers = curatorRoutes.map((route) => route.provider);
1364
+ const providers = modelRoutes.map((route) => route.provider);
866
1365
  // Keep a value the catalogue does not list, so a working pin is not
867
1366
  // silently replaced by whatever the adapter happens to offer.
868
- for (const value of [curatorSelected.provider, curatorConfigured.provider]) {
1367
+ for (const value of [modelSelected.provider, modelConfigured.provider]) {
869
1368
  if (value && !providers.includes(value)) providers.push(value);
870
1369
  }
871
1370
  const select = $('dj-provider');
872
1371
  select.innerHTML = providers.length
873
1372
  ? providers.map((provider) => `<option value="${esc(provider)}">${esc(provider)}</option>`).join('')
874
- : '<option value="">(无 provider / none)</option>';
875
- select.value = curatorSelected.provider ?? curatorConfigured.provider ?? providers[0] ?? '';
1373
+ : `<option value="">${esc(t('noProvider'))}</option>`;
1374
+ select.value = modelSelected.provider ?? modelConfigured.provider ?? providers[0] ?? '';
1375
+
1376
+ // Painted before the options are rendered: that call is what reports a
1377
+ // provider's own catalogue failure, and it must land last so the
1378
+ // folded card's marker cannot be wiped by the (usually empty)
1379
+ // host-level error.
1380
+ modelSourceRaw = data.source ?? '';
1381
+ paintDjModelHeader();
1382
+ paintModelStatus(data.error ?? '');
876
1383
  renderModelOptions();
877
-
878
- $('dj-model-source').textContent =
879
- { panel: '面板 / panel', config: '配置 / profile', discovered: '自动 / auto' }[data.source] ?? data.source ?? '';
880
- $('dj-model-status').textContent = data.error ?? '';
881
1384
  } catch (error) {
882
1385
  // A 404 here means this page is newer than the host module: the page
883
1386
  // is re-read from disk on every plugin mount, while Node keeps
884
1387
  // serving the cached module. Only a process restart reunites them, so
885
1388
  // say that instead of showing a bare "unknown endpoint".
886
1389
  if (/unknown endpoint|\b404\b/.test(error.message)) {
887
- setCuratorEnabled(false);
888
- $('dj-model-status').textContent =
889
- '宿主模块早于本功能:请完全退出并重新启动 Harness(关闭窗口不够,它会留在托盘里)/ ' +
890
- 'the host module predates this feature — fully quit and relaunch the harness ' +
891
- '(closing the window is not enough; it stays running in the tray)';
1390
+ setModelControls(false);
1391
+ paintModelStatus(t('hostPredatesLong'));
892
1392
  } else {
893
- $('dj-model-status').textContent = error.message;
1393
+ paintModelStatus(error.message);
894
1394
  }
895
1395
  }
896
1396
  }
@@ -902,11 +1402,11 @@
902
1402
  : $('dj-model-select').value;
903
1403
  }
904
1404
 
905
- async function saveCurator(provider, model, message) {
1405
+ async function saveModelRoute(provider, model, message) {
906
1406
  try {
907
1407
  state = await api('/dj', { method: 'POST', body: { provider, model } });
908
1408
  render();
909
- await loadCurator();
1409
+ await loadModelPicker();
910
1410
  toast(message);
911
1411
  } catch (error) {
912
1412
  toast(error.message);
@@ -921,26 +1421,25 @@
921
1421
  const provider = $('dj-provider').value;
922
1422
  const model = chosenModel();
923
1423
  if (!provider || !model) {
924
- toast('请先选择 provider 与 model / pick a provider and a model');
1424
+ toast(t('pickProviderModel'));
925
1425
  return;
926
1426
  }
927
- void saveCurator(provider, model, `已保存 / saved ${provider}/${model}`);
1427
+ void saveModelRoute(provider, model, t('saved', { route: `${provider}/${model}` }));
928
1428
  };
929
1429
  $('dj-model-clear').onclick = () =>
930
- void saveCurator('', '', '已回退到 profile 配置 / reverted to the profile config');
1430
+ void saveModelRoute('', '', t('reverted'));
931
1431
  $('dj-model-test').onclick = async () => {
932
- $('dj-model-status').textContent = '测试中… / testing…';
1432
+ paintModelStatus(t('testing'));
933
1433
  try {
934
1434
  const result = await api('/dj/test', { method: 'POST', body: {} });
935
1435
  if (result.source === 'model') {
936
- $('dj-model-status').textContent =
937
- `✓ ${result.route} — ${(result.picked ?? []).join(' · ')}${result.vibe ? ` (${result.vibe})` : ''}`;
1436
+ paintModelStatus(
1437
+ `✓ ${result.route} — ${(result.picked ?? []).join(' · ')}${result.vibe ? ` (${result.vibe})` : ''}`);
938
1438
  } else {
939
- $('dj-model-status').textContent =
940
- `✗ ${result.modelError ?? result.note ?? 'model tier unavailable'} — 已用启发式 / heuristics`;
1439
+ paintModelStatus(`✗ ${result.modelError ?? result.note ?? t('modelTierUnavailable')} — ${t('heuristics')}`);
941
1440
  }
942
1441
  } catch (error) {
943
- $('dj-model-status').textContent = error.message;
1442
+ paintModelStatus(error.message);
944
1443
  }
945
1444
  };
946
1445
 
@@ -956,6 +1455,24 @@
956
1455
  */
957
1456
  let qualityLevels = [];
958
1457
 
1458
+ /**
1459
+ * One level's display name. The dictionary knows the shipped levels;
1460
+ * anything else falls back to the host's own (bilingual) label.
1461
+ */
1462
+ function qualityLabel(entry) {
1463
+ return DICT[LANG]?.['quality.' + entry.id] ?? DICT.en['quality.' + entry.id] ?? entry.label ?? entry.id;
1464
+ }
1465
+
1466
+ /** Rebuild the picker options, preserving the current selection. */
1467
+ function paintQualityOptions() {
1468
+ if (qualityLevels.length === 0) return;
1469
+ const current = $('quality').value;
1470
+ $('quality').innerHTML = qualityLevels
1471
+ .map((entry) => `<option value="${esc(entry.id)}">${esc(qualityLabel(entry))}</option>`)
1472
+ .join('');
1473
+ if (qualityLevels.some((entry) => entry.id === current)) $('quality').value = current;
1474
+ }
1475
+
959
1476
  /** Caption for what the last stream actually served. */
960
1477
  function paintQualityInfo() {
961
1478
  const last = state?.audio?.last;
@@ -966,22 +1483,20 @@
966
1483
  }
967
1484
  const kbps = last.br ? ` · ${Math.round(last.br / 1000)} kbps` : '';
968
1485
  $('quality-info').textContent =
969
- `▶ ${String(last.type ?? '').toUpperCase()}${kbps}` + (last.downgraded ? ` (${last.served}, 已降级/downgraded)` : '');
1486
+ `▶ ${String(last.type ?? '').toUpperCase()}${kbps}` + (last.downgraded ? ` (${last.served}, ${t('downgraded')})` : '');
970
1487
  }
971
1488
 
972
1489
  async function loadQuality() {
973
1490
  try {
974
1491
  const data = await api('/quality');
975
1492
  qualityLevels = data.levels ?? [];
976
- $('quality').innerHTML = qualityLevels
977
- .map((entry) => `<option value="${esc(entry.id)}">${esc(entry.label || entry.id)}</option>`)
978
- .join('');
1493
+ paintQualityOptions();
979
1494
  $('quality').value = data.preferred ?? 'exhigh';
980
1495
  paintQualityInfo();
981
1496
  } catch (error) {
982
1497
  // A host without this endpoint is older than this page; say so once.
983
1498
  $('quality-info').textContent = /unknown endpoint|\b404\b/.test(error.message)
984
- ? '宿主模块早于本功能 / host module predates this feature'
1499
+ ? t('hostPredatesShort')
985
1500
  : error.message;
986
1501
  }
987
1502
  }
@@ -993,7 +1508,7 @@
993
1508
  applyTransport();
994
1509
  pokeEngine();
995
1510
  paintQualityInfo();
996
- toast('音质已设为 / quality set to ' + event.target.value);
1511
+ toast(t('qualitySet', { level: event.target.value }));
997
1512
  } catch (error) {
998
1513
  toast(error.message);
999
1514
  }
@@ -1020,64 +1535,61 @@
1020
1535
  $('seek').onchange = (event) => {
1021
1536
  control({ action: 'seek', value: Math.round((Number(event.target.value) / 1000) * durationMs()) });
1022
1537
  };
1023
- $('like').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'likes', trackId: state.current.id } }).then(() => toast('已喜欢 / liked'));
1024
- $('dislike').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'dislikes', trackId: state.current.id } }).then(() => { toast('不再推荐 / disliked'); control({ action: 'next' }); });
1025
- $('go').onclick = doSearch;
1026
- $('q').onkeydown = (event) => { if (event.key === 'Enter') doSearch(); };
1538
+ // Both taste buttons are switchers: the filled state is the one that
1539
+ // removes, so the click asks the host for the level it moves to.
1540
+ $('like').onclick = async (event) => {
1541
+ const track = state?.current;
1542
+ if (!track) return;
1543
+ if (await sendTaste(track, track.liked ? 'none' : 'liked', event?.currentTarget)) {
1544
+ toast(t(track.liked ? 'unliked' : 'liked'));
1545
+ }
1546
+ };
1547
+ $('dislike').onclick = async (event) => {
1548
+ const track = state?.current;
1549
+ if (!track) return;
1550
+ if (track.disliked) {
1551
+ await sendTaste(track, 'none', event?.currentTarget);
1552
+ return;
1553
+ }
1554
+ if (await sendTaste(track, 'disliked', event?.currentTarget)) {
1555
+ toast(t('disliked'));
1556
+ control({ action: 'next' });
1557
+ }
1558
+ };
1027
1559
 
1028
1560
  $('dj-toggle').onclick = async () => {
1029
1561
  try {
1030
1562
  const data = await api('/dj', { method: 'POST', body: { enabled: !state?.dj?.enabled, prompt: $('dj-prompt').value } });
1031
1563
  state = data;
1032
1564
  render();
1033
- toast(state.dj?.enabled ? 'AI DJ 已开启 / AI DJ on' : 'AI DJ 已关闭 / AI DJ off');
1565
+ toast(state.dj?.enabled ? t('djOn') : t('djOff'));
1034
1566
  } catch (error) { toast(error.message); }
1035
1567
  };
1036
1568
  $('dj-prompt').onchange = () => api('/dj', { method: 'POST', body: { prompt: $('dj-prompt').value, enabled: state?.dj?.enabled } }).then((data) => { state = data; render(); }).catch((error) => toast(error.message));
1037
1569
 
1038
- document.querySelectorAll('.tabs button').forEach((button) => {
1039
- button.onclick = () => {
1040
- tab = button.dataset.tab;
1041
- document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('on', b === button));
1042
- $('searchbar').style.display = tab === 'search' ? 'flex' : 'none';
1043
- $('browser').dataset.done = '';
1044
- renderBrowser();
1045
- };
1046
- });
1047
-
1048
- $('browser').onclick = async (event) => {
1049
- const playId = event.target.closest('[data-play]')?.dataset.play;
1050
- const queueId = event.target.closest('[data-queue]')?.dataset.queue;
1051
- const chartId = event.target.closest('[data-chart]')?.dataset.chart;
1052
- const listId = event.target.closest('[data-list]')?.dataset.list;
1053
- try {
1054
- if (playId) {
1055
- const data = await api('/track/' + playId);
1056
- await playTracks([data.track], 0);
1057
- } else if (queueId) {
1058
- const data = await api('/track/' + queueId);
1059
- state = await api('/queue', { method: 'POST', body: { action: 'append', tracks: [data.track] } });
1060
- render(); pokeEngine(); toast('已加入队列 / queued');
1061
- } else if (chartId || listId) {
1062
- const data = await api('/playlist/' + (chartId || listId));
1063
- await playTracks(data.tracks, 0);
1064
- toast('播放歌单 ' + data.name);
1065
- }
1066
- } catch (error) { toast(error.message); }
1067
- };
1068
-
1069
1570
  $('queue').onclick = async (event) => {
1571
+ const like = event.target.closest('[data-like]');
1070
1572
  const jump = event.target.closest('[data-jump]')?.dataset.jump;
1071
- const remove = event.target.closest('[data-remove]')?.dataset.remove;
1573
+ const remove = event.target.closest('[data-remove]');
1072
1574
  try {
1073
- if (remove !== undefined) {
1074
- state = await api('/queue', { method: 'POST', body: { action: 'remove', index: Number(remove) } });
1575
+ // A heart inside a row must not also count as "play this row".
1576
+ if (like) {
1577
+ const track = state?.queue?.[Number(like.dataset.like)];
1578
+ if (!track) return;
1579
+ await sendTaste(track, track.liked ? 'none' : 'liked', like);
1580
+ return;
1581
+ }
1582
+ if (remove) {
1583
+ // The host drops the row and, for an unrated track, records the
1584
+ // removal as the same dislike the transport ✕ makes — one request.
1585
+ // The confirm names the click, not that side effect of it.
1586
+ if (await removeFromList(Number(remove.dataset.remove), remove)) toast(t('removed'));
1075
1587
  } else if (jump !== undefined) {
1076
1588
  state = await api('/queue', { method: 'POST', body: { action: 'jump', index: Number(jump) } });
1077
1589
  } else return;
1078
1590
  render();
1079
1591
  pokeEngine();
1080
- } catch (error) { toast(error.message); }
1592
+ } catch (error) { toast(failureText(error)); }
1081
1593
  };
1082
1594
 
1083
1595
  // ------------------------------------------------------------------ boot
@@ -1087,13 +1599,15 @@
1087
1599
  render();
1088
1600
  applyTransport();
1089
1601
  } catch (error) {
1090
- $('np-title').textContent = '连接失败 / cannot reach the host: ' + error.message;
1602
+ $('np-title').textContent = t('connectFailed') + error.message;
1091
1603
  }
1092
1604
  }
1093
1605
 
1606
+ applyLocale();
1607
+ watchShellLocale();
1094
1608
  refresh();
1095
1609
  setInterval(refresh, 1500);
1096
- void loadCurator();
1610
+ void loadModelPicker();
1097
1611
  void loadQuality();
1098
1612
  // Progress and lyric highlighting interpolate locally between the
1099
1613
  // engine's reports, so they need their own repaint clock now that the