dsh-music-player 0.2.0 → 0.2.1

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 +137 -48
  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() {
@@ -581,7 +628,25 @@ window.__ModuleLoader__.load({
581
628
  }
582
629
  }
583
630
  // `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); }
631
+ function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
632
+ // Play a novel from its saved progress when available (e.g. switching to
633
+ // music and back), otherwise start fresh from the beginning. Explicit
634
+ // chapter jumps keep using playBook(id, fromChunk) and are unaffected.
635
+ function resumeOrPlayBook(id) {
636
+ const book = bookById(id);
637
+ if (book === null) return;
638
+ const entry = getBookPlayback(book.name);
639
+ if (entry === null) { playBook(id); return; }
640
+ // Seed chunk / cumulative clock / in-chunk position from this book's entry.
641
+ restoreBookPlayback(book.name);
642
+ if (String(store.currentId) === 'book:' + id) {
643
+ // Restore applied: play the saved chunk; onTime seeks to the in-chunk pos.
644
+ unlockAutoplay();
645
+ playBookFrom(id, bookFromRef, false);
646
+ } else {
647
+ playBook(id); // restore bailed (book gone) → start fresh
648
+ }
649
+ }
585
650
  // When a chunk ends, switch to the next HTTP chunk (warmed by preAudio).
586
651
  // The switch is silent: no buffering flash, and the book-wide clock keeps
587
652
  // the completed chunk's duration so the readout never resets.
@@ -745,6 +810,10 @@ window.__ModuleLoader__.load({
745
810
  });
746
811
  }
747
812
  function stop() {
813
+ // Capture the current novel before resetting, so stopping forgets only
814
+ // that one book's position (other novels keep their own progress).
815
+ const stoppedBook = (store.currentId !== null && String(store.currentId).startsWith('book:'))
816
+ ? bookById(currentBookId()) : null;
748
817
  envReqId++;
749
818
  trackEnv = null;
750
819
  restoredMusicPos = null;
@@ -754,7 +823,7 @@ window.__ModuleLoader__.load({
754
823
  stopBookHelper();
755
824
  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
825
  clearPref(PREF_PLAYBACK);
757
- clearPref(PREF_BOOK_PLAYBACK);
826
+ if (stoppedBook !== null) clearBookPlayback(stoppedBook.name);
758
827
  releaseWakeLock();
759
828
  }
760
829
 
@@ -923,37 +992,41 @@ window.__ModuleLoader__.load({
923
992
  restoredMusicPos = null;
924
993
  }
925
994
  // 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 }));
995
+ savePref(PREF_PLAYBACK, JSON.stringify({ id: track.id, name: track.name, position: pos, duration: savedDur, ts: Date.now() }));
927
996
  }
928
997
 
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);
998
+ // Restore a novel's playback after a refresh (or when resuming a book):
999
+ // same book, same chunk, same cumulative clock, and same in-chunk position —
1000
+ // paused (tap ▶ to resume), matching how music is restored. When targetName
1001
+ // is given, restore that book; otherwise restore the most recently played
1002
+ // novel from the per-book map.
1003
+ function restoreBookPlayback(targetName) {
1004
+ const map = readBooksPlayback();
1005
+ let name = targetName;
1006
+ let entry = null;
1007
+ if (typeof name === 'string' && name !== '') {
1008
+ entry = map[name] || null;
1009
+ } else {
1010
+ for (const [n, e] of Object.entries(map)) {
1011
+ if (e && typeof e.from === 'number' && (entry === null || e.ts > entry.ts)) { entry = e; name = n; }
1012
+ }
1013
+ }
1014
+ if (entry === null || !name) return;
1015
+ const book = store.books.find((b) => b.name === name);
940
1016
  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;
1017
+ const from = Number.isFinite(entry.from) && entry.from >= 0 ? entry.from : 0;
1018
+ const base = Number.isFinite(entry.base) ? entry.base : 0;
1019
+ const pos = Number.isFinite(entry.pos) ? entry.pos : 0;
1020
+ bookTotal = Number.isFinite(entry.total) ? entry.total : -1;
948
1021
  bookFromRef = from;
949
1022
  bookBaseTime = base;
950
- const url = bookUrl(saved.id, from);
1023
+ const url = bookUrl(book.id, from);
951
1024
  if (url === null) return;
952
1025
  // Mark the book as current. Like music restore, we do NOT touch the
953
1026
  // <audio> element here (avoiding the Chromium 'getTopURL' quirk on
954
1027
  // refresh); togglePlay loads + seeks the chunk when the user resumes.
955
1028
  set({
956
- currentId: 'book:' + saved.id, currentName: book.name,
1029
+ currentId: 'book:' + book.id, currentName: book.name,
957
1030
  position: base + pos, duration: base + (Number.isFinite(audio.duration) ? audio.duration : 0),
958
1031
  pendingId: null, pendingName: null, error: null, playing: false,
959
1032
  bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0,
@@ -963,13 +1036,31 @@ window.__ModuleLoader__.load({
963
1036
  // changed since the save); fall back to the saved total on failure.
964
1037
  const savedTotal = bookTotal;
965
1038
  bookTotal = -1;
966
- void ensureBookTotal(saved.id).then((total) => {
1039
+ void ensureBookTotal(book.id).then((total) => {
967
1040
  if (!Number.isFinite(total) || total < 0) { bookTotal = savedTotal; return; }
968
1041
  bookTotal = total;
969
- if (from + 1 < total) preloadBook(saved.id, from + 1);
1042
+ if (from + 1 < total) preloadBook(book.id, from + 1);
970
1043
  });
971
1044
  }
972
1045
 
1046
+ // Restore whichever (music vs novel) was playing most recently — both now
1047
+ // persist independently, so a music interlude never wipes a novel's progress.
1048
+ function restoreLatest(list) {
1049
+ let musicTs = -1;
1050
+ try {
1051
+ const p = JSON.parse(loadPref(PREF_PLAYBACK) || 'null');
1052
+ if (p && typeof p.ts === 'number') musicTs = p.ts;
1053
+ } catch (e) {}
1054
+ const book = latestBookPlayback();
1055
+ const bookTs = book ? book.ts : -1;
1056
+ if (bookTs > musicTs) { restoreBookPlayback(); return; }
1057
+ if (musicTs > bookTs) { restorePlayback(list); return; }
1058
+ // tie / legacy data without timestamps: restore whatever exists (music
1059
+ // first, book last — legacy data only ever had one of the two populated).
1060
+ restorePlayback(list);
1061
+ restoreBookPlayback();
1062
+ }
1063
+
973
1064
  // ---- host data ----
974
1065
  async function loadTracks() {
975
1066
  set({ loading: true });
@@ -994,8 +1085,7 @@ window.__ModuleLoader__.load({
994
1085
  // Envelope (spectrum) decoding is deferred to actual playback — no need
995
1086
  // to decode several full files eagerly at page load; the current track's
996
1087
  // envelope decodes on play (startPlay / resume).
997
- restorePlayback(list);
998
- restoreBookPlayback();
1088
+ restoreLatest(list);
999
1089
  } catch (err) {
1000
1090
  set({ loading: false, error: '\u65e0\u6cd5\u8bfb\u53d6\u97f3\u4e50\u5e93\uff1a' + String((err && err.message) || err) });
1001
1091
  }
@@ -1015,8 +1105,7 @@ window.__ModuleLoader__.load({
1015
1105
  tracks: result.tracks || [], books: result.books || [],
1016
1106
  count: result.count || 0, loading: false, error: null,
1017
1107
  });
1018
- restorePlayback(result.tracks || []);
1019
- restoreBookPlayback();
1108
+ restoreLatest(result.tracks || []);
1020
1109
  } else {
1021
1110
  set({ loading: false, error: (result && result.error) || '\u8bbe\u7f6e\u76ee\u5f55\u5931\u8d25' });
1022
1111
  }
@@ -1417,7 +1506,7 @@ window.__ModuleLoader__.load({
1417
1506
  key: b.id,
1418
1507
  className: 'dsh-music-track' + (active ? ' active' : ''),
1419
1508
  title: b.url,
1420
- onClick: () => { if (active) togglePlay(); else playBook(b.id); },
1509
+ onClick: () => { if (active) togglePlay(); else resumeOrPlayBook(b.id); },
1421
1510
  },
1422
1511
  React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + b.name),
1423
1512
  React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(b.size)),
@@ -1609,7 +1698,7 @@ window.__ModuleLoader__.load({
1609
1698
  const book = bookById(intent.id);
1610
1699
  if (book !== null) {
1611
1700
  set({ pendingId: 'book:' + book.id, pendingName: intent.name || book.name, error: null });
1612
- playBook(book.id);
1701
+ resumeOrPlayBook(book.id);
1613
1702
  return;
1614
1703
  }
1615
1704
  const track = trackById(intent.id);
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.1",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",