dsh-music-player 0.6.1 → 0.6.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ DeepSeek Harness 本地音乐/小说播放插件。
13
13
  - 本地音频流式播放(HTTP Range),刷新后断点续播
14
14
  - 顺序播放、单曲循环、乱序播放三种模式
15
15
  - 实时 8 段频谱可视化(真实 FFT 频段,解码时离线计算、跟随播放位置)
16
- - **实时歌词/字幕**:本地音频自动匹配同名 `.lrc` 逐行显示;在线 QQ 歌曲自动取官方歌词(外语歌带逐句翻译「原文 / 翻译」);AI 讲书时显示当前朗读句子(逐句滚动)。歌词/字幕显示在播放条频谱之后、时长之前,仅在闲置(控件组折叠)时展示,鼠标进入操作时自动收起
16
+ - **实时歌词/字幕**:本地音频自动匹配同名 `.lrc` 逐行显示;**本地没有同名 `.lrc` 时自动在线兜底**(QQ 音乐官方歌词 → LRCLIB 免费同步歌词,结果按曲目缓存避免重复请求);在线 QQ 歌曲自动取官方歌词(外语歌带逐句翻译「原文 / 翻译」);AI 讲书时显示当前朗读句子(逐句滚动)。歌词/字幕显示在播放条频谱之后、时长之前,仅在闲置(控件组折叠)时展示,鼠标进入操作时自动收起;**AI 讲书还有一条「已读字符/全书字符」的全书进度细线**(按已读字数实时计算,不依赖合成时长,切块不回退,操作时再显示「N%」)
17
17
  - 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
18
18
  - 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
19
19
  - AI 讲书:本地 `.txt` / `.epub` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转(打开即定位到当前正在播放的章节)、章节切歌,可选 4 种中文 AI 声音(默认白桦)
@@ -147,6 +147,7 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
147
147
  - **章节目录**(📖 按钮):自动识别全书结构(书名/前言/章节/尾声),点击弹出**位于按钮正上方**的目录(自动定位到当前正在播放的章节),点击任意章节即从该章开头朗读
148
148
  - **后退 / 前进**:讲书模式下跳上一章 / 下一章(音乐模式下仍是上一首 / 下一首)
149
149
  - **AI 声音**:点音量按钮,在弹层选择声音——冰糖(女)、茉莉(女)、苏打(男)、白桦(男,默认)
150
+ - **全书进度**:播放条底部有一条「已读字符/全书字符」的进度细线,操作时显示「N%」——按已读字数实时计算,无需先合成全书就能给出稳定的整体进度(切块不回退;字符量来自源文本,因此总长总是可知,而总时长得合成完才知道),刷新页面后也会立即恢复显示(无需先点播放)
150
151
  4. 刷新页面后从上次位置续读(断点续播)。
151
152
 
152
153
  支持的格式:
@@ -179,7 +180,7 @@ dsh plugin --profile <profile> add ./ # 或直接改 profile 里的 link 目
179
180
  点面板顶部「选择音乐目录」,选一个包含音频文件的实际目录(默认 `~/Music`)。目录路径不可读或不存在时会回退到默认目录而不是报错。
180
181
 
181
182
  **改了音乐目录/新增了歌曲,但列表没更新?**
182
- 播放器在启动时扫描,并在每次“选择音乐目录”时重扫。想强制刷新当前目录,重新打开一次面板或点一次目录设置即可(扫描上限 500 首、递归子目录深度上限 4 层)。
183
+ 播放器在启动时扫描一次,并支持手动重扫:点「选择音乐目录」旁边的新增 **「刷新」按钮**,会重新遍历当前目录并更新列表(音乐与小说通用;无需重选目录)。扫描上限 500 首、递归子目录深度上限 4 层。
183
184
 
184
185
  **`music_play` 工具说"音乐库为空"?**
185
186
  说明还没有可用的音乐目录。请先打开播放面板,点「选择音乐目录」配置一次。
@@ -193,6 +194,9 @@ AI 语音目前仅支持 xiaomi 提供方(限时免费)。请先在 DSH 模
193
194
  **听书偶尔"没声音但时间还在走"?**
194
195
  这种一般是某一段的合成结果异常(返回了退化/静音音频),或瞬时合成失败。0.3.3 起 Host 端会严格校验合成音频(拒绝空数据/非 PCM 等退化 WAV)并自动重试一次瞬时失败,同时把每次合成结果记录在诊断日志里。若再遇到,可访问 `http://<DSH地址>/dsh-music/tts-logs` 查看最近 60 条合成记录(含失败原因、退化音频事件),据此定位具体是哪个块出的问题。
195
196
 
197
+ **本地音乐没有 .lrc,歌词是怎么来的?**
198
+ 播放器会**在线兜底取词**:先用文件名(可带歌手/时长)在 QQ 音乐匿名接口匹配官方歌词(外语歌带逐句翻译),QQ 无果再查 LRCLIB(免费公开歌词库,返回同步 LRC)。结果按曲目在进程内缓存(正命中 6 小时 / 空命中 30 分钟),避免重复请求;无匹配或失败时静默保持无歌词,不影响播放。歌词为版权内容,仅供个人试听。
199
+
196
200
  **想支持更多音频格式?**
197
201
  格式支持由 Host 端 `AUDIO_TYPES` 表驱动,在 `lib/index.js` 里加扩展名与 MIME 即可(播放器本身用浏览器原生 `<audio>` 解码,最终能否播放还取决于浏览器对该编码的支持)。
198
202
 
package/lib/client.js CHANGED
@@ -453,6 +453,9 @@ window.__ModuleLoader__.load({
453
453
  // 空串 = 未取到(非 QQ / 取链失败),播放条只显示「QQ音乐」。
454
454
  currentQuality: '',
455
455
  playing: false, position: 0, duration: 0, volume: 0.8,
456
+ // AI 讲书全书进度(0..1):已读字符 / 全书字符。Host 的 charOffsets 就绪前为 0;
457
+ // 总字符数无需合成即可知,因此能给出稳定的「读到哪了」比例(时长占比做不到)。
458
+ bookProgress: 0,
456
459
  panelOpen: false, loading: false, error: null, pendingId: null, pendingName: null,
457
460
  mode: 'order', vizState: 'ok', tab: 'music',
458
461
  // true once the Host prefs snapshot is loaded (volume/mode/playback from
@@ -485,6 +488,8 @@ window.__ModuleLoader__.load({
485
488
  prompt: null,
486
489
  // 自定义确认弹窗(替代浏览器 confirm):{ title, message, onOk, okText, danger } | null。
487
490
  confirm: null,
491
+ // 「加入歌单」成功/失败提示:{ text, ok, id } | null。面板窗口内居中显示,2s 自动消失。
492
+ toast: null,
488
493
  };
489
494
  const listeners = new Set();
490
495
  function set(patch) {
@@ -505,6 +510,19 @@ window.__ModuleLoader__.load({
505
510
  }, []);
506
511
  return snap;
507
512
  }
513
+ // 「加入歌单」成功/失败提示:统一在面板窗口内居中显示(成功绿色 / 失败红色),
514
+ // 2 秒后自动消失。连续触发时只保留最后一条(前一条立即被顶掉)。
515
+ let toastTimer = null;
516
+ let toastSeq = 0;
517
+ function showToast(text, ok) {
518
+ const id = ++toastSeq;
519
+ set({ toast: { text, ok: !!ok, id } });
520
+ if (toastTimer !== null) clearTimeout(toastTimer);
521
+ toastTimer = setTimeout(() => {
522
+ toastTimer = null;
523
+ if (store.toast && store.toast.id === id) set({ toast: null });
524
+ }, 2000);
525
+ }
508
526
  // 自定义输入弹窗(替代浏览器 prompt):openPrompt 打开、closePrompt 关闭,
509
527
  // onOk(value) 在用户点「确定」时收到去空格后的值;点「取消」/关闭不回调。
510
528
  let promptSeq = 0;
@@ -585,7 +603,7 @@ window.__ModuleLoader__.load({
585
603
  method: 'POST', cache: 'no-store',
586
604
  headers: { 'content-type': 'application/json' },
587
605
  body: JSON.stringify({ id, paths }),
588
- }).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); if (then) then(r); }).catch(() => {});
606
+ }).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); if (then) then(r); }).catch(() => { if (then) then(null); });
589
607
  }
590
608
  function apiPlaylistRemove(id, paths, then) {
591
609
  fetch('/dsh-music/playlist/remove', {
@@ -787,13 +805,19 @@ window.__ModuleLoader__.load({
787
805
  if (store.currentId === null) { clearPref(PREF_PLAYBACK); return; }
788
806
  if (String(store.currentId).startsWith('book:')) { saveCurrentBookPlayback(); return; }
789
807
  if (String(store.currentId).startsWith('qq:')) {
790
- // 在线曲目:保存曲目 + 队列(刷新后可恢复)。流地址每次经代理重新获取,可续播。
808
+ // 在线曲目:保存曲目 + 队列 + 播放位置(刷新/重启后可从中途续播)。
809
+ // 流地址每次经代理重新获取(Range 透传),所以位置 seek 始终有效。
791
810
  const mid = String(store.currentId).slice(3);
792
811
  const song = (store.qqQueue || []).find((t) => String(t.songmid || t.id) === mid);
793
812
  if (!song) return;
794
813
  savePref(PREF_PLAYBACK, JSON.stringify({
795
814
  kind: 'qq', id: store.currentId, name: store.currentName,
796
815
  artists: store.currentArtists || [],
816
+ // 与本地曲目一致:恢复中的曲目还没真正加载,currentTime 可能短暂为 0,
817
+ // 用钉住的 restoredMusicPos 覆盖,避免把已恢复的位置写成 0。
818
+ position: (restoredMusicPos !== null && restoredMusicPos > 0) ? restoredMusicPos : (audio.currentTime || 0),
819
+ // 顺手持久化已知时长:恢复时浏览器尚未加载元数据,避免显示 "0:00"。
820
+ duration: Number.isFinite(audio.duration) ? audio.duration : 0,
797
821
  queue: store.qqQueue || [], source: store.qqSource || '在线',
798
822
  ts: Date.now(),
799
823
  }));
@@ -834,6 +858,11 @@ window.__ModuleLoader__.load({
834
858
  // 右引号字符:断在句尾标点后、若紧跟右引号则连同引号一起收进本行,避免把 「”」
835
859
  // 单独留在一行。
836
860
  const SUB_QUOTE_CLOSE = new Set('”」』"'.split(''));
861
+ // 断行「容忍」余量:当必须按 SUBTITLE_MAX 边界硬切时,允许本行再多收几个有效字,
862
+ // 以落到边界后不远处的一个自然停顿(断句标点)上,或吞掉本块末尾的极小残留——这样
863
+ // 每行更完整,避免「……他说」被硬切、也避免末尾残留一个极短行。仅在越过边界后累计
864
+ // 额外有效字 ≤ 该值时生效,防止行无限变长。
865
+ const SUBTITLE_TOLERANCE = 3;
837
866
  const isSubLowSurrogate = (code) => code >= 0xDC00 && code <= 0xDFFF;
838
867
  // 标点集合:不计入「字数」(仍是显示内容,只是长度统计时不算字)。
839
868
  const SUB_PUNCT = new Set(',。!?…:;、“”‘’()《》〈〉[]【】—~·`~!@#$%^&*()-_=+[]{};\':",.<>/?\\|'.split(''));
@@ -849,7 +878,9 @@ window.__ModuleLoader__.load({
849
878
  // 把「当前讲书块文本」直接切成字幕行:每行 ≤ SUBTITLE_MAX(去标点字数),优先在
850
879
  // 逗号/顿号/句号等自然停顿处换行(不避让引号——长对话也按内部标点优雅切断,而不是
851
880
  // 触发兜底硬切),断口后紧跟的右引号会随本行收走。仅当窗口内没有任何停顿标点时才
852
- // 按字数边界断。标点不计字数;空白/换行折叠为空格。
881
+ // 按字数边界硬切;硬切前先做「容忍」:若边界后不远处就是断句标点,或本块马上就要
882
+ // 结束,就把断行位置往后挪一点,让这一行更完整(见 SUBTITLE_TOLERANCE)。标点不计
883
+ // 字数;空白/换行折叠为空格。
853
884
  function wrapSubtitleLine(text) {
854
885
  const raw = String(text || '');
855
886
  const fold = (s) => s.replace(/\s+/g, ' ').trim();
@@ -872,7 +903,29 @@ window.__ModuleLoader__.load({
872
903
  for (let j = start; j < end; j++) {
873
904
  if (SUBTITLE_CLAUSE_BREAKS.indexOf(raw[j]) !== -1) brk = j;
874
905
  }
875
- let lineEnd = brk > start ? brk + 1 : Math.max(start + 1, end);
906
+ let lineEnd;
907
+ if (brk > start) {
908
+ lineEnd = brk + 1;
909
+ } else {
910
+ // 需要硬切。先容忍:向后看 SUBTITLE_TOLERANCE 个有效字范围内有没有断句标点,
911
+ // 有则把行尾挪到该标点之后(行更完整)。再容忍「块尾」:若边界后只剩极小残留
912
+ // (≤ SUBTITLE_TOLERANCE 个有效字),直接吞掉,避免末尾出一个极短行。
913
+ let where = -1;
914
+ let extra = 0;
915
+ for (let j = end; j < n; j++) {
916
+ if (isSubContent(raw[j])) {
917
+ extra++;
918
+ if (extra > SUBTITLE_TOLERANCE) break; // 超出容忍范围,不再往后找停顿
919
+ }
920
+ if (SUBTITLE_CLAUSE_BREAKS.indexOf(raw[j]) !== -1) { where = j; break; }
921
+ }
922
+ if (where !== -1) {
923
+ lineEnd = where + 1;
924
+ } else {
925
+ const tailContent = countSubContent(raw.slice(end));
926
+ lineEnd = (tailContent > 0 && tailContent <= SUBTITLE_TOLERANCE) ? n : Math.max(start + 1, end);
927
+ }
928
+ }
876
929
  // 断句后把紧跟在后面的右引号一并收进本行(如 「。.””」),避免引号单独成行。
877
930
  while (lineEnd < n && SUB_QUOTE_CLOSE.has(raw[lineEnd])) lineEnd++;
878
931
  lines.push(fold(raw.slice(start, lineEnd)));
@@ -914,6 +967,27 @@ window.__ModuleLoader__.load({
914
967
  }
915
968
  return Math.max(0, ans);
916
969
  }
970
+ // 讲书「已读字符/全书字符」的实时进度(0..1)。字符数无需合成即可由 Host 精确算出
971
+ // (charOffsets 已缓存于 bookMetaCache),因此能得到稳定的全书占比——而时长占比
972
+ // 做不到:全书总时长在合成本书之前不可知。块内按播放时间占比插值(假设语速大致
973
+ // 恒定),跨块由 charOffsets[from] 阶梯推进,整体单调、切块不回退。meta 未就绪时
974
+ // 返回 0(进度条先不渲染),不阻塞其它功能。
975
+ function bookProgressFor() {
976
+ const id = currentBookId();
977
+ const meta = id !== null ? bookMetaCache.get(id) : undefined;
978
+ const offsets = meta && Array.isArray(meta.charOffsets) ? meta.charOffsets : [];
979
+ if (offsets.length < 2) return 0;
980
+ const total = offsets[offsets.length - 1];
981
+ if (!(total > 0)) return 0;
982
+ const k = bookFromRef;
983
+ if (k < 0 || k >= offsets.length - 1) return 0;
984
+ const dur = Number.isFinite(subtitleDur) && subtitleDur > 0
985
+ ? subtitleDur
986
+ : (Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0);
987
+ const frac = dur > 0 ? Math.max(0, Math.min(1, (audio.currentTime || 0) / dur)) : 0;
988
+ const consumed = offsets[k] + (offsets[k + 1] - offsets[k]) * frac;
989
+ return consumed > 0 ? Math.max(0, Math.min(1, consumed / total)) : 0;
990
+ }
917
991
  function resetLyric() {
918
992
  musicLyric = [];
919
993
  subtitleLines = [];
@@ -953,7 +1027,8 @@ window.__ModuleLoader__.load({
953
1027
  if (text !== store.lyricText) set({ lyricText: text });
954
1028
  }
955
1029
  }
956
- // 拉取并解析本地曲目的 .lrc 歌词;无 .lrc / 切歌后返回皆静默。
1030
+ // 拉取并解析本地曲目的歌词:优先同名 .lrc(/lyric);本地无则在线兜底
1031
+ // (/lyric/online:Host 按 QQ 音乐 → LRCLIB 匹配取词)。无词/切歌后返回皆静默。
957
1032
  function loadLyricForTrack(id) {
958
1033
  resetLyric();
959
1034
  lyricTrackId = id;
@@ -963,9 +1038,40 @@ window.__ModuleLoader__.load({
963
1038
  fetch('/dsh-music/lyric?path=' + encodeURIComponent(track.path), { cache: 'no-store' })
964
1039
  .then((r) => r.json())
965
1040
  .then((d) => {
966
- if (!d || !d.ok || !Array.isArray(d.lrc)) return;
967
1041
  if (store.currentId !== id) return; // 取词期间已切歌
968
- musicLyric = d.lrc;
1042
+ if (d && d.ok && Array.isArray(d.lrc) && d.lrc.length > 0) {
1043
+ musicLyric = d.lrc;
1044
+ updateLyric();
1045
+ return;
1046
+ }
1047
+ // 本地无同名 .lrc → 在线兜底(QQ → LRCLIB)
1048
+ loadOnlineLyric(id, track);
1049
+ })
1050
+ .catch(() => {});
1051
+ }
1052
+ // 从文件名 stem 拆「歌手 - 歌名」。常见命名:'周杰伦 - 乱舞春秋'、'周杰伦、温岚 - 屋顶'、
1053
+ // '周杰伦 余妮 - 懦夫'、'周杰伦 - 李玟 - 刀马旦'。以最后一个 ' - '/'–'/'—' 为界:
1054
+ // 前段当歌手(可含多位/空格/顿号),后段当歌名;没有分隔符时整段当歌名(歌手留空)。
1055
+ // 拆出干净歌名 + 歌手能显著提高在线歌词匹配精度(否则前缀会拖低匹配分)。
1056
+ function splitArtistTitle(stem) {
1057
+ const m = /^(.*)[-–—]\s*(.+)$/.exec(String(stem || ''));
1058
+ if (!m) return { title: String(stem || ''), artist: '' };
1059
+ return { title: m[2].trim(), artist: m[1].trim() };
1060
+ }
1061
+ // 本地歌曲在线歌词兜底:把文件名拆出的歌名/歌手 + 时长交给 Host 去 QQ/LRCLIB 匹配取词。
1062
+ function loadOnlineLyric(id, track) {
1063
+ const base = String(track.name || '').split(/[\\/]/).pop();
1064
+ const { title, artist } = splitArtistTitle(stripExt(base));
1065
+ const params = new URLSearchParams({ path: track.path, title });
1066
+ if (artist !== '') params.set('artist', artist);
1067
+ else if (Array.isArray(track.artists) && track.artists.length > 0) params.set('artist', track.artists.join('/'));
1068
+ if (Number.isFinite(audio.duration) && audio.duration > 0) params.set('duration', String(Math.round(audio.duration)));
1069
+ fetch('/dsh-music/lyric/online?' + params.toString(), { cache: 'no-store' })
1070
+ .then((r) => r.json())
1071
+ .then((d) => {
1072
+ if (!d || !d.ok || !Array.isArray(d.lrc) || d.lrc.length === 0) return;
1073
+ if (store.currentId !== id) return; // 取词期间已切歌
1074
+ musicLyric = mergeLyricTrans(d.lrc, d.trans);
969
1075
  updateLyric();
970
1076
  })
971
1077
  .catch(() => {});
@@ -1359,7 +1465,7 @@ window.__ModuleLoader__.load({
1359
1465
  bookRestorePos = -1;
1360
1466
  audio.src = track.url;
1361
1467
  audio.load();
1362
- set({ currentId: id, currentName: track.name, currentArtists: track.artists || [], pendingId: null, pendingName: null, error: null, tocOpen: false, currentSection: '', qqFaved: false, currentQuality: (track && track.quality) || '' });
1468
+ set({ currentId: id, currentName: track.name, currentArtists: track.artists || [], pendingId: null, pendingName: null, error: null, tocOpen: false, currentSection: '', qqFaved: false, currentQuality: (track && track.quality) || '', bookProgress: 0 });
1363
1469
  syncShufflePos();
1364
1470
  loadEnvelope(id, track.url);
1365
1471
  loadLyricForTrack(id);
@@ -1456,6 +1562,10 @@ window.__ModuleLoader__.load({
1456
1562
  title: (m && m.title) || '',
1457
1563
  author: (m && m.author) || '',
1458
1564
  sections: (m && Array.isArray(m.sections)) ? m.sections : [],
1565
+ // 逐块累积字符偏移 + 全书总字符:用于「已读字符/全书字符」的实时进度。
1566
+ // 旧 Host 没有这俩字段时优雅降级(进度条保持 0/不显示),不影响其它功能。
1567
+ charOffsets: (m && Array.isArray(m.charOffsets) && m.charOffsets.length > 1) ? m.charOffsets : [],
1568
+ totalChars: m && typeof m.totalChars === 'number' && m.totalChars > 0 ? m.totalChars : -1,
1459
1569
  };
1460
1570
  bookMetaCache.set(id, meta);
1461
1571
  }
@@ -1534,7 +1644,7 @@ window.__ModuleLoader__.load({
1534
1644
  // near-instant (server-side synthesis cache) so we don't flash a spinner
1535
1645
  // at every chunk boundary. Only user-initiated plays show it.
1536
1646
  const showBuffer = !silent;
1537
- set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, vizState: 'ok', currentSection: '', bookToc: [], currentQuality: '' });
1647
+ set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, vizState: 'ok', currentSection: '', bookToc: [], currentQuality: '', bookProgress: 0 });
1538
1648
  // Load the chapter structure for the toc + the current-section label (the
1539
1649
  // current section is derived from the chunk index once the meta arrives).
1540
1650
  const startFrom = from;
@@ -1675,6 +1785,9 @@ window.__ModuleLoader__.load({
1675
1785
  try { audio.currentTime = bookRestorePos; } catch (e) {}
1676
1786
  }
1677
1787
  if (bookTotal >= 0 && bookFromRef + 1 < bookTotal) preloadBook(id, bookFromRef + 1);
1788
+ // 恢复续播的这块之前没有拉取过字幕(restore 不动 audio、playBookFrom 不经过
1789
+ // 这里),必须补一次 loadBookSubtitle,否则续播后整块无字幕、直到切块才出现。
1790
+ loadBookSubtitle(id, bookFromRef);
1678
1791
  }
1679
1792
  const promise = audio.play();
1680
1793
  if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' }); });
@@ -1798,7 +1911,7 @@ window.__ModuleLoader__.load({
1798
1911
  audio.load();
1799
1912
  stopBookHelper();
1800
1913
  bookAutoRetried = false;
1801
- 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: '', lyricText: '', voiceSwitching: false });
1914
+ 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: '', lyricText: '', voiceSwitching: false, bookProgress: 0 });
1802
1915
  resetLyric();
1803
1916
  clearPref(PREF_PLAYBACK);
1804
1917
  if (stoppedBook !== null) clearBookPlayback(stoppedBook.name);
@@ -1838,6 +1951,12 @@ window.__ModuleLoader__.load({
1838
1951
  // block (it reads like one long audiobook). Music is unchanged.
1839
1952
  const bookTimeBase = () => (store.currentId !== null && String(store.currentId).startsWith('book:')) ? bookBaseTime : 0;
1840
1953
  const onTime = () => {
1954
+ // 讲书:按「已读字符/全书字符」实时推进全书进度条。不用 position/duration 的
1955
+ // 时间占比——那是当前块内比例,切块会回退;且全书总时长在合成本书前不可知。
1956
+ if (String(store.currentId).startsWith('book:')) {
1957
+ const bp = bookProgressFor();
1958
+ if (bp !== store.bookProgress) set({ bookProgress: bp });
1959
+ }
1841
1960
  // A restored music track keeps showing its restored position until real
1842
1961
  // playback has clearly advanced past it. The <audio> currentTime is
1843
1962
  // unreliable right after a restore — it can seek to the spot and then
@@ -1871,6 +1990,9 @@ window.__ModuleLoader__.load({
1871
1990
  try { audio.currentTime = bookRestorePos; } catch (e) {}
1872
1991
  }
1873
1992
  set({ position: bookBaseTime + bookRestorePos });
1993
+ // 补齐:pin 阶段也推进字幕——续播时该块的实时字幕可能还没就绪,这里在
1994
+ // 每次 timeupdate 都尝试定位当前句,避免「先空白、切块后才出字幕」。
1995
+ updateLyric();
1874
1996
  return;
1875
1997
  }
1876
1998
  }
@@ -2005,8 +2127,10 @@ window.__ModuleLoader__.load({
2005
2127
  function restorePlayback(list) {
2006
2128
  const saved = loadPlayback();
2007
2129
  if (saved === null) return;
2008
- // 在线 QQ 曲目:恢复当前曲目 + 队列 + 在线 scope + 在线 tab。
2130
+ // 在线 QQ 曲目:恢复当前曲目 + 队列 + 播放位置 + 在线 scope + 在线 tab。
2009
2131
  if (saved.kind === 'qq' || (saved.id && String(saved.id).startsWith('qq:'))) {
2132
+ const pos = Number.isFinite(saved.position) && saved.position > 0 ? saved.position : 0;
2133
+ const savedDur = Number.isFinite(saved.duration) && saved.duration > 0 ? saved.duration : 0;
2010
2134
  set({
2011
2135
  currentId: saved.id,
2012
2136
  currentName: saved.name || 'QQ音乐',
@@ -2014,10 +2138,14 @@ window.__ModuleLoader__.load({
2014
2138
  scope: { kind: 'qq' },
2015
2139
  qqQueue: Array.isArray(saved.queue) ? saved.queue : [],
2016
2140
  qqSource: saved.source || '在线',
2141
+ // 恢复在线曲目的中途位置:播放条立即显示保存的位置;用户点 ▶ 续播时
2142
+ // togglePlay 会经 restoredMusicPos 把流 seek 到该位置(与本地曲目一致)。
2143
+ position: pos, duration: savedDur,
2017
2144
  pendingId: null, pendingName: null, error: null,
2018
2145
  tab: 'qq',
2019
2146
  currentQuality: '',
2020
2147
  });
2148
+ restoredMusicPos = pos > 0 ? pos : null;
2021
2149
  // 更新时间戳,避免被当作旧数据。
2022
2150
  savePref(PREF_PLAYBACK, JSON.stringify({ ...saved, ts: Date.now() }));
2023
2151
  // 恢复在线曲目后按「我喜欢」集合刷新爱心状态。
@@ -2098,8 +2226,13 @@ window.__ModuleLoader__.load({
2098
2226
  // 立刻显示正在播放的章节(而不是等用户点 ▶、playBookFrom 才开始加载)。
2099
2227
  // ensureBookToc 内部会 set bookToc;这里再补上 currentSection。
2100
2228
  void ensureBookToc(book.id).then((meta) => {
2101
- if (meta === null || meta.sections.length === 0) return;
2102
- set({ currentSection: sectionForChunk(meta.sections, from) });
2229
+ if (meta === null) return;
2230
+ // 刷新后书尚未播放(自动播放被拦截),onTime 不会触发、bookProgress 仍为 0,
2231
+ // 必须先在这里按「已读字符/全书字符」补一次,进度条才能立即显示正确的整体进度
2232
+ // (audio 未加载时 frac=0,即当前块起点位移 offset[from];播放后由 onTime 微调)。
2233
+ const bp = bookProgressFor();
2234
+ if (bp !== store.bookProgress) set({ bookProgress: bp });
2235
+ if (meta.sections.length > 0) set({ currentSection: sectionForChunk(meta.sections, from) });
2103
2236
  });
2104
2237
  // Refresh the chunk total in the background (the book file may have
2105
2238
  // changed since the save); fall back to the saved total on failure.
@@ -2182,6 +2315,29 @@ window.__ModuleLoader__.load({
2182
2315
  set({ loading: false, error: '设置目录失败:' + String((err && err.message) || err) });
2183
2316
  });
2184
2317
  }
2318
+ // 手动刷新:重新扫描当前音乐/小说目录并更新列表(面板「刷新」按钮)。
2319
+ // 与 saveRoot 一致只刷新列表、不调用 restoreLatest,避免把播放中的在线 QQ
2320
+ // 曲目/tab 跳走。返回 promise 供按钮等待扫描完成以复位「刷新中…」。
2321
+ function rescanLibrary() {
2322
+ return fetch('/dsh-music/rescan', {
2323
+ method: 'POST', cache: 'no-store',
2324
+ headers: { 'content-type': 'application/json' },
2325
+ }).then((r) => r.json()).then((result) => {
2326
+ if (result && result.ok) {
2327
+ set({
2328
+ root: result.root || null, bookRoot: result.bookRoot || null,
2329
+ tracks: result.tracks || [], books: result.books || [],
2330
+ playlists: result.playlists || [],
2331
+ count: result.count || 0, error: null,
2332
+ voices: Array.isArray(result.voices) ? result.voices : [],
2333
+ });
2334
+ } else {
2335
+ set({ error: (result && result.error) || '刷新失败' });
2336
+ }
2337
+ }).catch((err) => {
2338
+ set({ error: '刷新失败:' + String((err && err.message) || err) });
2339
+ });
2340
+ }
2185
2341
 
2186
2342
  function fmtTime(seconds) {
2187
2343
  if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
@@ -2466,8 +2622,12 @@ window.__ModuleLoader__.load({
2466
2622
  // 无内容(点击停止 / 插件刚安装)时恒定工作态:不透明度 100%、控件组展开,无任何特效。
2467
2623
  const active = barHover || anyPopOpen || !hasTrack;
2468
2624
  const barDimmed = !active;
2469
- // 播放进度细线:有内容且已获取时长时,按 position/duration 计算填充百分比。
2470
- const progressPct = (hasTrack && s.duration > 0) ? Math.min(100, Math.max(0, (s.position / s.duration) * 100)) : 0;
2625
+ // 播放进度细线:音乐按 position/duration(单曲时长),讲书按「已读字符/全书字符」
2626
+ // bookProgress——全书总时长在合成本书前不可知,用时长占比会切块回退,字符占比
2627
+ // 才稳定且只增不减。
2628
+ const progressPct = isBook
2629
+ ? Math.min(100, Math.max(0, s.bookProgress * 100))
2630
+ : ((hasTrack && s.duration > 0) ? Math.min(100, Math.max(0, (s.position / s.duration) * 100)) : 0);
2471
2631
  return React.createElement('div', { className: 'dsh-music-bar-wrap' },
2472
2632
  React.createElement('div',
2473
2633
  { className: 'dsh-music-bar' + (isBook ? ' book' : '') + (barDimmed ? ' dimmed' : ''), onMouseEnter: showBarBtns, onMouseLeave: onBarLeave },
@@ -2488,7 +2648,9 @@ window.__ModuleLoader__.load({
2488
2648
  hasTrack
2489
2649
  ? (showHint
2490
2650
  ? React.createElement('span', { className: 'dsh-music-bar-hint' }, '⚠ 自动播放被拦截,点击▶解锁')
2491
- : (isBook ? null : (!barDimmed ? React.createElement('span', { className: 'dsh-music-bar-time' }, fmtTime(s.position) + ' / ' + fmtTime(s.duration)) : null)))
2651
+ : (isBook
2652
+ ? (!barDimmed ? React.createElement('span', { className: 'dsh-music-bar-time' }, Math.round(s.bookProgress * 100) + '%') : null)
2653
+ : (!barDimmed ? React.createElement('span', { className: 'dsh-music-bar-time' }, fmtTime(s.position) + ' / ' + fmtTime(s.duration)) : null)))
2492
2654
  : null,
2493
2655
  React.createElement('div', { className: 'dsh-music-bar-btns' },
2494
2656
  heartBtn,
@@ -2544,8 +2706,8 @@ window.__ModuleLoader__.load({
2544
2706
  React.createElement(VolumeSlider, null),
2545
2707
  )) : null,
2546
2708
  // 播放进度细线:绝对定位在播放条底部,与播放条等宽、高约 1px,随播放实时填充。
2547
- // 仅音乐模式显示(AI 讲书是累计时钟、按章节推进,不做单条进度线)。
2548
- hasTrack && !isBook && s.duration > 0
2709
+ // 音乐按单曲时长,讲书按「已读字符/全书字符」的 bookProgress(见 progressPct)。
2710
+ hasTrack && (isBook || s.duration > 0)
2549
2711
  ? React.createElement('div', { className: 'dsh-music-bar-progress' },
2550
2712
  React.createElement('div', { className: 'dsh-music-bar-progress-fill', style: { width: progressPct + '%' } }))
2551
2713
  : null,
@@ -2739,6 +2901,9 @@ window.__ModuleLoader__.load({
2739
2901
  const [histOpen, setHistOpen] = useState(false);
2740
2902
  const histRef = useRef(null);
2741
2903
  const histPopRef = useRef(null); // portal 到 body 的历史下拉(点击内部不关闭)
2904
+ // 恢复「在线播放列表」层:prefsReady 早于 restorePlayback 灌 qqQueue,队列就绪前
2905
+ // 先标记待恢复,等 qqQueue 非空后由下方 effect 补上(避免刷新后直接显示「暂无歌曲」)。
2906
+ const queueRestorePending = useRef(false);
2742
2907
  // 浏览。
2743
2908
  const [minePlays, setMinePlays] = useState([]);
2744
2909
  const [mineLoaded, setMineLoaded] = useState(false);
@@ -2750,7 +2915,6 @@ window.__ModuleLoader__.load({
2750
2915
  const [browseLoading, setBrowseLoading] = useState(false);
2751
2916
  // 「加入歌单」弹窗:{ song, x, y }(锚点=「+」按钮的视口坐标),点击弹出我的歌单列表。
2752
2917
  const [qqJoin, setQqJoin] = useState(null);
2753
- const [qqJoinMsg, setQqJoinMsg] = useState('');
2754
2918
  const qqJoinRef = useRef(null);
2755
2919
  // 点击弹窗外关闭「加入歌单」弹窗。
2756
2920
  useEffect(() => {
@@ -2821,11 +2985,40 @@ window.__ModuleLoader__.load({
2821
2985
  if (!ui || ui.layer !== 'playlist') return;
2822
2986
  setLayer('playlist');
2823
2987
  if (ui.plId) {
2824
- json('/dsh-music/qq/playlist/' + encodeURIComponent(ui.plId)).then((d) => { if (d && d.ok) setActivePl({ ...d.playlist, source: ui.plName || '歌单' }); });
2988
+ // openPlaylist 同款加载态:先显示「加载中…」而不是空列表。QQ 歌单详情
2989
+ // 接口(fcg_ucc_getcdinfo_byids)在页面刚加载时首次请求常返回空 songlist
2990
+ // (冷缓存/会话未就绪),单次请求就会停在「暂无歌曲」——这正是「刷新后
2991
+ // 播放列表为空、返回重进才出来」的原因。这里对空结果/失败自动重试几次。
2992
+ setPlLoading(true);
2993
+ void loadPlaylistForRestore(ui.plId, ui.plName);
2825
2994
  } else {
2826
- setActivePl({ name: s.qqSource || '在线播放列表', songs: s.qqQueue || [], source: s.qqSource || '在线' });
2995
+ // 「在线播放列表」(openQueue 保存的 plId=''):prefsReady 置位早于
2996
+ // restorePlayback 把 qqQueue 灌好,直接快照 s.qqQueue 会拿到空数组导致
2997
+ // 「暂无歌曲」。这里标记待恢复,由下方 [s.qqQueue] effect 在队列就绪后补上。
2998
+ queueRestorePending.current = true;
2999
+ setActivePl({ name: store.qqSource || '在线播放列表', songs: store.qqQueue || [], source: store.qqSource || '在线' });
2827
3000
  }
2828
3001
  };
3002
+ // 恢复的「在线播放列表」:等 restorePlayback 把 qqQueue 灌好后应用(修复刷新后
3003
+ // 该层显示为空的问题);用户手动离开该层(返回/进入歌单)时取消待恢复。
3004
+ useEffect(() => {
3005
+ if (queueRestorePending.current && store.qqQueue && store.qqQueue.length > 0) {
3006
+ queueRestorePending.current = false;
3007
+ setActivePl({ name: store.qqSource || '在线播放列表', songs: store.qqQueue, source: store.qqSource || '在线' });
3008
+ }
3009
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3010
+ }, [s.qqQueue]);
3011
+ // 恢复歌单层:拉取歌单详情,空结果/失败自动重试(退避递增),成功后置入 activePl。
3012
+ const loadPlaylistForRestore = async (plId, plName, attempts = 3) => {
3013
+ let d = null;
3014
+ for (let i = 0; i < attempts; i++) {
3015
+ d = await json('/dsh-music/qq/playlist/' + encodeURIComponent(plId));
3016
+ if (d && d.ok && d.playlist && Array.isArray(d.playlist.songs) && d.playlist.songs.length > 0) break;
3017
+ if (i + 1 < attempts) await new Promise((r) => setTimeout(r, 400 * (i + 1)));
3018
+ }
3019
+ if (d && d.ok && d.playlist) setActivePl({ ...d.playlist, source: plName || '歌单' });
3020
+ setPlLoading(false);
3021
+ };
2829
3022
  // ① 挂载时只做本地初始化:读搜索历史(Host 持久化)+ 查登录态(status 由
2830
3023
  // host 读本地 cookie 文件,不发任何外部网络请求)。外部数据(分类/推荐/我的
2831
3024
  // 歌单)一律延后到登录后(见下方 [loggedIn] effect),未登录零外部请求——
@@ -2986,6 +3179,7 @@ window.__ModuleLoader__.load({
2986
3179
  }
2987
3180
  function backToTops() { setTopDetail(null); setTopTotal(0); setTopHasMore(false); }
2988
3181
  async function openPlaylist(pl, mine) {
3182
+ queueRestorePending.current = false; // 手动进入歌单,取消「在线播放列表」待恢复
2989
3183
  setActivePl(null); setLayer('playlist'); setPlLoading(true); saveUi('playlist', pl.id, pl.name);
2990
3184
  // _dirId:从歌单移除歌曲需要 QQ 歌单的目录 id(「我的歌单」卡片带 dirId);
2991
3185
  // mine 标记这是「我自己的歌单」(含「我喜欢」),详情里才允许用「−」从该歌单移除。
@@ -2999,7 +3193,7 @@ window.__ModuleLoader__.load({
2999
3193
  setActivePl({ name: s.qqSource || '在线播放列表', songs: s.qqQueue || [], source: s.qqSource || '播放中' });
3000
3194
  setLayer('playlist'); saveUi('playlist', '', '');
3001
3195
  }
3002
- function backToMain() { setLayer('main'); setActivePl(null); saveUi('main', '', ''); }
3196
+ function backToMain() { queueRestorePending.current = false; setLayer('main'); setActivePl(null); saveUi('main', '', ''); }
3003
3197
  async function doSearch(kwOverride) {
3004
3198
  const kw = (kwOverride !== undefined ? String(kwOverride) : q).trim();
3005
3199
  if (kw === '') { setSearched(false); setQError(''); return; }
@@ -3152,15 +3346,16 @@ window.__ModuleLoader__.load({
3152
3346
  } catch (err) { setQError(String((err && err.message) || err)); }
3153
3347
  }
3154
3348
  // 「加入歌单」:打开弹窗(列出我的歌单),并把歌曲加入所选歌单 / 新建歌单。
3349
+ // 成功/失败统一走面板居中的 toast 提示(成功关闭弹窗;失败保留弹窗可重试)。
3155
3350
  function openQqJoin(song, e) {
3156
3351
  const r = e.currentTarget.getBoundingClientRect();
3157
3352
  if (minePlays.length === 0 && !mineLoaded) loadMine();
3158
- setQqJoinMsg('');
3159
3353
  setQqJoin({ song, x: r.right, y: r.top });
3160
3354
  }
3161
3355
  async function qqJoinAddTo(pl) {
3162
3356
  const song = qqJoin && qqJoin.song;
3163
3357
  if (!song) return;
3358
+ const name = (pl && pl.name) || '歌单';
3164
3359
  try {
3165
3360
  // dirId 用歌单的权威 dirId(dirid),tid 固定 0(「我喜欢」dirId=201,tid=0 的可用模式)。
3166
3361
  const d = await jsonPost('/dsh-music/qq/playlist-add', { song, dirId: (pl && (pl.dirId || pl.tid || pl.id)) || 0, tid: 0 });
@@ -3172,7 +3367,8 @@ window.__ModuleLoader__.load({
3172
3367
  loadMine();
3173
3368
  }
3174
3369
  setQqJoin(null);
3175
- } catch (err) { setQqJoinMsg(String((err && err.message) || err)); }
3370
+ showToast('添加到' + name + '成功', true);
3371
+ } catch (err) { showToast('添加到' + name + '失败', false); }
3176
3372
  }
3177
3373
  async function qqJoinCreate() {
3178
3374
  const song = qqJoin && qqJoin.song;
@@ -3189,7 +3385,8 @@ window.__ModuleLoader__.load({
3189
3385
  }
3190
3386
  loadMine();
3191
3387
  setQqJoin(null);
3192
- } catch (err) { setQqJoinMsg(String((err && err.message) || err)); }
3388
+ showToast('添加到' + ((created && created.name) || trimmed) + '成功', true);
3389
+ } catch (err) { showToast('添加到' + trimmed + '失败', false); }
3193
3390
  });
3194
3391
  }
3195
3392
  const qqJoinMenu = qqJoin ? portalToBody((() => {
@@ -3200,7 +3397,6 @@ window.__ModuleLoader__.load({
3200
3397
  transform: openUp ? 'translateY(-100%)' : 'none',
3201
3398
  };
3202
3399
  return React.createElement('div', { className: 'dsh-music-add-pop', ref: qqJoinRef, style },
3203
- qqJoinMsg ? React.createElement('div', { className: 'dsh-music-hint', style: { padding: '2px 8px', color: 'var(--dsw-alias-state-error-primary, #e5534b)' } }, qqJoinMsg) : null,
3204
3400
  minePlays.length > 0 ? minePlays.map((p) => React.createElement('button', {
3205
3401
  key: p.id, className: 'dsh-music-add-pop-item',
3206
3402
  title: '加入「' + p.name + '」',
@@ -3767,6 +3963,7 @@ window.__ModuleLoader__.load({
3767
3963
  }) : null,
3768
3964
  s.prompt ? React.createElement(PromptModal, { key: s.prompt.id, panelRef }) : null,
3769
3965
  s.confirm ? React.createElement(ConfirmModal, { key: s.confirm.title, panelRef }) : null,
3966
+ s.toast ? React.createElement('div', { className: 'dsh-music-panel-toast' + (s.toast.ok ? ' ok' : ' err') }, s.toast.text) : null,
3770
3967
  );
3771
3968
  }
3772
3969
  // 自定义输入弹窗(替代浏览器 prompt):新建/重命名歌单等需要名称输入的场景。
@@ -3838,9 +4035,12 @@ window.__ModuleLoader__.load({
3838
4035
  const [curName, setCurName] = useState('');
3839
4036
  const [curCrumbs, setCurCrumbs] = useState([]);
3840
4037
  const [dirError, setDirError] = useState(null);
4038
+ // 手动刷新按钮:扫描进行中禁用并显示「刷新中…」。
4039
+ const [refreshing, setRefreshing] = useState(false);
3841
4040
  const isBook = s.tab === 'book';
3842
4041
  const activeRoot = isBook ? s.bookRoot : s.root;
3843
4042
  const pickerTitle = isBook ? '选择小说目录' : '选择音乐目录';
4043
+ const refreshTitle = isBook ? '重新扫描小说目录' : '重新扫描音乐目录';
3844
4044
  const hint = isBook
3845
4045
  ? '支持 .txt / .epub 文件,AI语音目前仅支持xiaomi提供方(限时免费),请在设置中配置好再使用此功能。'
3846
4046
  : '支持 mp3 / m4a / flac / wav / ogg / opus / aac / webm 等格式,自动递归扫描子目录。';
@@ -3848,7 +4048,19 @@ window.__ModuleLoader__.load({
3848
4048
  React.createElement('div', { className: 'dsh-music-settings-row' },
3849
4049
  React.createElement('span', { className: 'dsh-music-settings-cur', title: activeRoot || '' },
3850
4050
  '📁 ' + (activeRoot || '未配置')),
3851
- React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle)),
4051
+ React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle),
4052
+ // 手动刷新:重新扫描当前目录(新增文件后无需重选目录即可看到)。
4053
+ React.createElement('button', {
4054
+ className: 'dsh-music-settings-btn ghost',
4055
+ title: refreshTitle,
4056
+ disabled: refreshing,
4057
+ onClick: async () => {
4058
+ if (refreshing) return;
4059
+ setRefreshing(true);
4060
+ await rescanLibrary();
4061
+ setRefreshing(false);
4062
+ },
4063
+ }, refreshing ? '刷新中…' : '刷新')),
3852
4064
  React.createElement('p', { className: 'dsh-music-hint' }, hint),
3853
4065
  pickerOpen ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
3854
4066
  React.createElement('div', { className: 'dsh-music-picker', style: panelCenterStyle(panelRef, pickerOpen, 320, Math.round(window.innerHeight * 0.72)) },
@@ -3924,7 +4136,15 @@ window.__ModuleLoader__.load({
3924
4136
  transform: openUp ? 'translateY(-100%)' : 'none',
3925
4137
  };
3926
4138
  const list = store.playlists || [];
3927
- const addTo = (id) => { apiPlaylistAdd(id, [track.path], () => onClose()); };
4139
+ // 加入已有歌单:成功关弹窗并居中提示,失败保留弹窗(可换歌单重试)居中提示。
4140
+ const addTo = (id) => {
4141
+ const pl = list.find((p) => p.id === id);
4142
+ const name = (pl && pl.name) || '歌单';
4143
+ apiPlaylistAdd(id, [track.path], (r) => {
4144
+ if (r && r.ok && r.playlist) { onClose(); showToast('添加到' + name + '成功', true); }
4145
+ else showToast('添加到' + name + '失败', false);
4146
+ });
4147
+ };
3928
4148
  const addNew = () => {
3929
4149
  openPrompt('新建歌单名称', '', (trimmed) => {
3930
4150
  if (!trimmed) return;
@@ -3935,9 +4155,14 @@ window.__ModuleLoader__.load({
3935
4155
  }).then((r) => r.json()).then((r) => {
3936
4156
  if (r && r.playlist) {
3937
4157
  set({ playlists: [...(store.playlists || []), r.playlist] });
3938
- apiPlaylistAdd(r.playlist.id, [track.path], () => onClose());
4158
+ apiPlaylistAdd(r.playlist.id, [track.path], (add) => {
4159
+ if (add && add.ok && add.playlist) { onClose(); showToast('添加到' + r.playlist.name + '成功', true); }
4160
+ else showToast('添加到' + r.playlist.name + '失败', false);
4161
+ });
4162
+ } else {
4163
+ showToast('添加到' + trimmed + '失败', false);
3939
4164
  }
3940
- }).catch(() => onClose());
4165
+ }).catch(() => showToast('添加到' + trimmed + '失败', false));
3941
4166
  });
3942
4167
  };
3943
4168
  return React.createElement('div', { className: 'dsh-music-add-pop', ref, style },
@@ -4190,7 +4415,7 @@ window.__ModuleLoader__.load({
4190
4415
  // (宽度 0.12s 平滑过渡)。播放条容器已 overflow:hidden,细线两端会被裁剪到
4191
4416
  // 圆角形状内,不会「戳出」圆角之外;pointer-events:none 避免挡住下方交互。
4192
4417
  '.dsh-music-bar-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; overflow: hidden; pointer-events: none; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.1)); }\n' +
4193
- '.dsh-music-bar-progress-fill { height: 100%; background: var(--dsh-music-accent, #2f9e6e); transition: width 0.12s linear; }\n' +
4418
+ '.dsh-music-bar-progress-fill { height: 100%; background: color-mix(in srgb, var(--dsh-music-accent, #2f9e6e) 55%, var(--dsw-alias-bg-base)); transition: width 0.12s linear; }\n' +
4194
4419
  '.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
4195
4420
  '.dsh-music-bar-name { max-width: 36%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-flex; align-items: center; min-width: 0; }\n' +
4196
4421
  '.dsh-music-note { color: var(--dsh-music-accent, #2f9e6e); flex: none; margin-right: 4px; }\n' +
@@ -4449,6 +4674,12 @@ window.__ModuleLoader__.load({
4449
4674
  '.dsh-music-add-pop-item { display: block; width: 100%; text-align: left; padding: 5px 8px; border: none; background: transparent; border-radius: 6px; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n' +
4450
4675
  '.dsh-music-add-pop-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsh-music-accent, #2f9e6e); }\n' +
4451
4676
  '.dsh-music-add-pop-item.new { color: var(--dsh-music-accent, #2f9e6e); border-top: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); margin-top: 2px; padding-top: 6px; }\n' +
4677
+ // 「加入歌单」成功/失败提示:面板窗口内绝对居中,颜色跟随 DSH 主题
4678
+ // (成功 = 主题强调色 --dsh-music-accent;失败 = 主题错误色),2s 自动消失。
4679
+ '.dsh-music-panel-toast { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 20; padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--dsh-music-accent-fg, #fff); background: rgba(30,31,34,0.92); box-shadow: 0 6px 20px rgba(0,0,0,0.35); pointer-events: none; white-space: nowrap; max-width: 90%; text-align: center; animation: dsh-music-toast-in 0.18s ease; }\n' +
4680
+ '.dsh-music-panel-toast.ok { background: var(--dsh-music-accent, #2f9e6e); }\n' +
4681
+ '.dsh-music-panel-toast.err { background: var(--dsw-alias-state-error-primary, #e5534b); }\n' +
4682
+ '@keyframes dsh-music-toast-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.94); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }\n' +
4452
4683
  '.dsh-music-bar-btn.fav { color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
4453
4684
  '.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n';
4454
4685
 
package/lib/index.js CHANGED
@@ -314,6 +314,7 @@ import { dirname, basename, parse as pathParse, join as pathJoin } from 'node:pa
314
314
  import { inflateRawSync, inflateSync } from 'node:zlib'
315
315
  import * as os from 'node:os'
316
316
  import * as QQ from './qq.js'
317
+ import { getOnlineLyric } from './lyric.js'
317
318
 
318
319
  // ---- book structure parsing (title / preface / chapters / epilogue) ----
319
320
  // Heuristic, rule-based parser that splits a novel's normalized text into
@@ -1378,6 +1379,27 @@ export function apply(ctx) {
1378
1379
  return null
1379
1380
  }
1380
1381
 
1382
+ // ---- 本地歌曲在线歌词兜底缓存(按 track.path,LRU + TTL)----
1383
+ // 正命中 6h、空命中 30min:避免每次播放重复打 QQ/LRCLIB;容量上限 500 条。
1384
+ const lyricOnlineCache = new Map()
1385
+ const LYRIC_ONLINE_TTL = 6 * 60 * 60 * 1000
1386
+ const LYRIC_ONLINE_TTL_EMPTY = 30 * 60 * 1000
1387
+ const LYRIC_ONLINE_MAX = 500
1388
+ const lyricOnlineCacheGet = (path) => {
1389
+ const e = lyricOnlineCache.get(path)
1390
+ if (!e) return null
1391
+ if (Date.now() - e.ts > e.ttl) { lyricOnlineCache.delete(path); return null }
1392
+ return e.payload
1393
+ }
1394
+ const lyricOnlineCacheSet = (path, payload, ttl) => {
1395
+ if (lyricOnlineCache.has(path)) lyricOnlineCache.delete(path)
1396
+ lyricOnlineCache.set(path, { ts: Date.now(), ttl, payload })
1397
+ if (lyricOnlineCache.size > LYRIC_ONLINE_MAX) {
1398
+ const k = lyricOnlineCache.keys().next().value
1399
+ if (k !== undefined) lyricOnlineCache.delete(k)
1400
+ }
1401
+ }
1402
+
1381
1403
  const scan = async (rootPath, kinds = { music: true, books: false }) => {
1382
1404
  const target = await ctx.fs.resolve(rootPath)
1383
1405
  const info = await ctx.fs.stat(target)
@@ -1868,6 +1890,13 @@ export function apply(ctx) {
1868
1890
  const cum = []
1869
1891
  let acc = 0
1870
1892
  for (const c of chunks) { acc += c.length; cum.push(acc) }
1893
+ // 逐块累积字符偏移:charOffsets[k] = 前 k 个块的字符数之和(charOffsets[0]=0,
1894
+ // charOffsets[total] = 全书总字符数)。浏览器端用它做「已读字符 / 全书字符」的
1895
+ // 实时进度——每块字符数已知,无需合成也能得到稳定的全书占比(时长做不到:
1896
+ // 全书总时长在合成本书之前是不可知的)。
1897
+ const charOffsets = new Array(chunks.length + 1)
1898
+ charOffsets[0] = 0
1899
+ for (let i = 0; i < chunks.length; i++) charOffsets[i + 1] = charOffsets[i] + chunks[i].length
1871
1900
  const upperBound = (x) => {
1872
1901
  let lo = 0, hi = cum.length - 1, ans = 0
1873
1902
  while (lo <= hi) {
@@ -1889,7 +1918,10 @@ export function apply(ctx) {
1889
1918
  startLine: s.startLine,
1890
1919
  }
1891
1920
  })
1892
- bookMetaCache[absPath] = { title: st.title, author: st.author, total: chunks.length, sections }
1921
+ bookMetaCache[absPath] = {
1922
+ title: st.title, author: st.author, total: chunks.length,
1923
+ sections, charOffsets, totalChars: charOffsets[chunks.length],
1924
+ }
1893
1925
  return bookMetaCache[absPath]
1894
1926
  }
1895
1927
 
@@ -1973,6 +2005,24 @@ export function apply(ctx) {
1973
2005
  })
1974
2006
  return
1975
2007
  }
2008
+ // 手动重扫:重新遍历当前音乐/小说目录并返回最新列表(面板「刷新」按钮调用)。
2009
+ if (pathname === '/dsh-music/rescan' && req.method === 'POST') {
2010
+ await ensureStarted()
2011
+ try {
2012
+ const r = await refresh()
2013
+ const ts = await ttsState()
2014
+ writeJson(res, {
2015
+ ok: true, root: r.root, bookRoot: r.bookRoot,
2016
+ tracks: r.tracks, books: r.books, count: r.count,
2017
+ playlists: publicPlaylists(),
2018
+ ttsConfigured: ts.configured, ttsReason: ts.reason, voices: MIMO_VOICES,
2019
+ qqLoggedIn: !!qq.cookie, qqUin: qqUin(), qqNickname: qq.nickname,
2020
+ })
2021
+ } catch (err) {
2022
+ writeJson(res, { ok: false, error: String((err && err.message) || err) }, 500)
2023
+ }
2024
+ return
2025
+ }
1976
2026
  // 播放偏好读写(volume/mode/voice/scope/panel-pos/playback/books-playback)。
1977
2027
  // GET 返回完整快照;POST 为合并语义:{ prefs: {k:v,...} } 写入,
1978
2028
  // { remove: [k,...] } 删除指定键。见 lib/index.js 的 serverPrefs 说明。
@@ -2135,6 +2185,9 @@ export function apply(ctx) {
2135
2185
  id, name: book.name, size: book.size,
2136
2186
  total: chunks.length,
2137
2187
  title: meta.title, author: meta.author, sections: meta.sections,
2188
+ // 逐块累积字符偏移 + 全书总字符:浏览器端据此算「已读字符/全书字符」
2189
+ // 的实时进度条(见 check-book progress)。
2190
+ charOffsets: meta.charOffsets, totalChars: meta.totalChars,
2138
2191
  })
2139
2192
  return
2140
2193
  }
@@ -2596,6 +2649,61 @@ export function apply(ctx) {
2596
2649
  }
2597
2650
  return
2598
2651
  }
2652
+ // ---- 本地歌曲在线歌词兜底(QQ 音乐 → LRCLIB)----
2653
+ // 客户端先请求 /lyric;本地无同名 .lrc 时再请求本端点:先复查本地(幂等),
2654
+ // 无则在线取词(QQ 匿名官方歌词含逐句翻译;LRCLIB 免费同步 LRC),按 track.path
2655
+ // LRU 缓存避免重复请求;无匹配/失败返回 hasLyric:false,播放不受影响。
2656
+ if (pathname === '/dsh-music/lyric/online' && req.method === 'GET') {
2657
+ await ensureStarted()
2658
+ const rawPath = url.searchParams.get('path') || ''
2659
+ if (rawPath === '') { writeJson(res, { ok: false, error: 'missing path' }, 400); return }
2660
+ if (!isAudioName(rawPath) || !isRegisteredAudioPath(rawPath)) { writeJson(res, { ok: false, error: 'forbidden' }, 403); return }
2661
+ // 本地同名 .lrc 优先(幂等:若已出现则直接返回本地,不浪费在线请求)。
2662
+ const lrcPath = findLrcForAudio(rawPath)
2663
+ if (lrcPath !== null) {
2664
+ try {
2665
+ const lrc = parseLrc(readBufToString(readFileSync(lrcPath)))
2666
+ writeJson(res, { ok: true, hasLyric: lrc.length > 0, source: 'local', lrc })
2667
+ } catch (err) {
2668
+ writeJson(res, { ok: false, hasLyric: true, error: String((err && err.message) || err) }, 500)
2669
+ }
2670
+ return
2671
+ }
2672
+ // 在线兜底:LRU 缓存命中直接返回(正命中 6h / 空命中 30min)。
2673
+ const cached = lyricOnlineCacheGet(rawPath)
2674
+ if (cached !== null) { writeJson(res, cached); return }
2675
+ // 搜索关键词:优先客户端给的标题(文件名去扩展名),否则从路径派生命名。
2676
+ let title = (url.searchParams.get('title') || '').trim()
2677
+ if (title === '') {
2678
+ const fn = basename(rawPath)
2679
+ const dot = fn.lastIndexOf('.')
2680
+ title = dot > 0 ? fn.slice(0, dot) : fn
2681
+ }
2682
+ const artist = (url.searchParams.get('artist') || '').trim()
2683
+ const durRaw = Number(url.searchParams.get('duration'))
2684
+ const duration = Number.isFinite(durRaw) && durRaw > 0 ? durRaw : null
2685
+ await loadQQCookie()
2686
+ let payload = { ok: true, hasLyric: false }
2687
+ try {
2688
+ const hit = await getOnlineLyric({ title, artist, duration, qqCookie: qq.cookie })
2689
+ if (hit !== null) {
2690
+ const lrc = parseLrc(hit.lrcText)
2691
+ payload = {
2692
+ ok: true, hasLyric: lrc.length > 0, source: hit.source,
2693
+ lrc, matched: hit.matched,
2694
+ ...(hit.transText !== '' ? { trans: parseLrc(hit.transText) } : {}),
2695
+ }
2696
+ }
2697
+ } catch (err) {
2698
+ payload = { ok: false, error: String((err && err.message) || err) }
2699
+ }
2700
+ // 只缓存成功响应(含空结果):错误/瞬时故障不缓存,避免之后 30 分钟都不再重试。
2701
+ if (payload.ok) {
2702
+ lyricOnlineCacheSet(rawPath, payload, payload.hasLyric ? LYRIC_ONLINE_TTL : LYRIC_ONLINE_TTL_EMPTY)
2703
+ }
2704
+ writeJson(res, payload)
2705
+ return
2706
+ }
2599
2707
  // ---- 通用文件流式路由(歌单成员专用,Range/seek)----
2600
2708
  // 只放行已登记路径(任一歌单成员 ∪ 曲库扫描集)。直接用 node:fs 读写,
2601
2709
  // 避免 DSH ctx.fs 对工作区外路径的围栏(与讲书读取小说文件的方式一致)。
package/lib/lyric.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * lib/lyric.js — 本地歌曲「在线歌词兜底」:QQ 音乐 → LRCLIB。
3
+ *
4
+ * 场景:本地音乐没有同名 .lrc 时,先用文件名(可带歌手/时长)在 QQ 匿名接口搜歌、
5
+ * 取官方歌词(含逐句翻译);QQ 无果再查 LRCLIB(免费、无需 key、返回同步 LRC)。
6
+ *
7
+ * 纯 Node(Node ≥ 20,用全局 fetch),无第三方依赖,无编译步骤。
8
+ *
9
+ * ⚠️ 合规:歌词文本/翻译版权归著作权人及对应平台所有,仅供个人学习、技术研究、
10
+ * 日常个人试听使用;严禁商业用途、公开传播、二次分发。非官方接口随时可能变更,
11
+ * 所有出网请求带超时,失败一律静默回退(保持「无歌词」的现状,不打扰播放)。
12
+ */
13
+
14
+ import * as QQ from './qq.js'
15
+
16
+ const LRCLIB_API = 'https://lrclib.net/api'
17
+ const LRCLIB_UA = 'dsh-music-player/1.0 (local lyric fallback; rate-limited+cached)'
18
+
19
+ // 出网统一超时(与 qq.js 同构):Node fetch 默认无超时,端点挂起会让播放流程卡住。
20
+ async function fetchWithTimeout(url, opts = {}, timeoutMs = 10000) {
21
+ const ctrl = new AbortController()
22
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
23
+ try {
24
+ return await fetch(url, { ...opts, signal: ctrl.signal })
25
+ } finally {
26
+ clearTimeout(timer)
27
+ }
28
+ }
29
+
30
+ // =====================================================================
31
+ // LRCLIB(https://lrclib.net)— 免费公开歌词 API,无需 key、CORS 开启、返回同步 LRC。
32
+ // 端点:GET /api/search?q= (模糊,返回数组 ≤20 条,无结果返回空数组)
33
+ // GET /api/get/<id> (按 id 取单条)
34
+ // 记录字段:id / trackName / artistName / albumName / duration / instrumental /
35
+ // plainLyrics / syncedLyrics(标准 LRC:[mm:ss.xx] 时间戳 + [ti:]/[ar:] 元数据)
36
+ // 注意:URL 查询参数必须百分号编码(直接放中文会 400)——用 URLSearchParams 构造。
37
+ // =====================================================================
38
+
39
+ // 搜索并归一化为候选数组(只保留带同步歌词的非纯音乐候选;纯音乐没有逐字歌词意义)。
40
+ export async function lrclibSearch(keyword) {
41
+ const p = new URLSearchParams({ q: String(keyword || '') })
42
+ const res = await fetchWithTimeout(`${LRCLIB_API}/search?${p.toString()}`, {
43
+ headers: { Accept: 'application/json', 'User-Agent': LRCLIB_UA },
44
+ })
45
+ if (res.status !== 200) throw new Error('lrclib search http ' + res.status)
46
+ const arr = await res.json().catch(() => null)
47
+ if (!Array.isArray(arr)) throw new Error('lrclib search payload')
48
+ return arr
49
+ .filter((r) => r && typeof r.trackName === 'string'
50
+ && typeof r.syncedLyrics === 'string' && r.syncedLyrics.trim() !== '')
51
+ .map((r) => ({
52
+ id: r.id,
53
+ title: r.trackName,
54
+ artist: r.artistName || '',
55
+ duration: Number(r.duration),
56
+ instrumental: !!r.instrumental,
57
+ synced: r.syncedLyrics,
58
+ }))
59
+ }
60
+
61
+ // =====================================================================
62
+ // 匹配打分:把候选(标题/歌手/时长/是否纯音乐)与期望值比对,返回 0..100 置信度。
63
+ // 标题为主信号(精确 60 / 包含 42 / 分词重叠),歌手为强副信号(精确 30 / 包含 20),
64
+ // 时长相近加分,纯音乐重罚。仅当分数 ≥ MIN_SCORE 才采信,避免「错配歌词」比「没有歌词」更糟。
65
+ // 导出供测试。
66
+ // =====================================================================
67
+
68
+ // 归一化用于比对:小写、去空白与常见分隔/标点/括号/引号。
69
+ export function normalizeText(s) {
70
+ return String(s || '')
71
+ .toLowerCase()
72
+ .replace(/[\s\u3000\-_/\\,.;:,。;:!?!?·'"“”‘’()()\[\]【】《》〈〉<>「」『』…—~~]+/g, '')
73
+ .trim()
74
+ }
75
+
76
+ // 轻量分词:拉丁词 + CJK 二元组(2-gram),供「包含」都不满足时做重叠匹配。
77
+ function tokenize(s) {
78
+ const out = []
79
+ for (const w of s.match(/[a-z0-9]+/g) || []) out.push(w)
80
+ const cjk = s.replace(/[a-z0-9]+/g, '')
81
+ for (let i = 0; i + 1 < cjk.length; i++) out.push(cjk.slice(i, i + 2))
82
+ return out
83
+ }
84
+
85
+ export function scoreCandidate(cand, want) {
86
+ const wt = normalizeText(want && want.title)
87
+ const wa = want && want.artist ? normalizeText(want.artist) : ''
88
+ const ct = normalizeText(cand && cand.title)
89
+ const ca = cand && cand.artist ? normalizeText(cand.artist) : ''
90
+ let score = 0
91
+ if (wt !== '' && ct !== '') {
92
+ if (ct === wt) score += 60
93
+ else if (ct.includes(wt) || wt.includes(ct)) score += 42
94
+ else {
95
+ const wts = tokenize(wt); const cts = tokenize(ct)
96
+ const inter = wts.filter((t) => cts.includes(t)).length
97
+ if (inter > 0) score += Math.min(40, inter * 12)
98
+ }
99
+ }
100
+ if (wa !== '' && ca !== '') {
101
+ if (ca === wa) score += 30
102
+ else if (ca.includes(wa) || wa.includes(ca)) score += 20
103
+ } else if (wa === '') {
104
+ score += 5 // 未知歌手:中性偏袒(不惩罚,也不确认)
105
+ }
106
+ const cd = Number(cand && cand.duration)
107
+ const wd = Number(want && want.duration)
108
+ if (Number.isFinite(cd) && cd > 0 && Number.isFinite(wd) && wd > 0) {
109
+ const d = Math.abs(cd - wd)
110
+ if (d <= 5) score += 12
111
+ else if (d <= 15) score += 6
112
+ }
113
+ if (cand && cand.instrumental) score -= 40
114
+ return score
115
+ }
116
+
117
+ // 在候选中挑最高分者;返回 { best, score }(best 可为 null)。
118
+ export function pickBest(candidates, want) {
119
+ let best = null, bestScore = -1
120
+ for (const c of candidates) {
121
+ const s = scoreCandidate(c, want)
122
+ if (s > bestScore) { bestScore = s; best = c }
123
+ }
124
+ return { best, score: bestScore }
125
+ }
126
+
127
+ // 采信阈值:标题精确(60)即接受;「包含 + 时长接近」也可达 ~54;含糊不取。
128
+ const MIN_SCORE = 52
129
+
130
+ // =====================================================================
131
+ // 在线兜底主入口:依次尝试 QQ 音乐 → LRCLIB。
132
+ // 入参 { title, artist, duration?, qqCookie? };返回统一结构或 null:
133
+ // { source: 'qq'|'lrclib', matched: {title, artist, songmid?|id?, score},
134
+ // lrcText, transText } // transText 仅 QQ(逐句翻译,可为空串)
135
+ // 两个源都失败/无好匹配时返回 null(调用方按「无歌词」处理)。
136
+ // =====================================================================
137
+ export async function getOnlineLyric({ title = '', artist = '', duration = null, qqCookie = '' } = {}) {
138
+ const want = {
139
+ title: String(title || '').trim(),
140
+ artist: String(artist || '').trim(),
141
+ duration: Number.isFinite(duration) ? duration : null,
142
+ }
143
+ if (want.title === '') return null
144
+
145
+ // 1) QQ 音乐(匿名搜索 + 官方歌词,可带逐句翻译)
146
+ try {
147
+ const kw = want.artist !== '' ? want.title + ' ' + want.artist : want.title
148
+ const s = await QQ.search(kw, qqCookie, 1)
149
+ const cands = (s.results || []).map((r) => ({
150
+ title: r.title,
151
+ artist: (r.artists || []).join('/'),
152
+ duration: Number(r.interval),
153
+ songmid: r.songmid,
154
+ instrumental: false,
155
+ }))
156
+ const { best, score } = pickBest(cands, want)
157
+ if (best && best.songmid && score >= MIN_SCORE) {
158
+ const g = await QQ.getLyric(best.songmid, qqCookie)
159
+ const lrcText = (g.lyric || '').trim()
160
+ if (lrcText !== '') {
161
+ return {
162
+ source: 'qq',
163
+ matched: { title: best.title, artist: best.artist, songmid: best.songmid, score },
164
+ lrcText,
165
+ transText: (g.trans || '').trim(),
166
+ }
167
+ }
168
+ }
169
+ } catch { /* QQ 失败/无匹配 → 尝试下一源 */ }
170
+
171
+ // 2) LRCLIB(免费、无需 key、同步 LRC)
172
+ try {
173
+ const cands = await lrclibSearch(want.title)
174
+ const { best, score } = pickBest(cands, want)
175
+ if (best && best.synced && score >= MIN_SCORE) {
176
+ return {
177
+ source: 'lrclib',
178
+ matched: { title: best.title, artist: best.artist, id: best.id, score },
179
+ lrcText: best.synced.trim(),
180
+ transText: '',
181
+ }
182
+ }
183
+ } catch { /* 失败静默 */ }
184
+
185
+ return null
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",