dsh-music-player 0.3.1 → 0.3.3

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
@@ -14,8 +14,8 @@ DeepSeek Harness 本地音乐/小说播放插件。
14
14
  - 顺序播放、单曲循环、乱序播放三种模式
15
15
  - 实时 7 段频谱可视化(解码音频包络驱动)
16
16
  - 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
17
- - 播放列表面板可自由拖动,位置跨刷新记忆
18
- - AI 讲书:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转、章节切歌,可选 4 种中文 AI 声音(默认白桦)
17
+ - 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
18
+ - AI 讲书:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转(打开即定位到当前正在播放的章节)、章节切歌,可选 4 种中文 AI 声音(默认白桦)
19
19
  - `music_play` 模型工具:agent 可按关键词播放本地音乐,也可按小说名启动 AI 讲书
20
20
  - 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
21
21
  - **自建歌单**:可新建多个歌单,从本地文件(支持多选、可跨目录)添加歌曲;播放条爱心按钮一键收藏到默认歌单「我最喜欢」;歌单作为播放来源时,顺序/乱序循环只在该歌单内进行
@@ -108,7 +108,7 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
108
108
  1. 打开播放面板,切到「小说」标签,点「选择小说目录」选定包含 `.txt` 的目录(默认与音乐目录相同)。
109
109
  2. 点击某一本小说开始朗读;也可让 agent 用 `music_play` 工具按小说名播放(如「播放《中国制造》」)。
110
110
  3. 播放条上的讲书控制:
111
- - **章节目录**(📖 按钮):自动识别全书结构(书名/前言/章节/尾声),弹出目录,点击任意章节即从该章开头朗读
111
+ - **章节目录**(📖 按钮):自动识别全书结构(书名/前言/章节/尾声),点击弹出**位于按钮正上方**的目录(自动定位到当前正在播放的章节),点击任意章节即从该章开头朗读
112
112
  - **后退 / 前进**:讲书模式下跳上一章 / 下一章(音乐模式下仍是上一首 / 下一首)
113
113
  - **AI 声音**:点音量按钮,在弹层选择声音——冰糖(女)、茉莉(女)、苏打(男)、白桦(男,默认)
114
114
  4. 刷新页面后从上次位置续读(断点续播)。
@@ -152,6 +152,9 @@ AI 语音目前仅支持 xiaomi 提供方(限时免费)。请先在 DSH 模
152
152
  **讲书播放时点后退/前进没反应?**
153
153
  讲书模式下后退/前进是跳上一章/下一章;如果当前小说没有识别出章节结构(目录按钮提示"暂无章节结构"),则无法跳章,只能整本顺序播。
154
154
 
155
+ **听书偶尔"没声音但时间还在走"?**
156
+ 这种一般是某一段的合成结果异常(返回了退化/静音音频),或瞬时合成失败。0.3.3 起 Host 端会严格校验合成音频(拒绝空数据/非 PCM 等退化 WAV)并自动重试一次瞬时失败,同时把每次合成结果记录在诊断日志里。若再遇到,可访问 `http://<DSH地址>/dsh-music/tts-logs` 查看最近 60 条合成记录(含失败原因、退化音频事件),据此定位具体是哪个块出的问题。
157
+
155
158
  **想支持更多音频格式?**
156
159
  格式支持由 Host 端 `AUDIO_TYPES` 表驱动,在 `lib/index.js` 里加扩展名与 MIME 即可(播放器本身用浏览器原生 `<audio>` 解码,最终能否播放还取决于浏览器对该编码的支持)。
157
160
 
package/lib/client.js CHANGED
@@ -101,14 +101,24 @@ window.__ModuleLoader__.load({
101
101
  }
102
102
  return best;
103
103
  }
104
- // Restore the playback-panel position ({x,y,h}) previously saved by dragging, if any.
104
+ // Playback-panel geometry: default CSS width (must match .dsh-music-panel),
105
+ // resize bounds, and the viewport-height fraction cap when user-resized.
106
+ const PANEL_W = 380;
107
+ const PANEL_MIN_W = 280;
108
+ const PANEL_MAX_W = 640;
109
+ const PANEL_MIN_H = 200;
110
+ const PANEL_MAX_H_VH = 0.8;
111
+ // Restore the playback-panel position ({x,y,w,h}) previously saved by
112
+ // dragging/resizing, if any. Old saves ({x,y,h}) migrate with a default width.
105
113
  function loadPanelPos() {
106
114
  const raw = loadPref(PREF_PANEL_POS);
107
115
  if (raw === null) return null;
108
116
  try {
109
117
  const p = JSON.parse(raw);
110
118
  if (p && typeof p.x === 'number' && typeof p.y === 'number'
111
- && typeof p.h === 'number' && p.h > 0) return p;
119
+ && typeof p.h === 'number' && p.h > 0) {
120
+ return { x: p.x, y: p.y, w: (typeof p.w === 'number' && p.w > 0) ? p.w : PANEL_W, h: p.h };
121
+ }
112
122
  } catch (e) {}
113
123
  return null;
114
124
  }
@@ -621,6 +631,7 @@ window.__ModuleLoader__.load({
621
631
  const id = currentBookId();
622
632
  if (id !== null) {
623
633
  unlockAutoplay();
634
+ bookAutoRetried = false; // manual retry resets the auto-retry budget
624
635
  set({ bookError: '', bookBuffering: true, bookBufferingSince: Date.now() });
625
636
  playBookFrom(id, bookFromRef, false);
626
637
  }
@@ -645,6 +656,9 @@ window.__ModuleLoader__.load({
645
656
  let lastPosSaveAt = 0; // throttle for the periodic playback-state save
646
657
  let restoredMusicPos = null; // restored music position to display until the audio truly reaches it
647
658
  let bookRestorePos = -1; // restored book's in-chunk position, seeked on play
659
+ // 当前块是否已自动重试过一次(瞬时 LLM 合成失败时先静音重试一次,
660
+ // 只有重试仍失败才弹错误 + 手动重试)。成功播放后复位。
661
+ let bookAutoRetried = false;
648
662
  const preAudio = new Audio();
649
663
  preAudio.preload = 'auto';
650
664
  const bookUrl = (id, from) => {
@@ -805,7 +819,7 @@ window.__ModuleLoader__.load({
805
819
  }
806
820
  }
807
821
  // `from` lets the toc jump straight to a chapter's chunk index.
808
- function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
822
+ function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookAutoRetried = false; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
809
823
  // Play a novel from its saved progress when available (e.g. switching to
810
824
  // music and back), otherwise start fresh from the beginning. Explicit
811
825
  // chapter jumps keep using playBook(id, fromChunk) and are unaffected.
@@ -831,6 +845,8 @@ window.__ModuleLoader__.load({
831
845
  if (store.currentId === null || !String(store.currentId).startsWith('book:')) return false;
832
846
  const id = String(store.currentId).slice('book:'.length);
833
847
  if (bookFromRef + 1 < bookTotal) {
848
+ // 进入新块:每个块各拥有一次自动重试的机会。
849
+ bookAutoRetried = false;
834
850
  const endedDur = Number.isFinite(audio.duration) ? audio.duration : (audio.currentTime || 0);
835
851
  if (Number.isFinite(endedDur)) bookBaseTime += endedDur;
836
852
  playBookFrom(id, bookFromRef + 1, true);
@@ -996,6 +1012,7 @@ window.__ModuleLoader__.load({
996
1012
  audio.removeAttribute('src');
997
1013
  audio.load();
998
1014
  stopBookHelper();
1015
+ bookAutoRetried = false;
999
1016
  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 });
1000
1017
  clearPref(PREF_PLAYBACK);
1001
1018
  if (stoppedBook !== null) clearBookPlayback(stoppedBook.name);
@@ -1095,6 +1112,9 @@ window.__ModuleLoader__.load({
1095
1112
  // next block until the whole book is done, then stop (never step into
1096
1113
  // the music list). Book ids are 'book:'-prefixed.
1097
1114
  if (store.currentId !== null && String(store.currentId).startsWith('book:')) {
1115
+ // 切块进行中(bookBuffering 为真,新块还没开始播放)忽略旧块的
1116
+ // ended:防止陈旧事件重复触发导致跳块/时间空走。
1117
+ if (store.bookBuffering) return;
1098
1118
  if (!maybeAdvanceBook()) stop();
1099
1119
  return;
1100
1120
  }
@@ -1111,6 +1131,15 @@ window.__ModuleLoader__.load({
1111
1131
  // clear the "合成中…" spinner and surface a real message instead of
1112
1132
  // leaving the bar stuck buffering forever.
1113
1133
  if (store.currentId !== null && String(store.currentId).startsWith('book:')) {
1134
+ // 瞬时 LLM 合成失败:先自动静音重试一次当前块(听书不中断),
1135
+ // 重试仍失败才进入错误 + 手动重试。
1136
+ if (!bookAutoRetried) {
1137
+ bookAutoRetried = true;
1138
+ const retryId = String(store.currentId).slice('book:'.length);
1139
+ unlockAutoplay();
1140
+ playBookFrom(retryId, bookFromRef, true);
1141
+ return;
1142
+ }
1114
1143
  set({ bookBuffering: false, bookBufferingSince: 0, playing: false, bookError: '\u8bb2\u4e66\u97f3\u9891\u83b7\u53d6\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5' });
1115
1144
  // Best-effort: fetch the URL to show the server's actual diagnostic
1116
1145
  // (e.g. "TTS 请求失败 401 ..."), which the <audio> error object lacks.
@@ -1228,6 +1257,13 @@ window.__ModuleLoader__.load({
1228
1257
  bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0,
1229
1258
  });
1230
1259
  bookRestorePos = pos;
1260
+ // 恢复后立即加载章节结构并计算当前章节:让播放条章节徽标与章节目录
1261
+ // 立刻显示正在播放的章节(而不是等用户点 ▶、playBookFrom 才开始加载)。
1262
+ // ensureBookToc 内部会 set bookToc;这里再补上 currentSection。
1263
+ void ensureBookToc(book.id).then((meta) => {
1264
+ if (meta === null || meta.sections.length === 0) return;
1265
+ set({ currentSection: sectionForChunk(meta.sections, from) });
1266
+ });
1231
1267
  // Refresh the chunk total in the background (the book file may have
1232
1268
  // changed since the save); fall back to the saved total on failure.
1233
1269
  const savedTotal = bookTotal;
@@ -1338,6 +1374,13 @@ window.__ModuleLoader__.load({
1338
1374
  return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
1339
1375
  React.createElement('path', { d: 'M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z' }));
1340
1376
  }
1377
+ // 讲书(AI 听书)时名称前的话筒图标,贴合「朗读/播讲」功能,
1378
+ // 与音乐的音符图标区分。
1379
+ function MicIcon(props) {
1380
+ const cls = props.className || '';
1381
+ return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
1382
+ React.createElement('path', { d: 'M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z' }));
1383
+ }
1341
1384
 
1342
1385
  // ---- components ----
1343
1386
  // Custom vertical volume slider. The native <input type=range> cannot be
@@ -1462,8 +1505,9 @@ window.__ModuleLoader__.load({
1462
1505
  onClick: () => { const t = resolvePlayable(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
1463
1506
  }, '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5');
1464
1507
  }
1465
- const note = React.createElement(MusicNote, { className: 'dsh-music-note' });
1466
1508
  const isBook = s.currentId !== null && String(s.currentId).startsWith('book:');
1509
+ // 名称前的图标:讲书用话筒图标,音乐用音符图标(空闲态无曲目 = 音乐)。
1510
+ const note = React.createElement(isBook ? MicIcon : MusicNote, { className: 'dsh-music-note' });
1467
1511
  // While a novel chunk is being synthesized, show a live "合成中… Ns"
1468
1512
  // counter (so the wait is transparent, not an endless spinner) and, on
1469
1513
  // error, the real message plus a retry button. Music keeps its old UI.
@@ -1505,14 +1549,18 @@ window.__ModuleLoader__.load({
1505
1549
  hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '\u4e0b\u4e00\u7ae0' : '\u4e0b\u4e00\u9996', onClick: () => (isBook ? stepBook(1) : step(1)) }, '\u23ed') : null,
1506
1550
  hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '\u505c\u6b62', onClick: stop }, '\u23f9') : null,
1507
1551
  // 章节目录按钮:仅讲书(book)时出现,点击弹出章节列表并可跳章。
1508
- // 与音量/播放列表按钮同款圆形样式(dsh-music-mode-trigger)。
1509
- isBook ? React.createElement('button', {
1510
- className: 'dsh-music-mode-trigger' + (s.tocOpen ? ' active' : ''),
1511
- title: '\u7ae0\u8282\u76ee\u5f55',
1512
- onClick: openToc,
1513
- }, React.createElement('svg', {
1514
- viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
1515
- }, React.createElement('path', { d: 'M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2z' }))) : null,
1552
+ // 与音量/播放模式按钮同款圆形样式(dsh-music-mode-trigger);弹层用
1553
+ // 与音量/播放模式同款的「相对容器 + 绝对定位」锚在按钮正上方。
1554
+ isBook ? React.createElement('div', { className: 'dsh-music-toc-trigger' },
1555
+ React.createElement('button', {
1556
+ className: 'dsh-music-mode-trigger' + (s.tocOpen ? ' active' : ''),
1557
+ title: '\u7ae0\u8282\u76ee\u5f55',
1558
+ onClick: openToc,
1559
+ }, React.createElement('svg', {
1560
+ viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
1561
+ }, React.createElement('path', { d: 'M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2z' }))),
1562
+ React.createElement(BookTocPanel, null),
1563
+ ) : null,
1516
1564
  // 音乐播放模式按钮:仅在音乐语境(非讲书)显示,与章节目录按钮互斥。
1517
1565
  !isBook ? React.createElement(ModeDropdown, null) : null,
1518
1566
  React.createElement('div', { className: 'dsh-music-bar-vol', ref: volRef },
@@ -1589,6 +1637,7 @@ window.__ModuleLoader__.load({
1589
1637
  function BookTocPanel() {
1590
1638
  const s = useStore();
1591
1639
  const ref = useRef(null);
1640
+ const listRef = useRef(null);
1592
1641
  useEffect(() => {
1593
1642
  if (!s.tocOpen) return;
1594
1643
  const onDown = (e) => {
@@ -1597,6 +1646,15 @@ window.__ModuleLoader__.load({
1597
1646
  document.addEventListener('mousedown', onDown);
1598
1647
  return () => document.removeEventListener('mousedown', onDown);
1599
1648
  }, [s.tocOpen]);
1649
+ // 打开章节目录时定位到正在播放的章节:把高亮的当前章节滚动进可视区,
1650
+ // 而不是从列表顶部开始。依赖 bookToc(打开时可能先为空、随后异步到达)。
1651
+ useEffect(() => {
1652
+ if (!s.tocOpen) return;
1653
+ const list = listRef.current;
1654
+ if (list === null) return;
1655
+ const active = list.querySelector('.dsh-music-toc-item.active');
1656
+ if (active !== null && typeof active.scrollIntoView === 'function') active.scrollIntoView({ block: 'nearest' });
1657
+ }, [s.tocOpen, s.bookToc]);
1600
1658
  if (!s.tocOpen) return null;
1601
1659
  if (s.currentId === null || !String(s.currentId).startsWith('book:')) return null;
1602
1660
  const id = currentBookId();
@@ -1619,11 +1677,14 @@ window.__ModuleLoader__.load({
1619
1677
  const body = (s.bookToc || []).length > 0
1620
1678
  ? rows
1621
1679
  : React.createElement('div', { className: 'dsh-music-empty' }, '\u6682\u65e0\u7ae0\u8282\u7ed3\u6784\uff08\u8be5\u4e66\u65e0\u6cd5\u8bc6\u522b\u5206\u8282\u3002\uff09');
1680
+ // 弹层用 CSS 定位在「章节目录」按钮正上方(与音量/播放模式弹窗同款:
1681
+ // 相对容器 + position:absolute + bottom:calc(100%+6px) 居中),见
1682
+ // .dsh-music-toc-trigger / .dsh-music-toc 样式。
1622
1683
  return React.createElement('div', { className: 'dsh-music-toc', ref },
1623
1684
  React.createElement('div', { className: 'dsh-music-toc-head' },
1624
1685
  React.createElement('span', { className: 'dsh-music-toc-title' }, '\u7ae0\u8282\u76ee\u5f55'),
1625
1686
  React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: closeToc }, '\u2715')),
1626
- React.createElement('div', { className: 'dsh-music-toc-list' }, body),
1687
+ React.createElement('div', { className: 'dsh-music-toc-list', ref: listRef }, body),
1627
1688
  );
1628
1689
  }
1629
1690
  function PlayerPanel() {
@@ -1631,9 +1692,11 @@ window.__ModuleLoader__.load({
1631
1692
  const isBook = s.currentId !== null && String(s.currentId).startsWith('book:');
1632
1693
  const listRef = useRef(null);
1633
1694
  const panelRef = useRef(null);
1634
- // Draggable panel position ({x, y, h} left/top/height once dragged; null = default right/bottom)
1695
+ // Draggable panel position + size ({x, y, w, h} left/top/width/height once
1696
+ // dragged or resized; null = default right/bottom, 380px, auto height).
1635
1697
  const [pos, setPos] = useState(loadPanelPos);
1636
- const dragRef = useRef(null);
1698
+ const dragRef = useRef(null); // head-drag state
1699
+ const resizeRef = useRef(null); // corner-resize state
1637
1700
  // 曲库每行「+」打开的「加入歌单」菜单:{track, x, y}(锚点=按钮右上角视口坐标)。
1638
1701
  const [addMenu, setAddMenu] = useState(null);
1639
1702
  const openAddMenu = (track, e) => {
@@ -1641,11 +1704,11 @@ window.__ModuleLoader__.load({
1641
1704
  setAddMenu({ track, x: r.right, y: r.top });
1642
1705
  };
1643
1706
 
1644
- // Once the panel is dragged we switch from CSS right/bottom anchoring to an
1645
- // explicit left/top/height. Locking the height matters: with only top+left set
1646
- // and no height, a fixed element whose CSS also sets bottom collapses to fit
1647
- // the leftover space, so the panel's height jumps while dragging.
1648
- const style = pos === null ? null : { left: pos.x, top: pos.y, height: pos.h };
1707
+ // Once the panel is dragged/resized we switch from CSS right/bottom anchoring
1708
+ // to explicit left/top/width/height. Locking height and clearing max-height
1709
+ // matters: with only top+left and the CSS max-height:72vh still applying, a
1710
+ // fixed element whose CSS also sets bottom would collapse/clamp while dragging.
1711
+ const style = pos === null ? null : { left: pos.x, top: pos.y, width: pos.w, height: pos.h, maxHeight: 'none' };
1649
1712
 
1650
1713
  const onHeadDown = (e) => {
1651
1714
  if (e.button !== undefined && e.button !== 0) return;
@@ -1654,15 +1717,16 @@ window.__ModuleLoader__.load({
1654
1717
  const el = panelRef.current;
1655
1718
  if (el === null) return;
1656
1719
  const rect = el.getBoundingClientRect();
1720
+ const w = pos !== null ? pos.w : PANEL_W;
1657
1721
  const h = pos !== null ? pos.h : rect.height;
1658
- const next = { x: pos !== null ? pos.x : rect.left, y: pos !== null ? pos.y : rect.top, h };
1722
+ const next = { x: pos !== null ? pos.x : rect.left, y: pos !== null ? pos.y : rect.top, w, h };
1659
1723
  dragRef.current = {
1660
1724
  startX: e.clientX, startY: e.clientY,
1661
- originX: next.x, originY: next.y, h,
1725
+ originX: next.x, originY: next.y, w, h,
1662
1726
  };
1663
1727
  setPos(next);
1664
1728
  savePref(PREF_PANEL_POS, JSON.stringify(next));
1665
- e.currentTarget.setPointerCapture(e.pointerId);
1729
+ if (typeof e.currentTarget.setPointerCapture === 'function') e.currentTarget.setPointerCapture(e.pointerId);
1666
1730
  };
1667
1731
  const onHeadMove = (e) => {
1668
1732
  const d = dragRef.current;
@@ -1674,13 +1738,56 @@ window.__ModuleLoader__.load({
1674
1738
  x = Math.max(0, Math.min(x, window.innerWidth - el.offsetWidth));
1675
1739
  y = Math.max(0, Math.min(y, window.innerHeight - el.offsetHeight));
1676
1740
  }
1677
- const next = { x, y, h: d.h };
1741
+ const next = { x, y, w: d.w, h: d.h };
1678
1742
  setPos(next);
1679
1743
  savePref(PREF_PANEL_POS, JSON.stringify(next));
1680
1744
  };
1681
1745
  const onHeadUp = (e) => {
1682
1746
  dragRef.current = null;
1683
- if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
1747
+ if (typeof e.currentTarget.releasePointerCapture === 'function'
1748
+ && typeof e.currentTarget.hasPointerCapture === 'function'
1749
+ && e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
1750
+ };
1751
+
1752
+ // Corner drag-to-resize: grow/shrink width & height from the bottom-right
1753
+ // handle, clamped to [min, max] and kept inside the viewport. The panel's
1754
+ // top-left (x/y) is untouched by a resize.
1755
+ const onResizeDown = (e) => {
1756
+ if (e.button !== undefined && e.button !== 0) return;
1757
+ const el = panelRef.current;
1758
+ if (el === null) return;
1759
+ const rect = el.getBoundingClientRect();
1760
+ const cur = {
1761
+ x: pos !== null ? pos.x : rect.left,
1762
+ y: pos !== null ? pos.y : rect.top,
1763
+ w: pos !== null ? pos.w : PANEL_W,
1764
+ h: pos !== null ? pos.h : rect.height,
1765
+ };
1766
+ resizeRef.current = { startX: e.clientX, startY: e.clientY, originW: cur.w, originH: cur.h };
1767
+ setPos(cur);
1768
+ savePref(PREF_PANEL_POS, JSON.stringify(cur));
1769
+ if (typeof e.currentTarget.setPointerCapture === 'function') e.currentTarget.setPointerCapture(e.pointerId);
1770
+ };
1771
+ const onResizeMove = (e) => {
1772
+ const d = resizeRef.current;
1773
+ if (d === null) return;
1774
+ const el = panelRef.current;
1775
+ const x = pos !== null ? pos.x : (el !== null ? el.getBoundingClientRect().left : 0);
1776
+ const y = pos !== null ? pos.y : (el !== null ? el.getBoundingClientRect().top : 0);
1777
+ const vw = window.innerWidth, vh = window.innerHeight;
1778
+ const maxW = Math.min(PANEL_MAX_W, Math.max(PANEL_MIN_W, vw - x));
1779
+ const maxH = Math.min(Math.floor(vh * PANEL_MAX_H_VH), Math.max(PANEL_MIN_H, vh - y));
1780
+ const w = Math.max(PANEL_MIN_W, Math.min(d.originW + (e.clientX - d.startX), maxW));
1781
+ const h = Math.max(PANEL_MIN_H, Math.min(d.originH + (e.clientY - d.startY), maxH));
1782
+ const next = { x, y, w, h };
1783
+ setPos(next);
1784
+ savePref(PREF_PANEL_POS, JSON.stringify(next));
1785
+ };
1786
+ const onResizeUp = (e) => {
1787
+ resizeRef.current = null;
1788
+ if (typeof e.currentTarget.releasePointerCapture === 'function'
1789
+ && typeof e.currentTarget.hasPointerCapture === 'function'
1790
+ && e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
1684
1791
  };
1685
1792
 
1686
1793
  useEffect(() => {
@@ -1782,7 +1889,8 @@ window.__ModuleLoader__.load({
1782
1889
  // panel (novel status shows on the playback bar instead).
1783
1890
  !isBook && s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
1784
1891
  !isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '\u626b\u63cf\u4e2d\u2026') : null,
1785
- React.createElement('div', { className: 'dsh-music-list', ref: (el) => { listRef.current = el; } }, listBody),
1892
+ React.createElement('div', { className: 'dsh-music-list', style: pos === null ? null : { maxHeight: 'none' }, ref: (el) => { listRef.current = el; } }, listBody),
1893
+ React.createElement('div', { className: 'dsh-music-resize', title: '拖动调整面板大小', onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp }),
1786
1894
  addMenu ? React.createElement(AddToPlaylistMenu, {
1787
1895
  track: addMenu.track, anchor: { x: addMenu.x, y: addMenu.y },
1788
1896
  onClose: () => setAddMenu(null),
@@ -2130,10 +2238,6 @@ window.__ModuleLoader__.load({
2130
2238
  { name: 'shell.overlay', id: 'music-player-panel', order: 20 },
2131
2239
  () => React.createElement(PlayerPanel),
2132
2240
  )), 'music-player: overlay panel');
2133
- ctx.effect(() => slots.inject('shell.overlay', () => slots.register(
2134
- { name: 'shell.overlay', id: 'music-player-book-toc', order: 19 },
2135
- () => React.createElement(BookTocPanel),
2136
- )), 'music-player: book toc panel');
2137
2241
 
2138
2242
  ctx.effect(() => () => clearInterval(intentTimer), 'music-player: intent poll stop');
2139
2243
  }
@@ -2188,6 +2292,9 @@ window.__ModuleLoader__.load({
2188
2292
  '.dsh-music-bar .dsh-music-mode-trigger svg { flex: none; }\n' +
2189
2293
  '.dsh-music-bar .dsh-music-mode-menu { align-self: center; }\n' +
2190
2294
  '.dsh-music-panel { position: fixed; right: 24px; bottom: 84px; width: 380px; max-height: 72vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; pointer-events: auto; overflow: hidden; }\n' +
2295
+ '.dsh-music-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; touch-action: none; z-index: 5; }\n' +
2296
+ '.dsh-music-resize::after { content: ""; position: absolute; right: 4px; bottom: 4px; width: 5px; height: 5px; border-right: 2px solid var(--dsw-alias-label-secondary, #8a8f98); border-bottom: 2px solid var(--dsw-alias-label-secondary, #8a8f98); opacity: 0.7; }\n' +
2297
+ '.dsh-music-resize:hover::after { opacity: 1; }\n' +
2191
2298
  '.dsh-music-panel-head { display: flex; align-items: center; gap: 6px; }\n' +
2192
2299
  '.dsh-music-tabs { display: flex; gap: 4px; }\n' +
2193
2300
  '.dsh-music-tab { flex: 1; padding: 5px 0; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; }\n' +
@@ -2235,7 +2342,10 @@ window.__ModuleLoader__.load({
2235
2342
  // 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。
2236
2343
  '.dsh-music-bar.book .dsh-music-bar-name { max-width: 24%; }\n' +
2237
2344
  '.dsh-music-bar-section { margin-left: 8px; flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 11px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); border-radius: 6px; padding: 0 6px; line-height: 16px; }\n' +
2238
- '.dsh-music-toc { position: fixed; right: 24px; bottom: 148px; width: 380px; max-height: 60vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; }\n' +
2345
+ '.dsh-music-toc-trigger { position: relative; flex: none; display: inline-flex; align-self: center; }\n' +
2346
+ // 章节目录弹层:与音量/播放模式弹窗同款定位——相对容器 + 绝对定位,
2347
+ // bottom:calc(100%+6px) 让其出现在按钮正上方,left:50% 居中。
2348
+ '.dsh-music-toc { position: absolute; left: 50%; transform: translateX(-50%); bottom: calc(100% + 6px); width: 380px; max-height: 60vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 60; }\n' +
2239
2349
  '.dsh-music-toc-head { display: flex; align-items: center; gap: 6px; }\n' +
2240
2350
  '.dsh-music-toc-title { font-weight: 600; margin-right: auto; }\n' +
2241
2351
  '.dsh-music-toc-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }\n' +
package/lib/index.js CHANGED
@@ -706,16 +706,59 @@ export function apply(ctx) {
706
706
  const data = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.audio && json.choices[0].message.audio.data
707
707
  if (typeof data !== 'string') throw new Error('TTS 响应缺少音频数据')
708
708
  const buf = Buffer.from(data, 'base64')
709
- // Verify it is actually a WAVE file so the browser <audio> can decode it;
710
- // otherwise report a precise diagnosis instead of feeding it invalid bytes.
711
- if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
712
- throw new Error('TTS 返回的不是有效的 WAV 音频(前4字节=' + (buf.length >= 4 ? buf.toString('ascii', 0, 4) : '<空>') + ')——请改用支持 wav 的模型或检查 API 返回')
713
- }
709
+ // Verify it is actually a playable WAVE file so the browser <audio> can
710
+ // decode it; otherwise report a precise diagnosis instead of feeding it
711
+ // invalid bytes. A shallow RIFF/WAVE check is NOT enough: a header-only or
712
+ // non-PCM wav passes it and the browser then "plays" silence while the
713
+ // reader's clock keeps advancing — the exact "没声音但时长还在走" symptom.
714
+ // Parse the fmt + data chunks and reject degenerate files outright.
715
+ validateWav(buf)
714
716
  // Return the MiMo audio as-is: the 24kHz source played clearly in the smoke
715
717
  // test, and our naive linear-interpolation resample to 48kHz degraded
716
718
  // intelligibility. Revisit resampling only if it becomes the confirmed cause.
717
719
  return buf
718
720
  }
721
+ // Strict WAV sanity check: refuses to serve audio the browser would decode
722
+ // but hear as silence/broken output. Throws with a precise message so the
723
+ // client surfaces it (with retry) instead of playing a silent chunk.
724
+ const validateWav = (buf) => {
725
+ if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
726
+ throw new Error('TTS 返回的不是有效的 WAV 音频(前4字节=' + (buf.length >= 4 ? buf.toString('ascii', 0, 4) : '<空>') + ')——请改用支持 wav 的模型或检查 API 返回')
727
+ }
728
+ // Walk the RIFF chunks to locate fmt + data.
729
+ let fmtFormat = -1
730
+ let fmtRate = -1
731
+ let fmtCh = -1
732
+ let fmtBits = -1
733
+ let dataSize = -1
734
+ let dataStart = -1
735
+ let off = 12
736
+ while (off + 8 <= buf.length) {
737
+ const id = buf.toString('ascii', off, off + 4)
738
+ const sz = buf.readUInt32LE(off + 4)
739
+ if (id === 'fmt ' && sz >= 16) {
740
+ fmtFormat = buf.readUInt16LE(off + 8)
741
+ fmtCh = buf.readUInt16LE(off + 10)
742
+ fmtRate = buf.readUInt32LE(off + 12)
743
+ fmtBits = buf.readUInt16LE(off + 22)
744
+ } else if (id === 'data') {
745
+ dataSize = sz
746
+ dataStart = off + 8 // payload begins right after the 8-byte chunk header
747
+ }
748
+ off += 8 + sz + (sz % 2)
749
+ if (off > buf.length) break
750
+ }
751
+ const actualData = (dataSize >= 0 && dataStart >= 0) ? Math.min(dataSize, Math.max(0, buf.length - dataStart)) : 0
752
+ const problems = []
753
+ if (fmtFormat !== 1) problems.push('非 PCM 格式(fmt=' + fmtFormat + ')')
754
+ if (!(fmtRate >= 4000 && fmtRate <= 192000)) problems.push('采样率异常(' + fmtRate + 'Hz)')
755
+ if (!(fmtCh >= 1 && fmtCh <= 8)) problems.push('声道数异常(' + fmtCh + ')')
756
+ if (dataSize < 2 || actualData < 2) problems.push('音频数据为空(data=' + dataSize + ')')
757
+ if (problems.length > 0) {
758
+ logTts({ kind: 'degenerate', detail: problems.join(',') })
759
+ throw new Error('TTS 返回的 WAV 异常(' + problems.join(',') + ')——该段无法朗读,请重试或检查 API')
760
+ }
761
+ }
719
762
 
720
763
  // In-memory synthesized-audio cache + in-flight dedup.
721
764
  // The browser requests the same chunk URL more than once per listen (the
@@ -728,11 +771,21 @@ export function apply(ctx) {
728
771
  const ttsAudioCache = new Map()
729
772
  const ttsAudioInflight = new Map()
730
773
  const MAX_TTS_CACHE_CHUNKS = 80
774
+ // Recent TTS synthesis diagnostics (successes with timing + every failure and
775
+ // every degenerate-wav rejection), served at /dsh-music/tts-logs so the user
776
+ // can confirm what a "没声音但时长还在走" episode was actually caused by.
777
+ const ttsLog = []
778
+ const MAX_TTS_LOG = 60
779
+ const logTts = (entry) => {
780
+ ttsLog.push({ ts: Date.now(), ...entry })
781
+ if (ttsLog.length > MAX_TTS_LOG) ttsLog.shift()
782
+ }
731
783
  const synthesizeCached = async (cacheKey, text, voice) => {
732
784
  const hit = ttsAudioCache.get(cacheKey)
733
785
  if (hit !== undefined) return hit
734
786
  const inFlight = ttsAudioInflight.get(cacheKey)
735
787
  if (inFlight !== undefined) return inFlight
788
+ const t0 = Date.now()
736
789
  const p = synthesize(text, voice)
737
790
  .then((buf) => {
738
791
  ttsAudioInflight.delete(cacheKey)
@@ -741,9 +794,14 @@ export function apply(ctx) {
741
794
  const oldest = ttsAudioCache.keys().next().value
742
795
  ttsAudioCache.delete(oldest)
743
796
  }
797
+ logTts({ kind: 'ok', key: cacheKey, ms: Date.now() - t0, bytes: buf.length, head: text.slice(0, 12) })
744
798
  return buf
745
799
  })
746
- .catch((err) => { ttsAudioInflight.delete(cacheKey); throw err })
800
+ .catch((err) => {
801
+ ttsAudioInflight.delete(cacheKey)
802
+ logTts({ kind: 'error', key: cacheKey, ms: Date.now() - t0, error: String((err && err.message) || err).slice(0, 160), head: text.slice(0, 12) })
803
+ throw err
804
+ })
747
805
  ttsAudioInflight.set(cacheKey, p)
748
806
  return p
749
807
  }
@@ -1069,6 +1127,12 @@ export function apply(ctx) {
1069
1127
  writeJson(res, it || null)
1070
1128
  return
1071
1129
  }
1130
+ // TTS 合成诊断日志(最近 60 条,含每次失败/退化 WAV),便于排查
1131
+ // 「没声音但时长继续走」等异常。
1132
+ if (pathname === '/dsh-music/tts-logs' && req.method === 'GET') {
1133
+ writeJson(res, { logs: ttsLog.slice() })
1134
+ return
1135
+ }
1072
1136
  // AI 讲书:一本书的元信息(总块数)或某个块的 wav。
1073
1137
  // GET /dsh-music/book/<id>/meta -> { id, name, total, size } (JSON)
1074
1138
  // GET /dsh-music/book/<id>?from=n -> chunk n as wav audio
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",