dsh-music-player 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/client.js +167 -55
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -52,13 +52,54 @@ window.__ModuleLoader__.load({
52
52
  const PREF_MODE = 'dsh-music-mode';
53
53
  const PREF_VOL = 'dsh-music-volume';
54
54
  const PREF_PLAYBACK = 'dsh-music-playback';
55
- const PREF_BOOK_PLAYBACK = 'dsh-music-book-playback';
55
+ const PREF_BOOKS_PLAYBACK = 'dsh-music-books-playback';
56
56
  const PREF_ROOT = 'dsh-music-root';
57
57
  const PREF_PANEL_POS = 'dsh-music-panel-pos';
58
58
  const PREF_VOICE = 'dsh-music-voice';
59
59
  const loadPref = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
60
60
  const savePref = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
61
61
  const clearPref = (k) => { try { localStorage.removeItem(k); } catch (e) {} };
62
+ // ---- per-book novel progress (independent from music) ----
63
+ // Every novel remembers its own position, keyed by its filename, so switching
64
+ // between books — or to music — never loses another book's place. Music
65
+ // progress lives in PREF_PLAYBACK and this subsystem never touches it.
66
+ const PREF_BOOK_PLAYBACK = 'dsh-music-book-playback'; // legacy single-book key
67
+ function readBooksPlayback() {
68
+ try {
69
+ const raw = loadPref(PREF_BOOKS_PLAYBACK);
70
+ if (raw) { const o = JSON.parse(raw); if (o && typeof o === 'object') return o; }
71
+ } catch (e) {}
72
+ return {};
73
+ }
74
+ function writeBooksPlayback(map) { savePref(PREF_BOOKS_PLAYBACK, JSON.stringify(map)); }
75
+ function getBookPlayback(name) { return readBooksPlayback()[name] || null; }
76
+ function clearBookPlayback(name) {
77
+ const map = readBooksPlayback();
78
+ if (Object.prototype.hasOwnProperty.call(map, name)) { delete map[name]; writeBooksPlayback(map); }
79
+ }
80
+ // Persist the currently playing novel's position into the per-book map.
81
+ function saveCurrentBookPlayback() {
82
+ const id = currentBookId();
83
+ if (id === null) return;
84
+ const book = bookById(id);
85
+ if (book === null) return;
86
+ const map = readBooksPlayback();
87
+ map[book.name] = {
88
+ from: bookFromRef, base: bookBaseTime,
89
+ pos: audio.currentTime || 0, total: bookTotal,
90
+ ts: Date.now(),
91
+ };
92
+ writeBooksPlayback(map);
93
+ }
94
+ // The most recently played novel (largest ts), used by refresh restore.
95
+ function latestBookPlayback() {
96
+ const map = readBooksPlayback();
97
+ let best = null, bestTs = -1;
98
+ for (const [name, e] of Object.entries(map)) {
99
+ if (e && typeof e.from === 'number' && e.ts > bestTs) { best = { name, ...e }; bestTs = e.ts; }
100
+ }
101
+ return best;
102
+ }
62
103
  // Restore the playback-panel position ({x,y,h}) previously saved by dragging, if any.
63
104
  function loadPanelPos() {
64
105
  const raw = loadPref(PREF_PANEL_POS);
@@ -159,23 +200,28 @@ window.__ModuleLoader__.load({
159
200
  if (typeof voice === 'string' && voice !== '') store.voice = voice;
160
201
  } catch (e) {}
161
202
 
162
- function savePlayback() {
163
- if (store.currentId === null) { clearPref(PREF_PLAYBACK); clearPref(PREF_BOOK_PLAYBACK); return; }
164
- if (String(store.currentId).startsWith('book:')) {
165
- // Books cache their own richer state: chunk index, the cumulative
166
- // book-wide clock, and the in-chunk position, so a refresh can resume
167
- // the exact spot with a continuous time readout.
168
- clearPref(PREF_PLAYBACK);
169
- const id = currentBookId();
170
- if (id === null) { clearPref(PREF_BOOK_PLAYBACK); return; }
171
- savePref(PREF_BOOK_PLAYBACK, JSON.stringify({
172
- id, name: store.currentName,
173
- from: bookFromRef, base: bookBaseTime,
174
- pos: audio.currentTime || 0, total: bookTotal,
175
- }));
176
- return;
203
+ // Migrate legacy single-book progress (pre-0.2.1) into the per-book map once.
204
+ try {
205
+ const legacy = loadPref(PREF_BOOK_PLAYBACK);
206
+ if (legacy) {
207
+ const p = JSON.parse(legacy);
208
+ if (p && typeof p.id === 'string' && typeof p.name === 'string' && p.name !== '') {
209
+ const map = readBooksPlayback();
210
+ if (!Object.prototype.hasOwnProperty.call(map, p.name)) {
211
+ map[p.name] = { from: p.from, base: p.base, pos: p.pos, total: p.total, ts: p.ts || Date.now() };
212
+ writeBooksPlayback(map);
213
+ }
214
+ }
215
+ clearPref(PREF_BOOK_PLAYBACK);
177
216
  }
178
- clearPref(PREF_BOOK_PLAYBACK);
217
+ } catch (e) {}
218
+
219
+ // Persist the current playback position. Music and novels are fully separate:
220
+ // music writes PREF_PLAYBACK, novels write into the per-book map — neither
221
+ // clears the other, so switching modes never loses progress.
222
+ function savePlayback() {
223
+ if (store.currentId === null) { clearPref(PREF_PLAYBACK); return; }
224
+ if (String(store.currentId).startsWith('book:')) { saveCurrentBookPlayback(); return; }
179
225
  savePref(PREF_PLAYBACK, JSON.stringify({
180
226
  id: store.currentId, name: store.currentName,
181
227
  // While a restored track is still paused, the <audio> currentTime can
@@ -184,6 +230,7 @@ window.__ModuleLoader__.load({
184
230
  // Persist the known duration too: on restore the browser may not have
185
231
  // loaded the track's metadata yet, and we don't want a "0:00" readout.
186
232
  duration: Number.isFinite(audio.duration) ? audio.duration : 0,
233
+ ts: Date.now(),
187
234
  }));
188
235
  }
189
236
  function loadPlayback() {
@@ -274,15 +321,30 @@ window.__ModuleLoader__.load({
274
321
  const smoothCur = new Float32Array(SMOOTH_BARS);
275
322
  const smoothPeak = new Float32Array(SMOOTH_BARS);
276
323
  const targetBuf = new Float32Array(SMOOTH_BARS);
277
- // Accent color for the spectrum bars, read from the theme-derived
278
- // --dsh-music-accent (see PLAYER_CSS), cached once.
324
+ // Accent color for the spectrum bars. DSH defines its --dsw-alias-* theme
325
+ // tokens on <body> — never on :root — so --dsh-music-accent must be read
326
+ // from body (reading documentElement would always return the fallback and
327
+ // the bars would never follow the theme). The value is cached but the cache
328
+ // is invalidated whenever the theme changes at runtime: the ThemePresenter
329
+ // projects tokens + the dark attribute onto body, so a MutationObserver on
330
+ // body's style/dark-attribute keeps the bars tracking live brand changes.
279
331
  let accentColor = null;
332
+ let accentObserver = null;
333
+ function readAccent() {
334
+ const el = document.body || document.documentElement;
335
+ return getComputedStyle(el).getPropertyValue('--dsh-music-accent').trim() || '#2f9e6e';
336
+ }
280
337
  function currentAccent() {
281
- if (accentColor === null) {
282
- accentColor = getComputedStyle(document.documentElement).getPropertyValue('--dsh-music-accent').trim() || '#2f9e6e';
283
- }
338
+ if (accentColor === null) accentColor = readAccent();
284
339
  return accentColor;
285
340
  }
341
+ function watchAccent() {
342
+ if (accentObserver !== null) return accentObserver;
343
+ if (typeof MutationObserver === 'undefined') return null;
344
+ accentObserver = new MutationObserver(() => { accentColor = readAccent(); });
345
+ accentObserver.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] });
346
+ return accentObserver;
347
+ }
286
348
  function drawBars(canvas, useCaps) {
287
349
  const c = canvas.getContext('2d');
288
350
  const w = canvas.width; const h = canvas.height;
@@ -581,7 +643,25 @@ window.__ModuleLoader__.load({
581
643
  }
582
644
  }
583
645
  // `from` lets the toc jump straight to a chapter's chunk index.
584
- function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; clearPref(PREF_BOOK_PLAYBACK); playBookFrom(id, from, false); }
646
+ function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
647
+ // Play a novel from its saved progress when available (e.g. switching to
648
+ // music and back), otherwise start fresh from the beginning. Explicit
649
+ // chapter jumps keep using playBook(id, fromChunk) and are unaffected.
650
+ function resumeOrPlayBook(id) {
651
+ const book = bookById(id);
652
+ if (book === null) return;
653
+ const entry = getBookPlayback(book.name);
654
+ if (entry === null) { playBook(id); return; }
655
+ // Seed chunk / cumulative clock / in-chunk position from this book's entry.
656
+ restoreBookPlayback(book.name);
657
+ if (String(store.currentId) === 'book:' + id) {
658
+ // Restore applied: play the saved chunk; onTime seeks to the in-chunk pos.
659
+ unlockAutoplay();
660
+ playBookFrom(id, bookFromRef, false);
661
+ } else {
662
+ playBook(id); // restore bailed (book gone) → start fresh
663
+ }
664
+ }
585
665
  // When a chunk ends, switch to the next HTTP chunk (warmed by preAudio).
586
666
  // The switch is silent: no buffering flash, and the book-wide clock keeps
587
667
  // the completed chunk's duration so the readout never resets.
@@ -745,6 +825,10 @@ window.__ModuleLoader__.load({
745
825
  });
746
826
  }
747
827
  function stop() {
828
+ // Capture the current novel before resetting, so stopping forgets only
829
+ // that one book's position (other novels keep their own progress).
830
+ const stoppedBook = (store.currentId !== null && String(store.currentId).startsWith('book:'))
831
+ ? bookById(currentBookId()) : null;
748
832
  envReqId++;
749
833
  trackEnv = null;
750
834
  restoredMusicPos = null;
@@ -754,7 +838,7 @@ window.__ModuleLoader__.load({
754
838
  stopBookHelper();
755
839
  set({ currentId: null, currentName: null, playing: false, position: 0, duration: 0, pendingId: null, pendingName: null, vizState: 'ok', bookBuffering: false, bookError: '', bookBufferingSince: 0, bookBufferingSilent: false, tocOpen: false, currentSection: '', voiceSwitching: false });
756
840
  clearPref(PREF_PLAYBACK);
757
- clearPref(PREF_BOOK_PLAYBACK);
841
+ if (stoppedBook !== null) clearBookPlayback(stoppedBook.name);
758
842
  releaseWakeLock();
759
843
  }
760
844
 
@@ -923,37 +1007,41 @@ window.__ModuleLoader__.load({
923
1007
  restoredMusicPos = null;
924
1008
  }
925
1009
  // Save the restored spot explicitly so it survives another refresh.
926
- savePref(PREF_PLAYBACK, JSON.stringify({ id: track.id, name: track.name, position: pos, duration: savedDur }));
1010
+ savePref(PREF_PLAYBACK, JSON.stringify({ id: track.id, name: track.name, position: pos, duration: savedDur, ts: Date.now() }));
927
1011
  }
928
1012
 
929
- // Restore a novel's playback after a refresh: same book, same chunk, same
930
- // cumulative clock, and same in-chunk position — paused (tap ▶ to resume),
931
- // matching how music is restored. The chunk URL is loaded in the background
932
- // so resuming is instant (and the next chunk gets pre-warmed).
933
- function restoreBookPlayback() {
934
- const raw = loadPref(PREF_BOOK_PLAYBACK);
935
- if (raw === null) return;
936
- let saved;
937
- try { saved = JSON.parse(raw); } catch (e) { return; }
938
- if (saved === null || typeof saved !== 'object' || typeof saved.id !== 'string') return;
939
- const book = store.books.find((b) => b.id === saved.id);
1013
+ // Restore a novel's playback after a refresh (or when resuming a book):
1014
+ // same book, same chunk, same cumulative clock, and same in-chunk position —
1015
+ // paused (tap ▶ to resume), matching how music is restored. When targetName
1016
+ // is given, restore that book; otherwise restore the most recently played
1017
+ // novel from the per-book map.
1018
+ function restoreBookPlayback(targetName) {
1019
+ const map = readBooksPlayback();
1020
+ let name = targetName;
1021
+ let entry = null;
1022
+ if (typeof name === 'string' && name !== '') {
1023
+ entry = map[name] || null;
1024
+ } else {
1025
+ for (const [n, e] of Object.entries(map)) {
1026
+ if (e && typeof e.from === 'number' && (entry === null || e.ts > entry.ts)) { entry = e; name = n; }
1027
+ }
1028
+ }
1029
+ if (entry === null || !name) return;
1030
+ const book = store.books.find((b) => b.name === name);
940
1031
  if (book === undefined) return; // the book is no longer in the library
941
- // Guard against the scan order shifting and reusing this id for a
942
- // different file: only restore when the saved name still matches.
943
- if (typeof saved.name === 'string' && saved.name !== '' && saved.name !== book.name) return;
944
- const from = Number.isFinite(saved.from) && saved.from >= 0 ? saved.from : 0;
945
- const base = Number.isFinite(saved.base) ? saved.base : 0;
946
- const pos = Number.isFinite(saved.pos) ? saved.pos : 0;
947
- bookTotal = Number.isFinite(saved.total) ? saved.total : -1;
1032
+ const from = Number.isFinite(entry.from) && entry.from >= 0 ? entry.from : 0;
1033
+ const base = Number.isFinite(entry.base) ? entry.base : 0;
1034
+ const pos = Number.isFinite(entry.pos) ? entry.pos : 0;
1035
+ bookTotal = Number.isFinite(entry.total) ? entry.total : -1;
948
1036
  bookFromRef = from;
949
1037
  bookBaseTime = base;
950
- const url = bookUrl(saved.id, from);
1038
+ const url = bookUrl(book.id, from);
951
1039
  if (url === null) return;
952
1040
  // Mark the book as current. Like music restore, we do NOT touch the
953
1041
  // <audio> element here (avoiding the Chromium 'getTopURL' quirk on
954
1042
  // refresh); togglePlay loads + seeks the chunk when the user resumes.
955
1043
  set({
956
- currentId: 'book:' + saved.id, currentName: book.name,
1044
+ currentId: 'book:' + book.id, currentName: book.name,
957
1045
  position: base + pos, duration: base + (Number.isFinite(audio.duration) ? audio.duration : 0),
958
1046
  pendingId: null, pendingName: null, error: null, playing: false,
959
1047
  bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0,
@@ -963,13 +1051,31 @@ window.__ModuleLoader__.load({
963
1051
  // changed since the save); fall back to the saved total on failure.
964
1052
  const savedTotal = bookTotal;
965
1053
  bookTotal = -1;
966
- void ensureBookTotal(saved.id).then((total) => {
1054
+ void ensureBookTotal(book.id).then((total) => {
967
1055
  if (!Number.isFinite(total) || total < 0) { bookTotal = savedTotal; return; }
968
1056
  bookTotal = total;
969
- if (from + 1 < total) preloadBook(saved.id, from + 1);
1057
+ if (from + 1 < total) preloadBook(book.id, from + 1);
970
1058
  });
971
1059
  }
972
1060
 
1061
+ // Restore whichever (music vs novel) was playing most recently — both now
1062
+ // persist independently, so a music interlude never wipes a novel's progress.
1063
+ function restoreLatest(list) {
1064
+ let musicTs = -1;
1065
+ try {
1066
+ const p = JSON.parse(loadPref(PREF_PLAYBACK) || 'null');
1067
+ if (p && typeof p.ts === 'number') musicTs = p.ts;
1068
+ } catch (e) {}
1069
+ const book = latestBookPlayback();
1070
+ const bookTs = book ? book.ts : -1;
1071
+ if (bookTs > musicTs) { restoreBookPlayback(); return; }
1072
+ if (musicTs > bookTs) { restorePlayback(list); return; }
1073
+ // tie / legacy data without timestamps: restore whatever exists (music
1074
+ // first, book last — legacy data only ever had one of the two populated).
1075
+ restorePlayback(list);
1076
+ restoreBookPlayback();
1077
+ }
1078
+
973
1079
  // ---- host data ----
974
1080
  async function loadTracks() {
975
1081
  set({ loading: true });
@@ -994,8 +1100,7 @@ window.__ModuleLoader__.load({
994
1100
  // Envelope (spectrum) decoding is deferred to actual playback — no need
995
1101
  // to decode several full files eagerly at page load; the current track's
996
1102
  // envelope decodes on play (startPlay / resume).
997
- restorePlayback(list);
998
- restoreBookPlayback();
1103
+ restoreLatest(list);
999
1104
  } catch (err) {
1000
1105
  set({ loading: false, error: '\u65e0\u6cd5\u8bfb\u53d6\u97f3\u4e50\u5e93\uff1a' + String((err && err.message) || err) });
1001
1106
  }
@@ -1015,8 +1120,7 @@ window.__ModuleLoader__.load({
1015
1120
  tracks: result.tracks || [], books: result.books || [],
1016
1121
  count: result.count || 0, loading: false, error: null,
1017
1122
  });
1018
- restorePlayback(result.tracks || []);
1019
- restoreBookPlayback();
1123
+ restoreLatest(result.tracks || []);
1020
1124
  } else {
1021
1125
  set({ loading: false, error: (result && result.error) || '\u8bbe\u7f6e\u76ee\u5f55\u5931\u8d25' });
1022
1126
  }
@@ -1417,7 +1521,7 @@ window.__ModuleLoader__.load({
1417
1521
  key: b.id,
1418
1522
  className: 'dsh-music-track' + (active ? ' active' : ''),
1419
1523
  title: b.url,
1420
- onClick: () => { if (active) togglePlay(); else playBook(b.id); },
1524
+ onClick: () => { if (active) togglePlay(); else resumeOrPlayBook(b.id); },
1421
1525
  },
1422
1526
  React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + b.name),
1423
1527
  React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(b.size)),
@@ -1559,6 +1663,7 @@ window.__ModuleLoader__.load({
1559
1663
  attachAudioElements();
1560
1664
  const unbind = bindAudio();
1561
1665
  startRaf();
1666
+ const accentWatch = watchAccent();
1562
1667
  // Browsers auto-release a wake lock when the page is hidden; re-acquire
1563
1668
  // on return if playback is still running, and drop it on hide so we
1564
1669
  // don't hold it while the tab is backgrounded.
@@ -1578,6 +1683,8 @@ window.__ModuleLoader__.load({
1578
1683
  return () => {
1579
1684
  window.removeEventListener('pagehide', onPageHide);
1580
1685
  document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); releaseWakeLock();
1686
+ if (accentWatch !== null) accentWatch.disconnect();
1687
+ accentObserver = null;
1581
1688
  };
1582
1689
  }, 'music-player: audio + viz engine');
1583
1690
 
@@ -1609,7 +1716,7 @@ window.__ModuleLoader__.load({
1609
1716
  const book = bookById(intent.id);
1610
1717
  if (book !== null) {
1611
1718
  set({ pendingId: 'book:' + book.id, pendingName: intent.name || book.name, error: null });
1612
- playBook(book.id);
1719
+ resumeOrPlayBook(book.id);
1613
1720
  return;
1614
1721
  }
1615
1722
  const track = trackById(intent.id);
@@ -1651,8 +1758,13 @@ window.__ModuleLoader__.load({
1651
1758
  const PLAYER_CSS = '\n' +
1652
1759
  // Accent follows the host app's theme brand color (stable from the start —
1653
1760
  // no green-default-to-sampled-blue flash); green is only the fallback when
1654
- // the app exposes no brand color.
1655
- ':root { --dsh-music-accent: var(--dsw-alias-brand-primary, #2f9e6e); }\n' +
1761
+ // the app exposes no brand color. The alias must be declared on BODY, not
1762
+ // :root: DSH defines its --dsw-alias-* theme tokens on <body> only, and a
1763
+ // var() reference resolves against the element that declares it — on
1764
+ // :root (html) it cannot see body's tokens and would always fall back to
1765
+ // green. Declared on body, the reference resolves and children inherit
1766
+ // the theme's actual brand color.
1767
+ 'body { --dsh-music-accent: var(--dsw-alias-brand-primary, #2f9e6e); }\n' +
1656
1768
  '.dsh-music-bar-wrap { box-sizing: border-box; width: 100%; padding: 0 var(--dsh-composer-side-clearance, 16px); }\n' +
1657
1769
  '.dsh-music-bar { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; max-width: var(--dsh-composer-card-max-width, 780px); margin: 0 auto; padding: 4px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04)); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); border-radius: 8px; }\n' +
1658
1770
  '.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",