@doitian/dsh-music 0.1.2 → 0.1.5
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 +206 -21
- package/lib/cache.js +156 -0
- package/lib/dj.js +443 -67
- package/lib/index.js +39 -5
- package/lib/likes.js +169 -0
- package/lib/netease.js +130 -0
- package/lib/panel.html +316 -41
- package/lib/router.js +203 -18
- package/lib/session.js +109 -18
- package/lib/state.js +55 -0
- package/package.json +5 -3
package/lib/panel.html
CHANGED
|
@@ -78,6 +78,16 @@
|
|
|
78
78
|
.row .dur { color: var(--muted); font-variant-numeric: tabular-nums; flex: none; font-size: 12px; }
|
|
79
79
|
.row .acts { display: none; gap: 4px; flex: none; }
|
|
80
80
|
.row:hover .acts { display: flex; }
|
|
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); }
|
|
81
91
|
|
|
82
92
|
.now { padding: 14px; border-bottom: 1px solid var(--line); }
|
|
83
93
|
.now-top { display: flex; gap: 14px; }
|
|
@@ -92,8 +102,17 @@
|
|
|
92
102
|
.controls { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
93
103
|
.controls .grow { flex: 1; }
|
|
94
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
|
+
}
|
|
95
111
|
#volume { flex: 0 0 88px; }
|
|
96
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; }
|
|
97
116
|
|
|
98
117
|
.section-title { padding: 12px 14px 4px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; }
|
|
99
118
|
.section-title.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
@@ -117,7 +136,21 @@
|
|
|
117
136
|
.queue .scroll { padding-top: 0; }
|
|
118
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; }
|
|
119
138
|
.dj-status b { color: var(--accent); }
|
|
120
|
-
|
|
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; }
|
|
121
154
|
.row-inline { display: flex; gap: 6px; margin-top: 6px; align-items: center; }
|
|
122
155
|
.row-inline select, .row-inline input { flex: 1 1 0; min-width: 0; font-size: 12px; padding: 4px 6px; }
|
|
123
156
|
.row-inline button { font-size: 12px; padding: 4px 8px; }
|
|
@@ -173,8 +206,10 @@
|
|
|
173
206
|
<select id="quality" class="mode" data-i18n-title="qualityTitle" title="Streaming quality"></select>
|
|
174
207
|
<span id="quality-info" class="mode" style="color: var(--muted)"></span>
|
|
175
208
|
<span class="grow"></span>
|
|
176
|
-
|
|
177
|
-
|
|
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>
|
|
178
213
|
<button id="mute" data-i18n-title="mute" title="Mute">🔊</button>
|
|
179
214
|
<input type="range" id="volume" min="0" max="1" step="0.01" value="0.8" />
|
|
180
215
|
</div>
|
|
@@ -193,23 +228,29 @@
|
|
|
193
228
|
<div id="dj-status"></div>
|
|
194
229
|
|
|
195
230
|
<div class="dj-status" id="dj-model">
|
|
196
|
-
|
|
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">
|
|
197
235
|
<span data-i18n="djModel">DJ model</span>
|
|
198
|
-
<span id="dj-model-source"
|
|
199
|
-
|
|
200
|
-
<div class="row-inline">
|
|
201
|
-
<select id="dj-provider" title="provider route"></select>
|
|
202
|
-
<select id="dj-model-select" title="model"></select>
|
|
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>
|
|
203
238
|
</div>
|
|
204
|
-
<div class="
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<
|
|
210
|
-
|
|
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>
|
|
211
253
|
</div>
|
|
212
|
-
<div id="dj-model-status" style="color: var(--muted)"></div>
|
|
213
254
|
</div>
|
|
214
255
|
</section>
|
|
215
256
|
</main>
|
|
@@ -261,7 +302,10 @@
|
|
|
261
302
|
modeShuffle: '随机',
|
|
262
303
|
qualityTitle: '流媒体音质',
|
|
263
304
|
like: '喜欢',
|
|
305
|
+
unlike: '取消喜欢',
|
|
264
306
|
dislike: '不喜欢',
|
|
307
|
+
undislike: '取消不喜欢',
|
|
308
|
+
likeNeedsSignIn: '登录后才能在网易云标记喜欢',
|
|
265
309
|
mute: '静音',
|
|
266
310
|
lyrics: '歌词',
|
|
267
311
|
lyricsHide: '收起',
|
|
@@ -270,6 +314,8 @@
|
|
|
270
314
|
lyricsToggleShow: '展开歌词面板',
|
|
271
315
|
queue: '播放队列',
|
|
272
316
|
djModel: 'AI DJ 模型',
|
|
317
|
+
djModelToggleShow: '展开模型设置',
|
|
318
|
+
djModelToggleHide: '收起模型设置',
|
|
273
319
|
customModelPh: '自定义 model id',
|
|
274
320
|
save: '保存',
|
|
275
321
|
test: '测试',
|
|
@@ -296,6 +342,7 @@
|
|
|
296
342
|
gestureNote: '在窗口任意处点击也可以',
|
|
297
343
|
queueEmpty: '队列为空,用 AI DJ 或让助手补歌',
|
|
298
344
|
remove: '移除',
|
|
345
|
+
removed: '已移除',
|
|
299
346
|
loading: '加载中…',
|
|
300
347
|
instrumental: '纯音乐,无歌词',
|
|
301
348
|
other: '其他…',
|
|
@@ -315,6 +362,7 @@
|
|
|
315
362
|
downgraded: '已降级',
|
|
316
363
|
qualitySet: '音质已设为 {level}',
|
|
317
364
|
liked: '已喜欢',
|
|
365
|
+
unliked: '已取消喜欢',
|
|
318
366
|
disliked: '不再推荐',
|
|
319
367
|
djOn: 'AI DJ 已开启',
|
|
320
368
|
djOff: 'AI DJ 已关闭',
|
|
@@ -343,7 +391,10 @@
|
|
|
343
391
|
modeShuffle: 'Shuffle',
|
|
344
392
|
qualityTitle: 'Streaming quality',
|
|
345
393
|
like: 'Like',
|
|
394
|
+
unlike: 'Unlike',
|
|
346
395
|
dislike: 'Dislike',
|
|
396
|
+
undislike: 'Undislike',
|
|
397
|
+
likeNeedsSignIn: 'Sign in to NetEase to like tracks',
|
|
347
398
|
mute: 'Mute',
|
|
348
399
|
lyrics: 'Lyrics',
|
|
349
400
|
lyricsHide: 'Hide',
|
|
@@ -352,6 +403,8 @@
|
|
|
352
403
|
lyricsToggleShow: 'Expand the lyrics pane',
|
|
353
404
|
queue: 'Queue',
|
|
354
405
|
djModel: 'DJ model',
|
|
406
|
+
djModelToggleShow: 'Show the model settings',
|
|
407
|
+
djModelToggleHide: 'Hide the model settings',
|
|
355
408
|
customModelPh: 'Custom model id',
|
|
356
409
|
save: 'Save',
|
|
357
410
|
test: 'Test',
|
|
@@ -378,6 +431,7 @@
|
|
|
378
431
|
gestureNote: 'clicking anywhere works too',
|
|
379
432
|
queueEmpty: 'Queue empty — use the AI DJ or ask the assistant to add songs',
|
|
380
433
|
remove: 'Remove',
|
|
434
|
+
removed: 'Removed',
|
|
381
435
|
loading: 'Loading…',
|
|
382
436
|
instrumental: 'Instrumental',
|
|
383
437
|
other: 'Other…',
|
|
@@ -397,6 +451,7 @@
|
|
|
397
451
|
downgraded: 'downgraded',
|
|
398
452
|
qualitySet: 'Quality set to {level}',
|
|
399
453
|
liked: 'Liked',
|
|
454
|
+
unliked: 'Like removed',
|
|
400
455
|
disliked: 'Disliked',
|
|
401
456
|
djOn: 'AI DJ on',
|
|
402
457
|
djOff: 'AI DJ off',
|
|
@@ -449,14 +504,20 @@
|
|
|
449
504
|
document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
|
|
450
505
|
} catch { /* headless */ }
|
|
451
506
|
applyStaticStrings();
|
|
452
|
-
// The
|
|
507
|
+
// The toggles' labels and titles depend on the collapse state, not on a
|
|
453
508
|
// fixed key, so they are painted rather than carried as data-i18n.
|
|
454
509
|
paintLyricsToggle();
|
|
510
|
+
paintDjModelToggle();
|
|
455
511
|
// The gesture hint carries markup; drop it so render() rebuilds it in
|
|
456
512
|
// the new language.
|
|
457
513
|
$('gesture')?.remove();
|
|
458
514
|
render();
|
|
459
|
-
|
|
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();
|
|
460
521
|
paintQualityOptions();
|
|
461
522
|
if (modelRoutes.length) renderModelOptions();
|
|
462
523
|
}
|
|
@@ -481,6 +542,9 @@
|
|
|
481
542
|
/** Local-storage key for whether the lyrics pane is collapsed. */
|
|
482
543
|
const LYRICS_PREF = 'dsh-music.lyrics.collapsed';
|
|
483
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();
|
|
484
548
|
let volumePostAt = 0;
|
|
485
549
|
|
|
486
550
|
const fmt = (ms) => {
|
|
@@ -507,10 +571,26 @@
|
|
|
507
571
|
const text = await response.text();
|
|
508
572
|
let data;
|
|
509
573
|
try { data = text ? JSON.parse(text) : {}; } catch { data = { error: text.slice(0, 200) }; }
|
|
510
|
-
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
|
+
}
|
|
511
581
|
return data;
|
|
512
582
|
}
|
|
513
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
|
+
|
|
514
594
|
// ------------------------------------------------------------- transport
|
|
515
595
|
/**
|
|
516
596
|
* Who plays the audio.
|
|
@@ -843,9 +923,18 @@
|
|
|
843
923
|
paintQualityInfo();
|
|
844
924
|
|
|
845
925
|
renderQueue();
|
|
926
|
+
paintTaste();
|
|
846
927
|
paintProgress();
|
|
847
928
|
}
|
|
848
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
|
+
|
|
849
938
|
function renderQueue() {
|
|
850
939
|
if (!state) return;
|
|
851
940
|
const queue = state.queue || [];
|
|
@@ -853,14 +942,27 @@
|
|
|
853
942
|
$('queue').innerHTML = `<div class="empty">${esc(t('queueEmpty'))}</div>`;
|
|
854
943
|
return;
|
|
855
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
|
+
}
|
|
856
955
|
$('queue').innerHTML = queue
|
|
857
|
-
.
|
|
956
|
+
.slice(start, end)
|
|
957
|
+
.map((track, offset) => {
|
|
958
|
+
const index = start + offset;
|
|
858
959
|
const playing = index === state.index;
|
|
859
960
|
return `<div class="row ${playing ? 'playing' : ''}" data-jump="${index}">
|
|
860
961
|
<span class="idx">${playing ? '▶' : index + 1}</span>
|
|
861
962
|
<img src="${esc(track.picUrl || '')}" alt="" loading="lazy">
|
|
862
963
|
<span class="meta"><span class="name">${esc(track.name)}</span><span class="sub">${esc((track.artists || []).join(' / '))}</span></span>
|
|
863
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>
|
|
864
966
|
<span class="dur">${fmt(track.duration)}</span>
|
|
865
967
|
<span class="acts"><button data-remove="${index}" title="${esc(t('remove'))}">✕</button></span>
|
|
866
968
|
</div>`;
|
|
@@ -868,6 +970,86 @@
|
|
|
868
970
|
.join('');
|
|
869
971
|
}
|
|
870
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
|
+
|
|
871
1053
|
function paintProgress() {
|
|
872
1054
|
if (!state || !state.current) return;
|
|
873
1055
|
const position = positionMs();
|
|
@@ -1075,6 +1257,59 @@
|
|
|
1075
1257
|
}
|
|
1076
1258
|
}
|
|
1077
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
|
+
|
|
1078
1313
|
function renderModelOptions() {
|
|
1079
1314
|
const provider = $('dj-provider').value;
|
|
1080
1315
|
const route = modelRoutes.find((entry) => entry.provider === provider);
|
|
@@ -1097,10 +1332,15 @@
|
|
|
1097
1332
|
} else {
|
|
1098
1333
|
custom.style.display = 'none';
|
|
1099
1334
|
}
|
|
1100
|
-
if (route?.error)
|
|
1335
|
+
if (route?.error) paintModelStatus(route.error);
|
|
1101
1336
|
}
|
|
1102
1337
|
|
|
1103
|
-
/**
|
|
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
|
+
*/
|
|
1104
1344
|
function paintModelSource() {
|
|
1105
1345
|
const key = {
|
|
1106
1346
|
panel: 'srcPanel',
|
|
@@ -1108,7 +1348,9 @@
|
|
|
1108
1348
|
'agent-default': 'srcAgentDefault',
|
|
1109
1349
|
discovered: 'srcDiscovered',
|
|
1110
1350
|
}[modelSourceRaw];
|
|
1111
|
-
|
|
1351
|
+
const label = key ? t(key) : modelSourceRaw || '';
|
|
1352
|
+
const route = $('dj-model-route').textContent;
|
|
1353
|
+
$('dj-model-source').textContent = label && route ? ` · ${label}` : label;
|
|
1112
1354
|
}
|
|
1113
1355
|
|
|
1114
1356
|
async function loadModelPicker() {
|
|
@@ -1130,11 +1372,15 @@
|
|
|
1130
1372
|
? providers.map((provider) => `<option value="${esc(provider)}">${esc(provider)}</option>`).join('')
|
|
1131
1373
|
: `<option value="">${esc(t('noProvider'))}</option>`;
|
|
1132
1374
|
select.value = modelSelected.provider ?? modelConfigured.provider ?? providers[0] ?? '';
|
|
1133
|
-
renderModelOptions();
|
|
1134
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.
|
|
1135
1380
|
modelSourceRaw = data.source ?? '';
|
|
1136
|
-
|
|
1137
|
-
|
|
1381
|
+
paintDjModelHeader();
|
|
1382
|
+
paintModelStatus(data.error ?? '');
|
|
1383
|
+
renderModelOptions();
|
|
1138
1384
|
} catch (error) {
|
|
1139
1385
|
// A 404 here means this page is newer than the host module: the page
|
|
1140
1386
|
// is re-read from disk on every plugin mount, while Node keeps
|
|
@@ -1142,9 +1388,9 @@
|
|
|
1142
1388
|
// say that instead of showing a bare "unknown endpoint".
|
|
1143
1389
|
if (/unknown endpoint|\b404\b/.test(error.message)) {
|
|
1144
1390
|
setModelControls(false);
|
|
1145
|
-
|
|
1391
|
+
paintModelStatus(t('hostPredatesLong'));
|
|
1146
1392
|
} else {
|
|
1147
|
-
|
|
1393
|
+
paintModelStatus(error.message);
|
|
1148
1394
|
}
|
|
1149
1395
|
}
|
|
1150
1396
|
}
|
|
@@ -1183,18 +1429,17 @@
|
|
|
1183
1429
|
$('dj-model-clear').onclick = () =>
|
|
1184
1430
|
void saveModelRoute('', '', t('reverted'));
|
|
1185
1431
|
$('dj-model-test').onclick = async () => {
|
|
1186
|
-
|
|
1432
|
+
paintModelStatus(t('testing'));
|
|
1187
1433
|
try {
|
|
1188
1434
|
const result = await api('/dj/test', { method: 'POST', body: {} });
|
|
1189
1435
|
if (result.source === 'model') {
|
|
1190
|
-
|
|
1191
|
-
`✓ ${result.route} — ${(result.picked ?? []).join(' · ')}${result.vibe ? ` (${result.vibe})` : ''}
|
|
1436
|
+
paintModelStatus(
|
|
1437
|
+
`✓ ${result.route} — ${(result.picked ?? []).join(' · ')}${result.vibe ? ` (${result.vibe})` : ''}`);
|
|
1192
1438
|
} else {
|
|
1193
|
-
$('
|
|
1194
|
-
`✗ ${result.modelError ?? result.note ?? t('modelTierUnavailable')} — ${t('heuristics')}`;
|
|
1439
|
+
paintModelStatus(`✗ ${result.modelError ?? result.note ?? t('modelTierUnavailable')} — ${t('heuristics')}`);
|
|
1195
1440
|
}
|
|
1196
1441
|
} catch (error) {
|
|
1197
|
-
|
|
1442
|
+
paintModelStatus(error.message);
|
|
1198
1443
|
}
|
|
1199
1444
|
};
|
|
1200
1445
|
|
|
@@ -1290,8 +1535,27 @@
|
|
|
1290
1535
|
$('seek').onchange = (event) => {
|
|
1291
1536
|
control({ action: 'seek', value: Math.round((Number(event.target.value) / 1000) * durationMs()) });
|
|
1292
1537
|
};
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
+
};
|
|
1295
1559
|
|
|
1296
1560
|
$('dj-toggle').onclick = async () => {
|
|
1297
1561
|
try {
|
|
@@ -1304,17 +1568,28 @@
|
|
|
1304
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));
|
|
1305
1569
|
|
|
1306
1570
|
$('queue').onclick = async (event) => {
|
|
1571
|
+
const like = event.target.closest('[data-like]');
|
|
1307
1572
|
const jump = event.target.closest('[data-jump]')?.dataset.jump;
|
|
1308
|
-
const remove = event.target.closest('[data-remove]')
|
|
1573
|
+
const remove = event.target.closest('[data-remove]');
|
|
1309
1574
|
try {
|
|
1310
|
-
|
|
1311
|
-
|
|
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'));
|
|
1312
1587
|
} else if (jump !== undefined) {
|
|
1313
1588
|
state = await api('/queue', { method: 'POST', body: { action: 'jump', index: Number(jump) } });
|
|
1314
1589
|
} else return;
|
|
1315
1590
|
render();
|
|
1316
1591
|
pokeEngine();
|
|
1317
|
-
} catch (error) { toast(error
|
|
1592
|
+
} catch (error) { toast(failureText(error)); }
|
|
1318
1593
|
};
|
|
1319
1594
|
|
|
1320
1595
|
// ------------------------------------------------------------------ boot
|