@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/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
- .model-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
139
+ /* The DJ model picker is a set-once setting, so the card keeps only its
140
+ header row and the queue and the lyrics pane keep the space. The header
141
+ is the click target; the chevron inside it is the keyboard one. */
142
+ .model-head { display: flex; align-items: baseline; gap: 8px; cursor: pointer; }
143
+ .model-head .model-now {
144
+ flex: 1; min-width: 0; text-align: right; color: var(--muted);
145
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
146
+ }
147
+ .model-head .toggle {
148
+ background: none; border: 0; margin: 0; padding: 0 2px; font: inherit;
149
+ color: var(--muted); cursor: pointer;
150
+ }
151
+ .model-head .toggle:hover { color: var(--fg); }
152
+ .model-head .warn { color: var(--accent); }
153
+ .dj-model-body.collapsed { display: none; }
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
- <button id="like" data-i18n-title="like" title="Like">♡</button>
177
- <button id="dislike" data-i18n-title="dislike" title="Dislike">✕</button>
209
+ <!-- Labels are state-dependent (a filled heart un-likes), so the
210
+ script owns text and title rather than data-i18n. -->
211
+ <button id="like" title="Like">♡</button>
212
+ <button id="dislike" title="Dislike">✕</button>
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
- <div class="model-head">
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" style="color: var(--muted)"></span>
199
- </div>
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="row-inline">
205
- <input id="dj-model-custom" data-i18n-ph="customModelPh" placeholder="Custom model id" style="display: none" />
206
- </div>
207
- <div class="row-inline">
208
- <button id="dj-model-save" class="primary" data-i18n="save">Save</button>
209
- <button id="dj-model-test" data-i18n="test">Test</button>
210
- <button id="dj-model-clear" data-i18n="revert" data-i18n-title="revertTitle" title="Revert to the profile patch">Revert</button>
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 toggle's label and title depend on the collapse state, not on a
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
- paintModelSource();
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) throw new Error(data.error || data.message || 'HTTP ' + response.status);
574
+ if (!response.ok) {
575
+ const error = new Error(data.error || data.message || 'HTTP ' + response.status);
576
+ // The host's machine-usable half. This page localizes around it
577
+ // (login does the same) instead of showing a raw English sentence.
578
+ if (data.code) error.code = data.code;
579
+ throw error;
580
+ }
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
- .map((track, index) => {
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) $('dj-model-status').textContent = route.error;
1335
+ if (route?.error) paintModelStatus(route.error);
1101
1336
  }
1102
1337
 
1103
- /** Where the effective model choice comes from, localized. */
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
- $('dj-model-source').textContent = key ? t(key) : modelSourceRaw || '';
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
- paintModelSource();
1137
- $('dj-model-status').textContent = data.error ?? '';
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
- $('dj-model-status').textContent = t('hostPredatesLong');
1391
+ paintModelStatus(t('hostPredatesLong'));
1146
1392
  } else {
1147
- $('dj-model-status').textContent = error.message;
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
- $('dj-model-status').textContent = t('testing');
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
- $('dj-model-status').textContent =
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
- $('dj-model-status').textContent =
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
- $('dj-model-status').textContent = error.message;
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
- $('like').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'likes', trackId: state.current.id } }).then(() => toast(t('liked')));
1294
- $('dislike').onclick = () => state?.current && api('/feedback', { method: 'POST', body: { kind: 'dislikes', trackId: state.current.id } }).then(() => { toast(t('disliked')); control({ action: 'next' }); });
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]')?.dataset.remove;
1573
+ const remove = event.target.closest('[data-remove]');
1309
1574
  try {
1310
- if (remove !== undefined) {
1311
- state = await api('/queue', { method: 'POST', body: { action: 'remove', index: Number(remove) } });
1575
+ // A heart inside a row must not also count as "play this row".
1576
+ if (like) {
1577
+ const track = state?.queue?.[Number(like.dataset.like)];
1578
+ if (!track) return;
1579
+ await sendTaste(track, track.liked ? 'none' : 'liked', like);
1580
+ return;
1581
+ }
1582
+ if (remove) {
1583
+ // The host drops the row and, for an unrated track, records the
1584
+ // removal as the same dislike the transport ✕ makes — one request.
1585
+ // The confirm names the click, not that side effect of it.
1586
+ if (await removeFromList(Number(remove.dataset.remove), remove)) toast(t('removed'));
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.message); }
1592
+ } catch (error) { toast(failureText(error)); }
1318
1593
  };
1319
1594
 
1320
1595
  // ------------------------------------------------------------------ boot