@doitian/dsh-music 0.1.1 → 0.1.2

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,6 @@
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; }
93
81
 
94
82
  .now { padding: 14px; border-bottom: 1px solid var(--line); }
95
83
  .now-top { display: flex; gap: 14px; }
@@ -108,14 +96,28 @@
108
96
  .mode { font-size: 12px; }
109
97
 
110
98
  .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; }
99
+ .section-title.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
100
+ .section-title.head button {
101
+ background: none; border: 0; margin: 0; padding: 0; font: inherit; color: var(--muted);
102
+ text-transform: inherit; letter-spacing: inherit; cursor: pointer;
103
+ }
104
+ .section-title.head button:hover { color: var(--fg); }
105
+ /* The pane is a glance at the current line, not a page: four lines of
106
+ synced text, scrolled for the rest, so the queue keeps the space.
107
+ `--lyrics-lines` is the single knob (the padding below is the `+ 12px`). */
108
+ .lyrics {
109
+ --lyrics-lines: 4;
110
+ max-height: calc(var(--lyrics-lines) * 1.5em + var(--lyrics-lines) * 4px + 12px);
111
+ line-height: 1.5; overflow: auto; padding: 0 14px 12px;
112
+ }
113
+ .lyrics.collapsed { display: none; }
112
114
  .lyrics p { margin: 0; padding: 2px 0; color: var(--muted); transition: color 0.15s; }
113
115
  .lyrics p.on { color: var(--fg); font-weight: 600; }
114
116
  .queue { flex: 1; min-height: 0; display: flex; flex-direction: column; }
115
117
  .queue .scroll { padding-top: 0; }
116
118
  .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
119
  .dj-status b { color: var(--accent); }
118
- .curator-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
120
+ .model-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
119
121
  .row-inline { display: flex; gap: 6px; margin-top: 6px; align-items: center; }
120
122
  .row-inline select, .row-inline input { flex: 1 1 0; min-width: 0; font-size: 12px; padding: 4px 6px; }
121
123
  .row-inline button { font-size: 12px; padding: 4px 8px; }
@@ -140,33 +142,20 @@
140
142
  <body>
141
143
  <div id="app">
142
144
  <header>
143
- <div class="brand">音乐<small>music.163.com</small></div>
145
+ <div class="brand"><span data-i18n="brand">Music</span><small>music.163.com</small></div>
144
146
  <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>
147
+ <input id="dj-prompt" data-i18n-ph="djPromptPh" placeholder="AI DJ mood, e.g. rainy afternoon jazz" />
148
+ <button id="dj-toggle" data-i18n-title="djToggleTitle" title="Continuous AI DJ: keep the queue stocked">AI DJ</button>
147
149
  <div id="account"></div>
148
150
  </header>
149
151
 
150
152
  <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">
153
+ <section class="player">
165
154
  <div class="now">
166
155
  <div class="now-top">
167
156
  <img id="cover" alt="" />
168
157
  <div style="min-width: 0; flex: 1">
169
- <div class="now-title" id="np-title">未播放 / nothing playing</div>
158
+ <div class="now-title" id="np-title" data-i18n="nothingPlaying">Nothing playing</div>
170
159
  <div class="now-artists" id="np-artists"></div>
171
160
  <div class="now-album" id="np-album"></div>
172
161
  </div>
@@ -177,30 +166,35 @@
177
166
  <span id="t-dur">0:00</span>
178
167
  </div>
179
168
  <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>
169
+ <button id="prev" data-i18n-title="prev" title="Previous">⏮</button>
170
+ <button id="play" class="primary" data-i18n-title="playPause" title="Play/pause">▶</button>
171
+ <button id="next" data-i18n-title="next" title="Next">⏭</button>
172
+ <button id="mode" class="mode" data-i18n-title="modeTitle" data-i18n="modeList" title="Playback mode">List</button>
173
+ <select id="quality" class="mode" data-i18n-title="qualityTitle" title="Streaming quality"></select>
185
174
  <span id="quality-info" class="mode" style="color: var(--muted)"></span>
186
175
  <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>
176
+ <button id="like" data-i18n-title="like" title="Like">♡</button>
177
+ <button id="dislike" data-i18n-title="dislike" title="Dislike">✕</button>
178
+ <button id="mute" data-i18n-title="mute" title="Mute">🔊</button>
190
179
  <input type="range" id="volume" min="0" max="1" step="0.01" value="0.8" />
191
180
  </div>
192
181
  </div>
193
182
 
194
- <div class="section-title">歌词 Lyrics</div>
183
+ <div class="section-title head">
184
+ <span data-i18n="lyrics">Lyrics</span>
185
+ <!-- Label and title are state-dependent, so the script owns them
186
+ (applyStaticStrings cannot know which way the pane is now). -->
187
+ <button id="lyrics-toggle" type="button" aria-expanded="true">Hide</button>
188
+ </div>
195
189
  <div class="lyrics" id="lyrics"><p class="hint">—</p></div>
196
190
 
197
- <div class="section-title">播放队列 Queue <span id="counts"></span></div>
191
+ <div class="section-title"><span data-i18n="queue">Queue</span> <span id="counts"></span></div>
198
192
  <div class="queue"><div class="scroll" id="queue"></div></div>
199
193
  <div id="dj-status"></div>
200
194
 
201
195
  <div class="dj-status" id="dj-model">
202
- <div class="curator-head">
203
- <span>AI DJ 模型 / curator model</span>
196
+ <div class="model-head">
197
+ <span data-i18n="djModel">DJ model</span>
204
198
  <span id="dj-model-source" style="color: var(--muted)"></span>
205
199
  </div>
206
200
  <div class="row-inline">
@@ -208,12 +202,12 @@
208
202
  <select id="dj-model-select" title="model"></select>
209
203
  </div>
210
204
  <div class="row-inline">
211
- <input id="dj-model-custom" placeholder="自定义 model id / custom model id" style="display: none" />
205
+ <input id="dj-model-custom" data-i18n-ph="customModelPh" placeholder="Custom model id" style="display: none" />
212
206
  </div>
213
207
  <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>
208
+ <button id="dj-model-save" class="primary" data-i18n="save">Save</button>
209
+ <button id="dj-model-test" data-i18n="test">Test</button>
210
+ <button id="dj-model-clear" data-i18n="revert" data-i18n-title="revertTitle" title="Revert to the profile patch">Revert</button>
217
211
  </div>
218
212
  <div id="dj-model-status" style="color: var(--muted)"></div>
219
213
  </div>
@@ -222,12 +216,12 @@
222
216
  </div>
223
217
 
224
218
  <dialog id="login">
225
- <div style="font-weight: 650">网易云音乐登录 / Sign in</div>
219
+ <div style="font-weight: 650" data-i18n="loginTitle">NetEase Cloud Music sign in</div>
226
220
  <canvas id="qr" width="220" height="220"></canvas>
227
- <div class="hint" id="qr-state">正在获取二维码… / fetching QR…</div>
221
+ <div class="hint" id="qr-state">Fetching QR…</div>
228
222
  <div class="row-actions">
229
- <button id="qr-refresh">刷新 Refresh</button>
230
- <button id="qr-close" class="primary">关闭 Close</button>
223
+ <button id="qr-refresh" data-i18n="refresh">Refresh</button>
224
+ <button id="qr-close" class="primary" data-i18n="close">Close</button>
231
225
  </div>
232
226
  </dialog>
233
227
 
@@ -240,11 +234,253 @@
240
234
  const API = BASE + '/api';
241
235
  const $ = (id) => document.getElementById(id);
242
236
 
237
+ // ------------------------------------------------------------------ i18n
238
+ /**
239
+ * Locale, without ctx.
240
+ *
241
+ * This page is served as a plain document, not a client module, so the
242
+ * host's `ctx.locale` service is out of reach. The shell keeps its own
243
+ * `<html lang>` in sync with the active locale, though, and this frame
244
+ * is same-origin with the shell — reading the parent's `lang`, and
245
+ * observing it for changes, follows the Settings language live. When
246
+ * the page is opened standalone there is no parent to follow, so the
247
+ * browser's own language decides.
248
+ */
249
+ const DICT = {
250
+ zh: {
251
+ brand: '音乐',
252
+ djPromptPh: 'AI DJ 心情,例如 rainy afternoon jazz',
253
+ djToggleTitle: '持续 AI DJ:自动补歌',
254
+ nothingPlaying: '未播放',
255
+ prev: '上一首',
256
+ playPause: '播放/暂停',
257
+ next: '下一首',
258
+ modeTitle: '播放模式',
259
+ modeList: '列表',
260
+ modeSingle: '单曲',
261
+ modeShuffle: '随机',
262
+ qualityTitle: '流媒体音质',
263
+ like: '喜欢',
264
+ dislike: '不喜欢',
265
+ mute: '静音',
266
+ lyrics: '歌词',
267
+ lyricsHide: '收起',
268
+ lyricsShow: '展开',
269
+ lyricsToggleHide: '收起歌词面板',
270
+ lyricsToggleShow: '展开歌词面板',
271
+ queue: '播放队列',
272
+ djModel: 'AI DJ 模型',
273
+ customModelPh: '自定义 model id',
274
+ save: '保存',
275
+ test: '测试',
276
+ revert: '回退',
277
+ revertTitle: '回退到 profile 配置',
278
+ loginTitle: '网易云音乐登录',
279
+ qrFetching: '正在获取二维码…',
280
+ qrScan: '用网易云音乐 App 扫码',
281
+ refresh: '刷新',
282
+ close: '关闭',
283
+ signin: '登录',
284
+ loginWaiting: '等待扫码',
285
+ loginScanned: '已扫码,请在手机上确认',
286
+ loginExpired: '二维码已过期,已刷新',
287
+ loginSuccess: '登录成功',
288
+ loginIdle: '未开始',
289
+ pollFailed: '轮询失败:',
290
+ resuming: '正在恢复播放…',
291
+ djPlanning: 'AI DJ 正在选曲…',
292
+ modelTierUnavailable: '模型层不可用',
293
+ djStocking: '持续补歌中',
294
+ gestureText: '浏览器需要你先与页面交互一次才会播放声音',
295
+ gesturePlay: '点击播放',
296
+ gestureNote: '在窗口任意处点击也可以',
297
+ queueEmpty: '队列为空,用 AI DJ 或让助手补歌',
298
+ remove: '移除',
299
+ loading: '加载中…',
300
+ instrumental: '纯音乐,无歌词',
301
+ other: '其他…',
302
+ noProvider: '(无 provider)',
303
+ srcPanel: '面板',
304
+ srcConfig: '配置',
305
+ srcAgentDefault: '会话模型',
306
+ srcDiscovered: '自动',
307
+ hostPredatesLong:
308
+ '宿主模块早于本功能:请完全退出并重新启动 Harness(关闭窗口不够,它会留在托盘里)',
309
+ hostPredatesShort: '宿主模块早于本功能',
310
+ pickProviderModel: '请先选择 provider 与 model',
311
+ saved: '已保存 {route}',
312
+ reverted: '已回退到 profile 配置',
313
+ testing: '测试中…',
314
+ heuristics: '已用启发式',
315
+ downgraded: '已降级',
316
+ qualitySet: '音质已设为 {level}',
317
+ liked: '已喜欢',
318
+ disliked: '不再推荐',
319
+ djOn: 'AI DJ 已开启',
320
+ djOff: 'AI DJ 已关闭',
321
+ connectFailed: '连接失败:',
322
+ 'quality.jymaster': '超清母带',
323
+ 'quality.dolby': '杜比全景声',
324
+ 'quality.sky': '沉浸环绕声',
325
+ 'quality.jyeffect': '沉浸声',
326
+ 'quality.hires': 'Hi-Res 无损',
327
+ 'quality.lossless': '无损',
328
+ 'quality.exhigh': '极高 320 kbps',
329
+ 'quality.higher': '较高 192 kbps',
330
+ 'quality.standard': '标准 128 kbps',
331
+ },
332
+ en: {
333
+ brand: 'Music',
334
+ djPromptPh: 'AI DJ mood, e.g. rainy afternoon jazz',
335
+ djToggleTitle: 'Continuous AI DJ: keep the queue stocked',
336
+ nothingPlaying: 'Nothing playing',
337
+ prev: 'Previous',
338
+ playPause: 'Play/pause',
339
+ next: 'Next',
340
+ modeTitle: 'Playback mode',
341
+ modeList: 'List',
342
+ modeSingle: 'Single',
343
+ modeShuffle: 'Shuffle',
344
+ qualityTitle: 'Streaming quality',
345
+ like: 'Like',
346
+ dislike: 'Dislike',
347
+ mute: 'Mute',
348
+ lyrics: 'Lyrics',
349
+ lyricsHide: 'Hide',
350
+ lyricsShow: 'Show',
351
+ lyricsToggleHide: 'Collapse the lyrics pane',
352
+ lyricsToggleShow: 'Expand the lyrics pane',
353
+ queue: 'Queue',
354
+ djModel: 'DJ model',
355
+ customModelPh: 'Custom model id',
356
+ save: 'Save',
357
+ test: 'Test',
358
+ revert: 'Revert',
359
+ revertTitle: 'Revert to the profile patch',
360
+ loginTitle: 'NetEase Cloud Music sign in',
361
+ qrFetching: 'Fetching QR…',
362
+ qrScan: 'Scan with the NetEase app',
363
+ refresh: 'Refresh',
364
+ close: 'Close',
365
+ signin: 'Sign in',
366
+ loginWaiting: 'Waiting for scan',
367
+ loginScanned: 'Scanned — confirm on your phone',
368
+ loginExpired: 'QR expired; a fresh one is shown',
369
+ loginSuccess: 'Signed in',
370
+ loginIdle: 'Not started',
371
+ pollFailed: 'Poll failed: ',
372
+ resuming: 'Resuming…',
373
+ djPlanning: 'AI DJ is planning…',
374
+ modelTierUnavailable: 'model tier unavailable',
375
+ djStocking: 'keeping the queue stocked',
376
+ gestureText: 'Chromium needs one interaction with this window before it will play audio',
377
+ gesturePlay: 'Play',
378
+ gestureNote: 'clicking anywhere works too',
379
+ queueEmpty: 'Queue empty — use the AI DJ or ask the assistant to add songs',
380
+ remove: 'Remove',
381
+ loading: 'Loading…',
382
+ instrumental: 'Instrumental',
383
+ other: 'Other…',
384
+ noProvider: '(no provider)',
385
+ srcPanel: 'panel',
386
+ srcConfig: 'profile',
387
+ srcAgentDefault: 'session model',
388
+ srcDiscovered: 'auto',
389
+ hostPredatesLong:
390
+ 'The host module predates this feature — fully quit and relaunch the harness (closing the window is not enough; it stays running in the tray)',
391
+ hostPredatesShort: 'Host module predates this feature',
392
+ pickProviderModel: 'Pick a provider and a model first',
393
+ saved: 'Saved {route}',
394
+ reverted: 'Reverted to the profile config',
395
+ testing: 'Testing…',
396
+ heuristics: 'heuristics used',
397
+ downgraded: 'downgraded',
398
+ qualitySet: 'Quality set to {level}',
399
+ liked: 'Liked',
400
+ disliked: 'Disliked',
401
+ djOn: 'AI DJ on',
402
+ djOff: 'AI DJ off',
403
+ connectFailed: 'Cannot reach the host: ',
404
+ 'quality.jymaster': 'Master',
405
+ 'quality.dolby': 'Dolby Atmos',
406
+ 'quality.sky': 'Immersive',
407
+ 'quality.jyeffect': 'Spatial',
408
+ 'quality.hires': 'Hi-Res lossless',
409
+ 'quality.lossless': 'Lossless',
410
+ 'quality.exhigh': '320 kbps',
411
+ 'quality.higher': '192 kbps',
412
+ 'quality.standard': '128 kbps',
413
+ },
414
+ };
415
+ let LANG = 'en';
416
+
417
+ function detectLang() {
418
+ try {
419
+ const lang = window.parent?.document?.documentElement?.lang;
420
+ if (lang) return String(lang).toLowerCase().startsWith('zh') ? 'zh' : 'en';
421
+ } catch { /* standalone or cross-origin */ }
422
+ try {
423
+ return String(navigator.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en';
424
+ } catch { return 'en'; }
425
+ }
426
+
427
+ /** Translate a key, interpolating `{name}` placeholders from `vars`. */
428
+ function t(key, vars) {
429
+ let text = DICT[LANG]?.[key] ?? DICT.en[key] ?? key;
430
+ if (vars) {
431
+ for (const name of Object.keys(vars)) {
432
+ text = text.split('{' + name + '}').join(String(vars[name]));
433
+ }
434
+ }
435
+ return text;
436
+ }
437
+
438
+ /** Static markup carries `data-i18n*` attributes; this applies them. */
439
+ function applyStaticStrings() {
440
+ for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
441
+ for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
442
+ for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
443
+ }
444
+
445
+ /** Re-apply every user-visible string after a language switch. */
446
+ function applyLocale() {
447
+ LANG = detectLang();
448
+ try {
449
+ document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
450
+ } catch { /* headless */ }
451
+ applyStaticStrings();
452
+ // The toggle's label and title depend on the collapse state, not on a
453
+ // fixed key, so they are painted rather than carried as data-i18n.
454
+ paintLyricsToggle();
455
+ // The gesture hint carries markup; drop it so render() rebuilds it in
456
+ // the new language.
457
+ $('gesture')?.remove();
458
+ render();
459
+ paintModelSource();
460
+ paintQualityOptions();
461
+ if (modelRoutes.length) renderModelOptions();
462
+ }
463
+
464
+ /** Follow the shell's language while this page lives inside it. */
465
+ function watchShellLocale() {
466
+ try {
467
+ const root = window.parent?.document?.documentElement;
468
+ if (!root || typeof MutationObserver !== 'function') return;
469
+ new MutationObserver(applyLocale).observe(root, { attributes: true, attributeFilter: ['lang'] });
470
+ } catch { /* standalone */ }
471
+ }
472
+
243
473
  /** Last state document from the host. */
244
474
  let state = null;
245
475
  let lyric = null;
246
476
  let lyricLine = -1;
247
- let tab = 'search';
477
+ /** Track whose lyrics belong in the pane; drives the fetch, per track. */
478
+ let lyricTrackId = null;
479
+ /** Bumped per request so a slow reply for a skipped track is discarded. */
480
+ let lyricRequest = 0;
481
+ /** Local-storage key for whether the lyrics pane is collapsed. */
482
+ const LYRICS_PREF = 'dsh-music.lyrics.collapsed';
483
+ let lyricsCollapsed = readLyricsPref();
248
484
  let volumePostAt = 0;
249
485
 
250
486
  const fmt = (ms) => {
@@ -393,8 +629,6 @@
393
629
  }
394
630
  audio.src = BASE + '/stream/' + track.id + '?level=' + encodeURIComponent(level);
395
631
  audio.load();
396
- // Lyrics belong to the track, so only fetch them on a real change.
397
- if (!sameTrack) loadLyrics(track.id);
398
632
  }
399
633
 
400
634
  function apply(next) {
@@ -526,7 +760,7 @@
526
760
  attempt();
527
761
  setTimeout(attempt, 200);
528
762
  setTimeout(attempt, 800);
529
- toast('正在恢复播放… / resuming…');
763
+ toast(t('resuming'));
530
764
  }
531
765
 
532
766
  // ---------------------------------------------------------------- render
@@ -535,11 +769,26 @@
535
769
  const track = state.current;
536
770
  $('cover').src = track?.picUrl || '';
537
771
  $('cover').style.visibility = track?.picUrl ? 'visible' : 'hidden';
538
- $('np-title').textContent = track ? track.name : '未播放 / nothing playing';
772
+ $('np-title').textContent = track ? track.name : t('nothingPlaying');
539
773
  $('np-artists').textContent = track ? (track.artists || []).join(' / ') : '';
540
774
  $('np-album').textContent = track ? track.album || '' : '';
775
+ // Lyrics belong to the track, and this page fetches them itself on
776
+ // every poll — not from the local transport's `applySource`, which
777
+ // only ever runs when this page owns the audio element. In the normal
778
+ // case the shell engine plays, so that path is dead and the pane
779
+ // stayed empty forever.
780
+ const trackId = track?.id ?? null;
781
+ if (trackId !== lyricTrackId) {
782
+ lyricTrackId = trackId;
783
+ if (trackId) loadLyrics(trackId);
784
+ else {
785
+ lyric = null;
786
+ lyricLine = -1;
787
+ $('lyrics').innerHTML = '<p class="hint">—</p>';
788
+ }
789
+ }
541
790
  $('play').textContent = state.playing ? '⏸' : '▶';
542
- $('mode').textContent = { list: '列表', single: '单曲', shuffle: '随机' }[state.mode] || '列表';
791
+ $('mode').textContent = { list: t('modeList'), single: t('modeSingle'), shuffle: t('modeShuffle') }[state.mode] || t('modeList');
543
792
  $('counts').textContent = state.counts ? `(${state.counts.queued})` : '';
544
793
  $('dj-toggle').classList.toggle('on', Boolean(state.dj?.enabled));
545
794
  // The mood brief is persisted player setting, not part of the DJ status
@@ -550,18 +799,18 @@
550
799
  const account = state.account;
551
800
  $('account').innerHTML = account
552
801
  ? `${account.avatar ? `<img src="${esc(account.avatar)}" alt="">` : ''}<span>${esc(account.nickname)}${account.vip ? ' · VIP' : ''}</span>`
553
- : `<button id="signin">登录 Sign in</button>`;
802
+ : `<button id="signin">${esc(t('signin'))}</button>`;
554
803
  const signin = $('signin');
555
804
  if (signin) signin.onclick = openLogin;
556
805
 
557
806
  // DJ status
558
807
  const dj = state.dj || {};
559
808
  $('dj-status').innerHTML = dj.enabled || dj.lastPlan
560
- ? `<div class="dj-status">${dj.busy ? 'AI DJ 正在选曲… / planning…' : dj.error ? `AI DJ: ${esc(dj.error)}` : ''}${
809
+ ? `<div class="dj-status">${dj.busy ? esc(t('djPlanning')) : dj.error ? `AI DJ: ${esc(dj.error)}` : ''}${
561
810
  dj.lastPlan
562
811
  ? `<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
812
  : ''
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>`
813
+ }${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
814
  : '';
566
815
 
567
816
  if (autoplayBlocked()) {
@@ -569,7 +818,7 @@
569
818
  const el = document.createElement('div');
570
819
  el.id = 'gesture';
571
820
  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>';
821
+ 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
822
  $('dj-status').before(el);
574
823
  el.querySelector('#gesture-go').onclick = resumeWithGesture;
575
824
  }
@@ -601,7 +850,7 @@
601
850
  if (!state) return;
602
851
  const queue = state.queue || [];
603
852
  if (queue.length === 0) {
604
- $('queue').innerHTML = '<div class="empty">队列为空,搜索或打开 AI DJ / queue empty</div>';
853
+ $('queue').innerHTML = `<div class="empty">${esc(t('queueEmpty'))}</div>`;
605
854
  return;
606
855
  }
607
856
  $('queue').innerHTML = queue
@@ -613,7 +862,7 @@
613
862
  <span class="meta"><span class="name">${esc(track.name)}</span><span class="sub">${esc((track.artists || []).join(' / '))}</span></span>
614
863
  ${track.vip ? '<span class="tag">VIP</span>' : ''}
615
864
  <span class="dur">${fmt(track.duration)}</span>
616
- <span class="acts"><button data-remove="${index}" title="移除">✕</button></span>
865
+ <span class="acts"><button data-remove="${index}" title="${esc(t('remove'))}">✕</button></span>
617
866
  </div>`;
618
867
  })
619
868
  .join('');
@@ -631,13 +880,49 @@
631
880
  }
632
881
 
633
882
  // ---------------------------------------------------------------- lyrics
883
+ /** Collapsed preference, remembered across reloads when storage is open. */
884
+ function readLyricsPref() {
885
+ try { return localStorage.getItem(LYRICS_PREF) === '1'; } catch { return false; }
886
+ }
887
+
888
+ function saveLyricsPref(collapsed) {
889
+ try { localStorage.setItem(LYRICS_PREF, collapsed ? '1' : '0'); } catch { /* storage denied */ }
890
+ }
891
+
892
+ /** Reflect `lyricsCollapsed` in the pane and in the header button. */
893
+ function paintLyricsToggle() {
894
+ $('lyrics').classList.toggle('collapsed', lyricsCollapsed);
895
+ const button = $('lyrics-toggle');
896
+ button.textContent = t(lyricsCollapsed ? 'lyricsShow' : 'lyricsHide');
897
+ button.title = t(lyricsCollapsed ? 'lyricsToggleShow' : 'lyricsToggleHide');
898
+ button.setAttribute('aria-expanded', lyricsCollapsed ? 'false' : 'true');
899
+ }
900
+
901
+ $('lyrics-toggle').onclick = () => {
902
+ lyricsCollapsed = !lyricsCollapsed;
903
+ saveLyricsPref(lyricsCollapsed);
904
+ paintLyricsToggle();
905
+ // A hidden pane has no scroll position to keep, so re-expanding puts
906
+ // the highlighted line back in view instead of waiting for the next
907
+ // line change to scroll there.
908
+ if (!lyricsCollapsed) {
909
+ lyricLine = -1;
910
+ paintLyrics();
911
+ }
912
+ };
913
+
634
914
  async function loadLyrics(id) {
915
+ const request = ++lyricRequest;
635
916
  lyric = null;
636
- $('lyrics').innerHTML = '<p class="hint">加载中… / loading…</p>';
917
+ lyricLine = -1;
918
+ $('lyrics').innerHTML = `<p class="hint">${esc(t('loading'))}</p>`;
637
919
  try {
638
920
  const data = await api('/lyric/' + id);
921
+ // Skipping tracks faster than NetEase answers would otherwise let an
922
+ // earlier reply land on the newer track's pane.
923
+ if (request !== lyricRequest) return;
639
924
  if (data.noLyric || !data.lrc) {
640
- $('lyrics').innerHTML = '<p class="hint">纯音乐,无歌词 / instrumental</p>';
925
+ $('lyrics').innerHTML = `<p class="hint">${esc(t('instrumental'))}</p>`;
641
926
  return;
642
927
  }
643
928
  const lines = [];
@@ -654,6 +939,7 @@
654
939
  lyric = lines;
655
940
  $('lyrics').innerHTML = lines.map((line, index) => `<p data-line="${index}">${esc(line.text || '♪')}</p>`).join('');
656
941
  } catch (error) {
942
+ if (request !== lyricRequest) return;
657
943
  $('lyrics').innerHTML = `<p class="hint">${esc(error.message)}</p>`;
658
944
  }
659
945
  }
@@ -674,66 +960,6 @@
674
960
  if (current) current.scrollIntoView({ block: 'center', behavior: 'smooth' });
675
961
  }
676
962
 
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
963
  // -------------------------------------------------------------- QR login
738
964
  function drawQr(text) {
739
965
  const canvas = $('qr');
@@ -761,15 +987,33 @@
761
987
  }
762
988
  }
763
989
 
990
+ /**
991
+ * The QR state line. The host's `state` field is stable vocabulary, so
992
+ * it localizes here; free-form host messages (agent-facing, bilingual)
993
+ * are the fallback for anything unknown, and a poll failure carries the
994
+ * raw error in `error`.
995
+ */
996
+ function loginMessage(data) {
997
+ if (data.error) return t('pollFailed') + data.error;
998
+ const key = {
999
+ waiting: 'loginWaiting',
1000
+ scanned: 'loginScanned',
1001
+ expired: 'loginExpired',
1002
+ success: 'loginSuccess',
1003
+ idle: 'loginIdle',
1004
+ }[data.state];
1005
+ return key ? t(key) : data.message || data.state;
1006
+ }
1007
+
764
1008
  let qrTimer;
765
1009
  async function openLogin() {
766
1010
  const dialog = $('login');
767
1011
  dialog.showModal();
768
- $('qr-state').textContent = '正在获取二维码… / fetching QR…';
1012
+ $('qr-state').textContent = t('qrFetching');
769
1013
  try {
770
1014
  const data = await api('/login/qr', { method: 'POST', body: {} });
771
1015
  drawQr(data.loginUrl);
772
- $('qr-state').textContent = '用网易云音乐 App 扫码 / scan with the NetEase app';
1016
+ $('qr-state').textContent = t('qrScan');
773
1017
  } catch (error) {
774
1018
  $('qr-state').textContent = error.message;
775
1019
  }
@@ -783,12 +1027,12 @@
783
1027
  if (data.state === 'success') {
784
1028
  clearInterval(qrTimer);
785
1029
  $('login').close();
786
- toast('登录成功 / signed in');
1030
+ toast(t('loginSuccess'));
787
1031
  await refresh();
788
1032
  return;
789
1033
  }
790
1034
  if (data.loginUrl) drawQr(data.loginUrl);
791
- $('qr-state').textContent = data.message || data.state;
1035
+ $('qr-state').textContent = loginMessage(data);
792
1036
  } catch (error) {
793
1037
  $('qr-state').textContent = error.message;
794
1038
  }
@@ -798,21 +1042,23 @@
798
1042
  $('qr-close').onclick = () => { clearInterval(qrTimer); $('login').close(); };
799
1043
  $('login').addEventListener('close', () => clearInterval(qrTimer));
800
1044
 
801
- // ---------------------------------------------------------- curator model
1045
+ // ---------------------------------------------------------- DJ model
802
1046
  /**
803
- * The curator picker.
1047
+ * The DJ model picker.
804
1048
  *
805
1049
  * Routes and their catalogued models come from the host's LLM service.
806
1050
  * The catalogue is advisory — core resolution accepts unlisted ids — so
807
- * "其他… / Other…" reveals a text field for any id the route serves. A
1051
+ * the "Other…" entry reveals a text field for any id the route serves. A
808
1052
  * saved choice persists in the session document and takes precedence over
809
- * the profile patch, which "回退 Revert" falls back to.
1053
+ * the profile patch, which the "Revert" button falls back to.
810
1054
  */
811
- let curatorRoutes = [];
812
- let curatorSelected = { provider: null, model: null };
813
- let curatorConfigured = { provider: null, model: null };
1055
+ let modelRoutes = [];
1056
+ let modelSelected = { provider: null, model: null };
1057
+ let modelConfigured = { provider: null, model: null };
1058
+ /** Raw source key from the host; localized on every paint. */
1059
+ let modelSourceRaw = '';
814
1060
  const CUSTOM = '__custom__';
815
- const CURATOR_CONTROLS = [
1061
+ const MODEL_CONTROLS = [
816
1062
  'dj-provider',
817
1063
  'dj-model-select',
818
1064
  'dj-model-custom',
@@ -822,8 +1068,8 @@
822
1068
  ];
823
1069
 
824
1070
  /** Enable or disable every picker control at once. */
825
- function setCuratorEnabled(enabled) {
826
- for (const id of CURATOR_CONTROLS) {
1071
+ function setModelControls(enabled) {
1072
+ for (const id of MODEL_CONTROLS) {
827
1073
  const node = $(id);
828
1074
  if (node) node.disabled = !enabled;
829
1075
  }
@@ -831,14 +1077,14 @@
831
1077
 
832
1078
  function renderModelOptions() {
833
1079
  const provider = $('dj-provider').value;
834
- const route = curatorRoutes.find((entry) => entry.provider === provider);
1080
+ const route = modelRoutes.find((entry) => entry.provider === provider);
835
1081
  const models = route?.models ?? [];
836
1082
  const select = $('dj-model-select');
837
1083
  select.innerHTML =
838
1084
  models.map((model) => `<option value="${esc(model.id)}">${esc(model.name || model.id)}</option>`).join('') +
839
- `<option value="${CUSTOM}">其他… / Other…</option>`;
1085
+ `<option value="${CUSTOM}">${esc(t('other'))}</option>`;
840
1086
 
841
- const pinned = curatorSelected.provider === provider ? curatorSelected.model : null;
1087
+ const pinned = modelSelected.provider === provider ? modelSelected.model : null;
842
1088
  const custom = $('dj-model-custom');
843
1089
  if (pinned && models.some((model) => model.id === pinned)) {
844
1090
  select.value = pinned;
@@ -854,29 +1100,40 @@
854
1100
  if (route?.error) $('dj-model-status').textContent = route.error;
855
1101
  }
856
1102
 
857
- async function loadCurator() {
1103
+ /** Where the effective model choice comes from, localized. */
1104
+ function paintModelSource() {
1105
+ const key = {
1106
+ panel: 'srcPanel',
1107
+ config: 'srcConfig',
1108
+ 'agent-default': 'srcAgentDefault',
1109
+ discovered: 'srcDiscovered',
1110
+ }[modelSourceRaw];
1111
+ $('dj-model-source').textContent = key ? t(key) : modelSourceRaw || '';
1112
+ }
1113
+
1114
+ async function loadModelPicker() {
858
1115
  try {
859
1116
  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 };
1117
+ setModelControls(true);
1118
+ modelRoutes = data.routes ?? [];
1119
+ modelSelected = data.selected ?? { provider: null, model: null };
1120
+ modelConfigured = data.configured ?? { provider: null, model: null };
864
1121
 
865
- const providers = curatorRoutes.map((route) => route.provider);
1122
+ const providers = modelRoutes.map((route) => route.provider);
866
1123
  // Keep a value the catalogue does not list, so a working pin is not
867
1124
  // silently replaced by whatever the adapter happens to offer.
868
- for (const value of [curatorSelected.provider, curatorConfigured.provider]) {
1125
+ for (const value of [modelSelected.provider, modelConfigured.provider]) {
869
1126
  if (value && !providers.includes(value)) providers.push(value);
870
1127
  }
871
1128
  const select = $('dj-provider');
872
1129
  select.innerHTML = providers.length
873
1130
  ? 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] ?? '';
1131
+ : `<option value="">${esc(t('noProvider'))}</option>`;
1132
+ select.value = modelSelected.provider ?? modelConfigured.provider ?? providers[0] ?? '';
876
1133
  renderModelOptions();
877
1134
 
878
- $('dj-model-source').textContent =
879
- { panel: '面板 / panel', config: '配置 / profile', discovered: '自动 / auto' }[data.source] ?? data.source ?? '';
1135
+ modelSourceRaw = data.source ?? '';
1136
+ paintModelSource();
880
1137
  $('dj-model-status').textContent = data.error ?? '';
881
1138
  } catch (error) {
882
1139
  // A 404 here means this page is newer than the host module: the page
@@ -884,11 +1141,8 @@
884
1141
  // serving the cached module. Only a process restart reunites them, so
885
1142
  // say that instead of showing a bare "unknown endpoint".
886
1143
  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)';
1144
+ setModelControls(false);
1145
+ $('dj-model-status').textContent = t('hostPredatesLong');
892
1146
  } else {
893
1147
  $('dj-model-status').textContent = error.message;
894
1148
  }
@@ -902,11 +1156,11 @@
902
1156
  : $('dj-model-select').value;
903
1157
  }
904
1158
 
905
- async function saveCurator(provider, model, message) {
1159
+ async function saveModelRoute(provider, model, message) {
906
1160
  try {
907
1161
  state = await api('/dj', { method: 'POST', body: { provider, model } });
908
1162
  render();
909
- await loadCurator();
1163
+ await loadModelPicker();
910
1164
  toast(message);
911
1165
  } catch (error) {
912
1166
  toast(error.message);
@@ -921,15 +1175,15 @@
921
1175
  const provider = $('dj-provider').value;
922
1176
  const model = chosenModel();
923
1177
  if (!provider || !model) {
924
- toast('请先选择 provider 与 model / pick a provider and a model');
1178
+ toast(t('pickProviderModel'));
925
1179
  return;
926
1180
  }
927
- void saveCurator(provider, model, `已保存 / saved ${provider}/${model}`);
1181
+ void saveModelRoute(provider, model, t('saved', { route: `${provider}/${model}` }));
928
1182
  };
929
1183
  $('dj-model-clear').onclick = () =>
930
- void saveCurator('', '', '已回退到 profile 配置 / reverted to the profile config');
1184
+ void saveModelRoute('', '', t('reverted'));
931
1185
  $('dj-model-test').onclick = async () => {
932
- $('dj-model-status').textContent = '测试中… / testing…';
1186
+ $('dj-model-status').textContent = t('testing');
933
1187
  try {
934
1188
  const result = await api('/dj/test', { method: 'POST', body: {} });
935
1189
  if (result.source === 'model') {
@@ -937,7 +1191,7 @@
937
1191
  `✓ ${result.route} — ${(result.picked ?? []).join(' · ')}${result.vibe ? ` (${result.vibe})` : ''}`;
938
1192
  } else {
939
1193
  $('dj-model-status').textContent =
940
- `✗ ${result.modelError ?? result.note ?? 'model tier unavailable'} — 已用启发式 / heuristics`;
1194
+ `✗ ${result.modelError ?? result.note ?? t('modelTierUnavailable')} — ${t('heuristics')}`;
941
1195
  }
942
1196
  } catch (error) {
943
1197
  $('dj-model-status').textContent = error.message;
@@ -956,6 +1210,24 @@
956
1210
  */
957
1211
  let qualityLevels = [];
958
1212
 
1213
+ /**
1214
+ * One level's display name. The dictionary knows the shipped levels;
1215
+ * anything else falls back to the host's own (bilingual) label.
1216
+ */
1217
+ function qualityLabel(entry) {
1218
+ return DICT[LANG]?.['quality.' + entry.id] ?? DICT.en['quality.' + entry.id] ?? entry.label ?? entry.id;
1219
+ }
1220
+
1221
+ /** Rebuild the picker options, preserving the current selection. */
1222
+ function paintQualityOptions() {
1223
+ if (qualityLevels.length === 0) return;
1224
+ const current = $('quality').value;
1225
+ $('quality').innerHTML = qualityLevels
1226
+ .map((entry) => `<option value="${esc(entry.id)}">${esc(qualityLabel(entry))}</option>`)
1227
+ .join('');
1228
+ if (qualityLevels.some((entry) => entry.id === current)) $('quality').value = current;
1229
+ }
1230
+
959
1231
  /** Caption for what the last stream actually served. */
960
1232
  function paintQualityInfo() {
961
1233
  const last = state?.audio?.last;
@@ -966,22 +1238,20 @@
966
1238
  }
967
1239
  const kbps = last.br ? ` · ${Math.round(last.br / 1000)} kbps` : '';
968
1240
  $('quality-info').textContent =
969
- `▶ ${String(last.type ?? '').toUpperCase()}${kbps}` + (last.downgraded ? ` (${last.served}, 已降级/downgraded)` : '');
1241
+ `▶ ${String(last.type ?? '').toUpperCase()}${kbps}` + (last.downgraded ? ` (${last.served}, ${t('downgraded')})` : '');
970
1242
  }
971
1243
 
972
1244
  async function loadQuality() {
973
1245
  try {
974
1246
  const data = await api('/quality');
975
1247
  qualityLevels = data.levels ?? [];
976
- $('quality').innerHTML = qualityLevels
977
- .map((entry) => `<option value="${esc(entry.id)}">${esc(entry.label || entry.id)}</option>`)
978
- .join('');
1248
+ paintQualityOptions();
979
1249
  $('quality').value = data.preferred ?? 'exhigh';
980
1250
  paintQualityInfo();
981
1251
  } catch (error) {
982
1252
  // A host without this endpoint is older than this page; say so once.
983
1253
  $('quality-info').textContent = /unknown endpoint|\b404\b/.test(error.message)
984
- ? '宿主模块早于本功能 / host module predates this feature'
1254
+ ? t('hostPredatesShort')
985
1255
  : error.message;
986
1256
  }
987
1257
  }
@@ -993,7 +1263,7 @@
993
1263
  applyTransport();
994
1264
  pokeEngine();
995
1265
  paintQualityInfo();
996
- toast('音质已设为 / quality set to ' + event.target.value);
1266
+ toast(t('qualitySet', { level: event.target.value }));
997
1267
  } catch (error) {
998
1268
  toast(error.message);
999
1269
  }
@@ -1020,52 +1290,19 @@
1020
1290
  $('seek').onchange = (event) => {
1021
1291
  control({ action: 'seek', value: Math.round((Number(event.target.value) / 1000) * durationMs()) });
1022
1292
  };
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(); };
1293
+ $('like').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'likes', trackId: state.current.id } }).then(() => toast(t('liked')));
1294
+ $('dislike').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'dislikes', trackId: state.current.id } }).then(() => { toast(t('disliked')); control({ action: 'next' }); });
1027
1295
 
1028
1296
  $('dj-toggle').onclick = async () => {
1029
1297
  try {
1030
1298
  const data = await api('/dj', { method: 'POST', body: { enabled: !state?.dj?.enabled, prompt: $('dj-prompt').value } });
1031
1299
  state = data;
1032
1300
  render();
1033
- toast(state.dj?.enabled ? 'AI DJ 已开启 / AI DJ on' : 'AI DJ 已关闭 / AI DJ off');
1301
+ toast(state.dj?.enabled ? t('djOn') : t('djOff'));
1034
1302
  } catch (error) { toast(error.message); }
1035
1303
  };
1036
1304
  $('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
1305
 
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
1306
  $('queue').onclick = async (event) => {
1070
1307
  const jump = event.target.closest('[data-jump]')?.dataset.jump;
1071
1308
  const remove = event.target.closest('[data-remove]')?.dataset.remove;
@@ -1087,13 +1324,15 @@
1087
1324
  render();
1088
1325
  applyTransport();
1089
1326
  } catch (error) {
1090
- $('np-title').textContent = '连接失败 / cannot reach the host: ' + error.message;
1327
+ $('np-title').textContent = t('connectFailed') + error.message;
1091
1328
  }
1092
1329
  }
1093
1330
 
1331
+ applyLocale();
1332
+ watchShellLocale();
1094
1333
  refresh();
1095
1334
  setInterval(refresh, 1500);
1096
- void loadCurator();
1335
+ void loadModelPicker();
1097
1336
  void loadQuality();
1098
1337
  // Progress and lyric highlighting interpolate locally between the
1099
1338
  // engine's reports, so they need their own repaint clock now that the