@doitian/dsh-music 0.1.0 → 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/README.md +152 -34
- package/cordis.patch.yml +14 -0
- package/lib/client.js +48 -11
- package/lib/dj.js +234 -48
- package/lib/index.js +35 -2
- package/lib/netease.js +23 -7
- package/lib/panel.html +451 -212
- package/lib/router.js +7 -4
- package/lib/session.js +14 -2
- package/package.json +2 -1
package/lib/panel.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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"
|
|
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
|
|
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="
|
|
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"
|
|
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="
|
|
181
|
-
<button id="play" class="primary" title="
|
|
182
|
-
<button id="next" title="
|
|
183
|
-
<button id="mode" class="mode" title="
|
|
184
|
-
<select id="quality" class="mode" title="
|
|
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="
|
|
188
|
-
<button id="dislike" title="
|
|
189
|
-
<button id="mute" title="
|
|
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"
|
|
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"
|
|
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="
|
|
203
|
-
<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="
|
|
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"
|
|
215
|
-
<button id="dj-model-test"
|
|
216
|
-
<button id="dj-model-clear" title="
|
|
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"
|
|
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"
|
|
221
|
+
<div class="hint" id="qr-state">Fetching QR…</div>
|
|
228
222
|
<div class="row-actions">
|
|
229
|
-
<button id="qr-refresh"
|
|
230
|
-
<button id="qr-close" class="primary"
|
|
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
|
-
|
|
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('
|
|
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 : '
|
|
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: '
|
|
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"
|
|
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 ? '
|
|
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)"
|
|
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 = '
|
|
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 =
|
|
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="
|
|
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
|
-
|
|
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 =
|
|
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 = '
|
|
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 = '
|
|
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('
|
|
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
|
|
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
|
-
// ----------------------------------------------------------
|
|
1045
|
+
// ---------------------------------------------------------- DJ model
|
|
802
1046
|
/**
|
|
803
|
-
* The
|
|
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
|
-
* "
|
|
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 "
|
|
1053
|
+
* the profile patch, which the "Revert" button falls back to.
|
|
810
1054
|
*/
|
|
811
|
-
let
|
|
812
|
-
let
|
|
813
|
-
let
|
|
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
|
|
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
|
|
826
|
-
for (const id of
|
|
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 =
|
|
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}"
|
|
1085
|
+
`<option value="${CUSTOM}">${esc(t('other'))}</option>`;
|
|
840
1086
|
|
|
841
|
-
const pinned =
|
|
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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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 =
|
|
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 [
|
|
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
|
-
:
|
|
875
|
-
select.value =
|
|
1131
|
+
: `<option value="">${esc(t('noProvider'))}</option>`;
|
|
1132
|
+
select.value = modelSelected.provider ?? modelConfigured.provider ?? providers[0] ?? '';
|
|
876
1133
|
renderModelOptions();
|
|
877
1134
|
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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('
|
|
1178
|
+
toast(t('pickProviderModel'));
|
|
925
1179
|
return;
|
|
926
1180
|
}
|
|
927
|
-
void
|
|
1181
|
+
void saveModelRoute(provider, model, t('saved', { route: `${provider}/${model}` }));
|
|
928
1182
|
};
|
|
929
1183
|
$('dj-model-clear').onclick = () =>
|
|
930
|
-
void
|
|
1184
|
+
void saveModelRoute('', '', t('reverted'));
|
|
931
1185
|
$('dj-model-test').onclick = async () => {
|
|
932
|
-
$('dj-model-status').textContent = '
|
|
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 ?? '
|
|
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},
|
|
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
|
-
|
|
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
|
-
? '
|
|
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('
|
|
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('
|
|
1024
|
-
$('dislike').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'dislikes', trackId: state.current.id } }).then(() => { toast('
|
|
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 ? '
|
|
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 = '
|
|
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
|
|
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
|