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 +6 -3
- package/lib/client.js +141 -31
- package/lib/index.js +70 -6
- package/package.json +1 -1
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
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
//
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
|
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
|
|
1645
|
-
// explicit left/top/height. Locking
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
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 (
|
|
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:
|
|
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
|
|
710
|
-
// otherwise report a precise diagnosis instead of feeding it
|
|
711
|
-
|
|
712
|
-
|
|
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) => {
|
|
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.
|
|
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",
|