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