dsh-music-player 0.3.5 → 0.3.6
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/lib/client.js +140 -55
- package/lib/index.js +238 -90
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -615,9 +615,40 @@ window.__ModuleLoader__.load({
|
|
|
615
615
|
if (store.mode !== 'shuffle') return;
|
|
616
616
|
ensureShuffleReady();
|
|
617
617
|
}
|
|
618
|
+
// play() 的失败原因五花八门:AbortError(被 pause()/load()/切歌中断)、
|
|
619
|
+
// “interrupted by ...”等都不是自动播放被拦截。这里**反向判断**:只有错误
|
|
620
|
+
// 明确是自动播放被拦截(NotAllowedError,或 Chromium 的 not-allowed 文案)才
|
|
621
|
+
// 提示“浏览器拦截了自动播放”。这样双击/连点/快速切歌产生的中断一律不会误报
|
|
622
|
+
// (本环境里中断错误未必是标准 AbortError,只过滤 AbortError 会漏网)。
|
|
623
|
+
function isAutoplayBlocked(err) {
|
|
624
|
+
try {
|
|
625
|
+
if (!err) return false;
|
|
626
|
+
const n = String(err.name || '');
|
|
627
|
+
const m = String((err && err.message) || '');
|
|
628
|
+
if (n === 'NotAllowedError') return true;
|
|
629
|
+
return /not allowed|autoplay|user (gesture|interaction|activation)|didn'?t interact|play\(\) failed/i.test(m);
|
|
630
|
+
} catch (e) { return false; }
|
|
631
|
+
}
|
|
632
|
+
// 播放被主动中断(pause/stop/切歌):用于抑制“播放失败”这类误导提示。
|
|
633
|
+
function isPlayAborted(err) {
|
|
634
|
+
try {
|
|
635
|
+
return !!err && (err.name === 'AbortError' || /abort|interrupted/i.test(String((err && err.message) || '')));
|
|
636
|
+
} catch (e) { return false; }
|
|
637
|
+
}
|
|
638
|
+
// 最近一次点击启动曲目的时刻。双击的第二次点击会落在已激活的行上;部分
|
|
639
|
+
// 浏览器/环境里那次点击的 detail 仍为 1,仅靠 detail>=2 判断不可靠,这里用
|
|
640
|
+
// 时间窗兜底:刚(600ms 内)通过点击启动的曲目被再次点击,一律视为双击的
|
|
641
|
+
// 第二次点击而忽略,避免把它当成“再点一次=暂停/重播”并触发上面的误报。
|
|
642
|
+
let lastPlayStartTs = 0;
|
|
643
|
+
function shouldIgnoreRowClick(e, isActive) {
|
|
644
|
+
if (e && e.detail >= 2) return true;
|
|
645
|
+
if (isActive && Date.now() - lastPlayStartTs < 600) return true;
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
618
648
|
function startPlay(id) {
|
|
619
649
|
const track = resolvePlayable(id);
|
|
620
650
|
if (track === null) return;
|
|
651
|
+
lastPlayStartTs = Date.now();
|
|
621
652
|
restoredMusicPos = null;
|
|
622
653
|
bookRestorePos = -1;
|
|
623
654
|
audio.src = track.url;
|
|
@@ -629,7 +660,10 @@ window.__ModuleLoader__.load({
|
|
|
629
660
|
savePlayback();
|
|
630
661
|
const promise = audio.play();
|
|
631
662
|
if (promise !== undefined && typeof promise.catch === 'function') {
|
|
632
|
-
promise.catch(() => {
|
|
663
|
+
promise.catch((err) => {
|
|
664
|
+
if (!isAutoplayBlocked(err)) return;
|
|
665
|
+
set({ error: '浏览器拦截了自动播放,请点击一次播放按钮', pendingId: id, pendingName: track.name });
|
|
666
|
+
});
|
|
633
667
|
}
|
|
634
668
|
}
|
|
635
669
|
const bookById = (id) => (store.books || []).find((b) => b.id === id) || null;
|
|
@@ -664,6 +698,12 @@ window.__ModuleLoader__.load({
|
|
|
664
698
|
// continuous book-wide time readout that never resets)
|
|
665
699
|
let bookStuckTimer = null; // single synthesis-timeout guard (see playBookFrom)
|
|
666
700
|
let lastPosSaveAt = 0; // throttle for the periodic playback-state save
|
|
701
|
+
// 单块时长上限:分块是 ≤150 字的散文,实测全书块长 10~36 秒,极端慢读也不
|
|
702
|
+
// 会超过 2 分钟。若浏览器报的 duration 远超此值,说明该块 WAV 异常(截断/
|
|
703
|
+
// 字节率错误导致时长虚高)——否则 <audio> 会「播静音」直到虚高时长走完。
|
|
704
|
+
// 注意:这只是兜底。命中时仅静音重试一次;重试后仍超长则正常播放、不报错
|
|
705
|
+
// (万一真是极慢的真实长块也不会被误杀)。主防御在 Host 的 WAV 头/静音校验。
|
|
706
|
+
const BOOK_MAX_CHUNK_SEC = 180;
|
|
667
707
|
let restoredMusicPos = null; // restored music position to display until the audio truly reaches it
|
|
668
708
|
let bookRestorePos = -1; // restored book's in-chunk position, seeked on play
|
|
669
709
|
// 当前块是否已自动重试过一次(瞬时 LLM 合成失败时先静音重试一次,
|
|
@@ -768,6 +808,9 @@ window.__ModuleLoader__.load({
|
|
|
768
808
|
async function playBookFrom(id, from, silent) {
|
|
769
809
|
const book = bookById(id);
|
|
770
810
|
if (book === null) return;
|
|
811
|
+
// 用户点击/跳章启动小说同样刷新双击时间窗(与音乐 startPlay 对齐),
|
|
812
|
+
// 保证 detail 不可靠的环境里双击小说的第二次点击也能被忽略。
|
|
813
|
+
lastPlayStartTs = Date.now();
|
|
771
814
|
restoredMusicPos = null;
|
|
772
815
|
const wasFresh = from === 0;
|
|
773
816
|
// `silent` is set for the hidden ended→next auto-advance: the switch is
|
|
@@ -903,7 +946,7 @@ window.__ModuleLoader__.load({
|
|
|
903
946
|
if (bookTotal >= 0 && bookFromRef + 1 < bookTotal) preloadBook(id, bookFromRef + 1);
|
|
904
947
|
}
|
|
905
948
|
const promise = audio.play();
|
|
906
|
-
if (promise !== undefined && typeof promise.catch === 'function') promise.catch(() => set({ error: '浏览器拦截了自动播放,请点击播放按钮' }));
|
|
949
|
+
if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' }); });
|
|
907
950
|
// Envelope decoding is deferred to play (see loadTracks) — decode the
|
|
908
951
|
// restored track lazily so its spectrum is ready once it resumes.
|
|
909
952
|
if (!String(store.currentId).startsWith('book:')) {
|
|
@@ -1112,6 +1155,19 @@ window.__ModuleLoader__.load({
|
|
|
1112
1155
|
// before metadata loads audio.duration is NaN and we'd clobber a
|
|
1113
1156
|
// restored/stored value with 0 (leaving "0:00").
|
|
1114
1157
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
|
1158
|
+
// 讲书块兜底:若浏览器报的时长远超正常范围,先静音重试一次(坏 WAV
|
|
1159
|
+
// 可能靠重试恢复)。重试后仍超长则按真实长块正常播放——Host 已做
|
|
1160
|
+
// WAV 头/静音校验,能到这里的几乎不可能是坏 WAV,因此绝不误杀。
|
|
1161
|
+
if (store.currentId !== null && String(store.currentId).startsWith('book:')
|
|
1162
|
+
&& audio.duration > BOOK_MAX_CHUNK_SEC) {
|
|
1163
|
+
if (!bookAutoRetried) {
|
|
1164
|
+
bookAutoRetried = true;
|
|
1165
|
+
const id = String(store.currentId).slice('book:'.length);
|
|
1166
|
+
unlockAutoplay();
|
|
1167
|
+
playBookFrom(id, bookFromRef, true);
|
|
1168
|
+
return; // retry re-loads; don't clobber duration yet
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1115
1171
|
set({ duration: (bookTimeBase() + audio.duration) });
|
|
1116
1172
|
}
|
|
1117
1173
|
};
|
|
@@ -1131,7 +1187,7 @@ window.__ModuleLoader__.load({
|
|
|
1131
1187
|
if (store.mode === 'single' && store.currentId !== null) {
|
|
1132
1188
|
audio.currentTime = 0;
|
|
1133
1189
|
const promise = audio.play();
|
|
1134
|
-
if (promise !== undefined && typeof promise.catch === 'function') promise.catch(() => set({ error: '播放失败', playing: false }));
|
|
1190
|
+
if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (!isPlayAborted(err)) set({ error: '播放失败', playing: false }); });
|
|
1135
1191
|
return;
|
|
1136
1192
|
}
|
|
1137
1193
|
step(1);
|
|
@@ -1379,6 +1435,30 @@ window.__ModuleLoader__.load({
|
|
|
1379
1435
|
}
|
|
1380
1436
|
return Math.round(bytes / 1024) + ' KB';
|
|
1381
1437
|
}
|
|
1438
|
+
// 目录面包屑:把绝对路径渲染成逐个可点击的目录名,点击任一段即可直接跳到
|
|
1439
|
+
// 该目录;最后一段(当前目录)高亮展示、不可点击。crumbs 为空时回退显示
|
|
1440
|
+
// 目录名/路径纯文本(例如驱动列表或家目录未配置)。
|
|
1441
|
+
function renderCrumbs(crumbs, path, name, onGo) {
|
|
1442
|
+
if (!crumbs || crumbs.length === 0) {
|
|
1443
|
+
return React.createElement('span', { className: 'dsh-music-crumb-plain' }, name || path || '家目录');
|
|
1444
|
+
}
|
|
1445
|
+
const els = [];
|
|
1446
|
+
crumbs.forEach((c, i) => {
|
|
1447
|
+
if (i > 0) els.push(React.createElement('span', { key: 'sep' + i, className: 'dsh-music-crumb-sep' }, '\u203A'));
|
|
1448
|
+
const isLast = i === crumbs.length - 1;
|
|
1449
|
+
if (isLast) {
|
|
1450
|
+
els.push(React.createElement('span', { key: 'c' + i, className: 'dsh-music-crumb cur', title: c.path }, c.name));
|
|
1451
|
+
} else {
|
|
1452
|
+
els.push(React.createElement('button', {
|
|
1453
|
+
key: 'c' + i,
|
|
1454
|
+
className: 'dsh-music-crumb',
|
|
1455
|
+
title: c.path,
|
|
1456
|
+
onClick: () => onGo(c.path),
|
|
1457
|
+
}, c.name));
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
return els;
|
|
1461
|
+
}
|
|
1382
1462
|
function MusicNote(props) {
|
|
1383
1463
|
const cls = props.className || '';
|
|
1384
1464
|
return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
|
|
@@ -1675,7 +1755,11 @@ window.__ModuleLoader__.load({
|
|
|
1675
1755
|
key: i,
|
|
1676
1756
|
className: 'dsh-music-toc-item' + (active ? ' active' : ''),
|
|
1677
1757
|
title: sec.heading,
|
|
1678
|
-
|
|
1758
|
+
// Same double-click guard as the track/book rows: the second click of a
|
|
1759
|
+
// dblclick must not re-start the chapter (which would re-synthesize the
|
|
1760
|
+
// same chunk and visibly restart it).
|
|
1761
|
+
onClick: (e) => {
|
|
1762
|
+
if (e.detail >= 2) return;
|
|
1679
1763
|
if (id !== null) playBook(id, sec.fromChunk);
|
|
1680
1764
|
closeToc();
|
|
1681
1765
|
},
|
|
@@ -1834,8 +1918,14 @@ window.__ModuleLoader__.load({
|
|
|
1834
1918
|
return React.createElement('div', { key: t.id, className: 'dsh-music-track-row' + (active ? ' active' : '') },
|
|
1835
1919
|
React.createElement('button', {
|
|
1836
1920
|
className: 'dsh-music-track' + (active ? ' active' : ''),
|
|
1837
|
-
title: t.
|
|
1838
|
-
|
|
1921
|
+
title: t.path,
|
|
1922
|
+
// A browser's double-click fires the row's click twice: the first
|
|
1923
|
+
// click starts the track, the second lands on the now-active row and
|
|
1924
|
+
// would togglePlay() it (pausing it and aborting its pending play
|
|
1925
|
+
// promise — historically misreported as an autoplay block). Ignore
|
|
1926
|
+
// the repeat click (detail >= 2, plus a time-window fallback) so a
|
|
1927
|
+
// double-click keeps playing.
|
|
1928
|
+
onClick: (e) => { if (shouldIgnoreRowClick(e, active)) return; if (active) togglePlay(); else startPlayFrom(t.id, 'library'); },
|
|
1839
1929
|
},
|
|
1840
1930
|
React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '▶ ' : '') + t.name),
|
|
1841
1931
|
React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
|
|
@@ -1853,8 +1943,8 @@ window.__ModuleLoader__.load({
|
|
|
1853
1943
|
return React.createElement('button', {
|
|
1854
1944
|
key: b.id,
|
|
1855
1945
|
className: 'dsh-music-track' + (active ? ' active' : ''),
|
|
1856
|
-
title: b.
|
|
1857
|
-
onClick: () => { if (active) togglePlay(); else resumeOrPlayBook(b.id); },
|
|
1946
|
+
title: b.path || b.name,
|
|
1947
|
+
onClick: (e) => { if (shouldIgnoreRowClick(e, active)) return; if (active) togglePlay(); else resumeOrPlayBook(b.id); },
|
|
1858
1948
|
},
|
|
1859
1949
|
React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '▶ ' : '') + b.name),
|
|
1860
1950
|
React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(b.size)),
|
|
@@ -1905,7 +1995,8 @@ window.__ModuleLoader__.load({
|
|
|
1905
1995
|
s.tab === 'music' ? musicSubTabs : null,
|
|
1906
1996
|
// While a novel is playing, keep music-only errors/scanning out of the
|
|
1907
1997
|
// panel (novel status shows on the playback bar instead).
|
|
1908
|
-
|
|
1998
|
+
// 音乐/小说统一在主列表区上方显示 error(设置块不再重复/分模式显示)。
|
|
1999
|
+
s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
|
|
1909
2000
|
!isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '扫描中…') : null,
|
|
1910
2001
|
React.createElement('div', { className: 'dsh-music-list', style: pos === null ? null : { maxHeight: 'none' }, ref: (el) => { listRef.current = el; } }, listBody),
|
|
1911
2002
|
React.createElement('div', { className: 'dsh-music-resize', title: '拖动调整面板大小', onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp }),
|
|
@@ -1921,9 +2012,10 @@ window.__ModuleLoader__.load({
|
|
|
1921
2012
|
const s = useStore();
|
|
1922
2013
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
1923
2014
|
const [dirs, setDirs] = useState([]);
|
|
2015
|
+
const [files, setFiles] = useState([]);
|
|
1924
2016
|
const [curPath, setCurPath] = useState('');
|
|
1925
2017
|
const [curName, setCurName] = useState('');
|
|
1926
|
-
const [
|
|
2018
|
+
const [curCrumbs, setCurCrumbs] = useState([]);
|
|
1927
2019
|
const [dirError, setDirError] = useState(null);
|
|
1928
2020
|
const isBook = s.tab === 'book';
|
|
1929
2021
|
const activeRoot = isBook ? s.bookRoot : s.root;
|
|
@@ -1936,27 +2028,29 @@ window.__ModuleLoader__.load({
|
|
|
1936
2028
|
React.createElement('span', { className: 'dsh-music-settings-cur', title: activeRoot || '' },
|
|
1937
2029
|
'📁 ' + (activeRoot || '未配置')),
|
|
1938
2030
|
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle)),
|
|
1939
|
-
s.error ? React.createElement('p', { className: 'dsh-music-error' }, s.error) : null,
|
|
1940
2031
|
React.createElement('p', { className: 'dsh-music-hint' }, hint),
|
|
1941
2032
|
pickerOpen ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
|
|
1942
2033
|
React.createElement('div', { className: 'dsh-music-picker' },
|
|
1943
2034
|
React.createElement('div', { className: 'dsh-music-picker-head' },
|
|
1944
2035
|
React.createElement('span', { className: 'dsh-music-picker-title' }, pickerTitle)),
|
|
1945
2036
|
React.createElement('div', { className: 'dsh-music-picker-cur', title: curPath },
|
|
1946
|
-
|
|
2037
|
+
renderCrumbs(curCrumbs, curPath, curName, browse)),
|
|
1947
2038
|
React.createElement('div', { className: 'dsh-music-picker-list' },
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2039
|
+
// 目录排在前(可点击进入),文件排在后(仅作展示,不响应点击)。
|
|
2040
|
+
dirs.map((d) => React.createElement('button', {
|
|
2041
|
+
key: d.path,
|
|
2042
|
+
className: 'dsh-music-picker-item',
|
|
2043
|
+
title: d.path,
|
|
2044
|
+
onClick: () => browse(d.path),
|
|
2045
|
+
}, '📁 ' + d.name)),
|
|
2046
|
+
files.map((f) => React.createElement('span', {
|
|
2047
|
+
key: f.path,
|
|
2048
|
+
className: 'dsh-music-picker-item file',
|
|
2049
|
+
title: f.path,
|
|
2050
|
+
}, '📄 ' + f.name)),
|
|
1956
2051
|
dirError ? React.createElement('div', { className: 'dsh-music-error' }, dirError) : null,
|
|
1957
2052
|
),
|
|
1958
2053
|
React.createElement('div', { className: 'dsh-music-picker-foot' },
|
|
1959
|
-
React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => goUp() }, '返回上级'),
|
|
1960
2054
|
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => pickCurrent() }, '选择此目录'),
|
|
1961
2055
|
React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => setPickerOpen(false) }, '取消'),
|
|
1962
2056
|
),
|
|
@@ -1977,26 +2071,13 @@ window.__ModuleLoader__.load({
|
|
|
1977
2071
|
if (data && data.error) { setDirError(data.error); return; }
|
|
1978
2072
|
setCurPath(data.path || '');
|
|
1979
2073
|
setCurName(data.name || '');
|
|
1980
|
-
|
|
2074
|
+
setCurCrumbs(data.crumbs || []);
|
|
1981
2075
|
setDirs(data.dirs || []);
|
|
2076
|
+
setFiles(data.files || []);
|
|
1982
2077
|
} catch (err) {
|
|
1983
2078
|
setDirError('读取目录失败:' + String((err && err.message) || err));
|
|
1984
2079
|
}
|
|
1985
2080
|
}
|
|
1986
|
-
function goUp() {
|
|
1987
|
-
// Prefer the parent path computed by the host (correct separators per OS).
|
|
1988
|
-
// At a drive root the host reports the "__drives__" sentinel, so "up"
|
|
1989
|
-
// jumps to the drive list and lets the user switch disks.
|
|
1990
|
-
if (curUp === '__drives__') { browse('__drives__'); return; }
|
|
1991
|
-
if (curUp !== null && curUp !== undefined && curUp !== '') { browse(curUp); return; }
|
|
1992
|
-
// fallback: derive the parent locally when the host omitted `up`.
|
|
1993
|
-
// Handle both "\" and "/" so Windows paths never dead-end (the old
|
|
1994
|
-
// POSIX-only parse did nothing on backslash paths like C:\Users\x).
|
|
1995
|
-
if (curPath === '' || curPath === '/' || /^[A-Za-z]:[\\/]?$/.test(curPath)) return;
|
|
1996
|
-
const idx = Math.max(curPath.lastIndexOf('/'), curPath.lastIndexOf('\\'));
|
|
1997
|
-
if (idx <= 0) return;
|
|
1998
|
-
browse(curPath.slice(0, idx));
|
|
1999
|
-
}
|
|
2000
2081
|
function pickCurrent() {
|
|
2001
2082
|
const p = curPath;
|
|
2002
2083
|
// The drive-list view ("__drives__") is not a real directory.
|
|
@@ -2057,8 +2138,10 @@ window.__ModuleLoader__.load({
|
|
|
2057
2138
|
return React.createElement('div', { key: t.id, className: 'dsh-music-playlist-row' + (active ? ' active' : '') },
|
|
2058
2139
|
React.createElement('button', {
|
|
2059
2140
|
className: 'dsh-music-track',
|
|
2060
|
-
title: t.
|
|
2061
|
-
|
|
2141
|
+
title: t.path,
|
|
2142
|
+
// Same double-click guard as the library rows: the second click of a
|
|
2143
|
+
// dblclick must not togglePlay() (pause) the just-started track.
|
|
2144
|
+
onClick: (e) => { if (shouldIgnoreRowClick(e, active)) return; if (active) togglePlay(); else startPlayFrom(t.id, 'playlist', pl.id); },
|
|
2062
2145
|
},
|
|
2063
2146
|
React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '▶ ' : '') + (idx + 1) + '. ' + t.name),
|
|
2064
2147
|
React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
|
|
@@ -2082,7 +2165,7 @@ window.__ModuleLoader__.load({
|
|
|
2082
2165
|
}
|
|
2083
2166
|
// 文件系统多选器:浏览目录 + 勾选音频文件,用于歌单「添加歌曲」。
|
|
2084
2167
|
function FilePicker({ pl, onClose }) {
|
|
2085
|
-
const [cur, setCur] = useState({ path: '', name: '',
|
|
2168
|
+
const [cur, setCur] = useState({ path: '', name: '', dirs: [], files: [], crumbs: [] });
|
|
2086
2169
|
const [sel, setSel] = useState(new Set());
|
|
2087
2170
|
const [err, setErr] = useState(null);
|
|
2088
2171
|
const [busy, setBusy] = useState(false);
|
|
@@ -2091,7 +2174,7 @@ window.__ModuleLoader__.load({
|
|
|
2091
2174
|
try {
|
|
2092
2175
|
const data = await jsonGet('/dsh-music/files?path=' + encodeURIComponent(p || ''));
|
|
2093
2176
|
if (data && data.error) { setErr(data.error); return; }
|
|
2094
|
-
setCur({ path: data.path || '', name: data.name || '',
|
|
2177
|
+
setCur({ path: data.path || '', name: data.name || '', dirs: data.dirs || [], files: data.files || [], crumbs: data.crumbs || [] });
|
|
2095
2178
|
} catch (e) { setErr('读取目录失败:' + String((e && e.message) || e)); }
|
|
2096
2179
|
};
|
|
2097
2180
|
// 默认定位到音乐目录(store.root),未配置时回退家目录。
|
|
@@ -2112,7 +2195,8 @@ window.__ModuleLoader__.load({
|
|
|
2112
2195
|
React.createElement('div', { className: 'dsh-music-picker-head' },
|
|
2113
2196
|
React.createElement('span', { className: 'dsh-music-picker-title' }, '添加歌曲到「' + pl.name + '」'),
|
|
2114
2197
|
),
|
|
2115
|
-
React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path },
|
|
2198
|
+
React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path },
|
|
2199
|
+
renderCrumbs(cur.crumbs, cur.path, cur.name, browse)),
|
|
2116
2200
|
React.createElement('div', { className: 'dsh-music-picker-list' },
|
|
2117
2201
|
(cur.dirs || []).map((d) => React.createElement('button', {
|
|
2118
2202
|
key: d.path, className: 'dsh-music-picker-item', title: d.path,
|
|
@@ -2134,21 +2218,11 @@ window.__ModuleLoader__.load({
|
|
|
2134
2218
|
err ? React.createElement('div', { className: 'dsh-music-error' }, err) : null,
|
|
2135
2219
|
),
|
|
2136
2220
|
React.createElement('div', { className: 'dsh-music-picker-foot' },
|
|
2137
|
-
React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => goUp() }, '上一级'),
|
|
2138
2221
|
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: confirmAdd, disabled: busy }, '确定添加(' + sel.size + ')'),
|
|
2139
2222
|
React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: onClose }, '取消'),
|
|
2140
2223
|
),
|
|
2141
2224
|
),
|
|
2142
2225
|
));
|
|
2143
|
-
function goUp() {
|
|
2144
|
-
const u = cur.up;
|
|
2145
|
-
if (u === '__drives__') { browse('__drives__'); return; }
|
|
2146
|
-
if (u) { browse(u); return; }
|
|
2147
|
-
if (cur.path === '' || cur.path === '/' || /^[A-Za-z]:[\\/]?$/.test(cur.path)) return;
|
|
2148
|
-
const idx = Math.max(cur.path.lastIndexOf('/'), cur.path.lastIndexOf('\\'));
|
|
2149
|
-
if (idx <= 0) return;
|
|
2150
|
-
browse(cur.path.slice(0, idx));
|
|
2151
|
-
}
|
|
2152
2226
|
}
|
|
2153
2227
|
|
|
2154
2228
|
const inject = ['slots'];
|
|
@@ -2203,7 +2277,7 @@ window.__ModuleLoader__.load({
|
|
|
2203
2277
|
if (action === 'pause') { audio.pause(); set({ playing: false }); return; }
|
|
2204
2278
|
if (action === 'resume') {
|
|
2205
2279
|
const p = audio.play();
|
|
2206
|
-
if (p !== undefined && typeof p.catch === 'function') p.catch(() => set({ error: '播放失败' }));
|
|
2280
|
+
if (p !== undefined && typeof p.catch === 'function') p.catch((err) => { if (!isPlayAborted(err)) set({ error: '播放失败' }); });
|
|
2207
2281
|
return;
|
|
2208
2282
|
}
|
|
2209
2283
|
if (action === 'stop') { stop(); return; }
|
|
@@ -2242,7 +2316,10 @@ window.__ModuleLoader__.load({
|
|
|
2242
2316
|
savePlayback();
|
|
2243
2317
|
const promise = audio.play();
|
|
2244
2318
|
if (promise !== undefined && typeof promise.catch === 'function') {
|
|
2245
|
-
promise.catch(() =>
|
|
2319
|
+
promise.catch((err) => {
|
|
2320
|
+
if (!isAutoplayBlocked(err)) return;
|
|
2321
|
+
set({ error: '浏览器拦截了自动播放,请在播放条点击▶解锁', pendingId: intent.id, pendingName: track.name });
|
|
2322
|
+
});
|
|
2246
2323
|
}
|
|
2247
2324
|
}
|
|
2248
2325
|
}).catch(() => {});
|
|
@@ -2350,11 +2427,19 @@ window.__ModuleLoader__.load({
|
|
|
2350
2427
|
'.dsh-music-picker { box-sizing: border-box; width: 88%; max-width: 640px; max-height: 100%; margin: auto; 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; color: var(--dsw-alias-label-primary, #e6e6e6); }\n' +
|
|
2351
2428
|
'.dsh-music-picker-head { display: flex; align-items: center; flex: none; }\n' +
|
|
2352
2429
|
'.dsh-music-picker-title { font-weight: 600; }\n' +
|
|
2353
|
-
'.dsh-music-picker-cur { flex: none; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); overflow:
|
|
2430
|
+
'.dsh-music-picker-cur { flex: none; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); white-space: nowrap; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px; }\n' +
|
|
2431
|
+
'.dsh-music-picker-cur::-webkit-scrollbar { height: 4px; }\n' +
|
|
2432
|
+
'.dsh-music-picker-cur::-webkit-scrollbar-thumb { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.14)); border-radius: 4px; }\n' +
|
|
2433
|
+
'.dsh-music-crumb { display: inline-block; padding: 1px 4px; border: none; background: transparent; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; border-radius: 4px; }\n' +
|
|
2434
|
+
'.dsh-music-crumb:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.08)); color: var(--dsw-alias-label-primary, #e6e6e6); }\n' +
|
|
2435
|
+
'.dsh-music-crumb.cur { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 600; cursor: default; }\n' +
|
|
2436
|
+
'.dsh-music-crumb-sep { margin: 0 2px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
2437
|
+
'.dsh-music-crumb-plain { color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
2354
2438
|
'.dsh-music-picker-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }\n' +
|
|
2355
2439
|
'.dsh-music-picker-item { text-align: left; padding: 6px 8px; border: none; background: transparent; border-radius: 6px; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 13px; }\n' +
|
|
2356
2440
|
'.dsh-music-picker-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
|
|
2357
|
-
|
|
2441
|
+
// 文件条目:仅作展示,不可点击(无 hover 高亮,光标为默认)。
|
|
2442
|
+
'.dsh-music-picker-item.file { color: var(--dsw-alias-label-secondary, #8a8f98); cursor: default; }\n' +
|
|
2358
2443
|
'.dsh-music-picker-foot { display: flex; gap: 8px; justify-content: flex-end; }\n' +
|
|
2359
2444
|
'.dsh-music-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
2360
2445
|
// 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。
|
package/lib/index.js
CHANGED
|
@@ -277,6 +277,154 @@ export function parseBookStructure(text, filenameHint = '') {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// ---- book chunking into TTS blocks ----
|
|
281
|
+
// Maximum length of a "clean, short" chapter heading that gets its own
|
|
282
|
+
// dedicated TTS chunk. Real headings are a handful of characters (e.g.
|
|
283
|
+
// "第一章 闪电划过星空"); anything longer is almost certainly an inline heading
|
|
284
|
+
// that has already swallowed the following body text (parseBookStructure
|
|
285
|
+
// classifies whole lines), so we refuse to isolate it — isolating would only
|
|
286
|
+
// move the merged body into a "heading" chunk. Keep the old merge behaviour
|
|
287
|
+
// for those instead.
|
|
288
|
+
const MAX_HEADING_CHARS = 30
|
|
289
|
+
|
|
290
|
+
// Locate where a heading ends inside a sentence segment, using two bounds and
|
|
291
|
+
// taking the smaller one:
|
|
292
|
+
// 1) the end of the heading's own line (next '\n') — exact when the heading
|
|
293
|
+
// sits on its own line (the normal case);
|
|
294
|
+
// 2) the end of an elastic-whitespace match of the heading string against the
|
|
295
|
+
// source — guards against inline headings so a short heading never
|
|
296
|
+
// swallows the rest of a long paragraph.
|
|
297
|
+
// `heading` is the cleaned heading (WPS codes / full-width spaces already
|
|
298
|
+
// stripped by structClassifyLine), so source whitespace runs are treated
|
|
299
|
+
// elastically.
|
|
300
|
+
function headingEndInSegment(segText, from, heading) {
|
|
301
|
+
// Bound 1: end of the heading's line in the raw segment (newlines preserved).
|
|
302
|
+
const nl = segText.indexOf('\n', from)
|
|
303
|
+
const lineEnd = nl === -1 ? segText.length : nl
|
|
304
|
+
// Bound 2: elastic whitespace match of the heading string.
|
|
305
|
+
let i = from
|
|
306
|
+
let j = 0
|
|
307
|
+
while (j < heading.length) {
|
|
308
|
+
const hc = heading[j]
|
|
309
|
+
if (/\s/.test(hc)) { j++; continue } // heading whitespace: skip (any run)
|
|
310
|
+
while (i < segText.length && /\s/.test(segText[i])) i++ // source whitespace: skip
|
|
311
|
+
if (i >= segText.length || segText[i] !== hc) break // mismatch → heading ends here
|
|
312
|
+
i++
|
|
313
|
+
j++
|
|
314
|
+
}
|
|
315
|
+
// Skip a trailing whitespace run so "标题 " does not keep its padding.
|
|
316
|
+
while (i < segText.length && /\s/.test(segText[i])) i++
|
|
317
|
+
return Math.min(lineEnd, i)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Split prose into natural chunks (<= MAX_TTS_CHARS each). Sentences are
|
|
322
|
+
* accumulated up to the cap (so each block is a few sentences of speech),
|
|
323
|
+
* only closing a block when the next sentence would overflow. Paragraph
|
|
324
|
+
* newlines are folded into whitespace. A single over-long sentence becomes
|
|
325
|
+
* its own block (hard-capped).
|
|
326
|
+
*
|
|
327
|
+
* Optional `breaks` = sorted list of section headings, each
|
|
328
|
+
* { start, text } where `start` is the heading's char offset in `text` and
|
|
329
|
+
* `text` is the heading string (s.heading from parseBookStructure). A break is
|
|
330
|
+
* applied at sub-segment precision: text before the break stays in the previous
|
|
331
|
+
* chunk, and the heading itself opens a NEW chunk — so a chapter jump starts
|
|
332
|
+
* exactly at the heading even when a divider page ("《书名》作者") shares the
|
|
333
|
+
* sentence segment with it.
|
|
334
|
+
*
|
|
335
|
+
* A clean short heading (< = MAX_HEADING_CHARS) gets its own dedicated chunk so
|
|
336
|
+
* the TTS reads the chapter title alone with a natural pause before the body;
|
|
337
|
+
* long/inline-polluted headings fall back to the old merge (title + body in the
|
|
338
|
+
* same chunk) so we never turn a whole paragraph into a "heading" block.
|
|
339
|
+
*
|
|
340
|
+
* Exported for tests (same pattern as parseBookStructure).
|
|
341
|
+
* Returns { chunks, fromChunkOfBreak } where fromChunkOfBreak[i] is the chunk
|
|
342
|
+
* index opened by breaks[i] (undefined if that break opened no chunk).
|
|
343
|
+
*/
|
|
344
|
+
export function splitBookChunks(text, breaks = null) {
|
|
345
|
+
const chunks = []
|
|
346
|
+
const fromChunkOfBreak = []
|
|
347
|
+
// Sentence segments with their original char offsets in `text`.
|
|
348
|
+
const segs = []
|
|
349
|
+
let segStart = 0
|
|
350
|
+
for (let i = 0; i < text.length; i++) {
|
|
351
|
+
if ('。!?;…'.indexOf(text[i]) !== -1) {
|
|
352
|
+
segs.push({ s: text.slice(segStart, i + 1), start: segStart })
|
|
353
|
+
segStart = i + 1
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (segStart < text.length) segs.push({ s: text.slice(segStart), start: segStart })
|
|
357
|
+
let cur = ''
|
|
358
|
+
let curOpener = -1 // break index that opened the chunk being accumulated
|
|
359
|
+
let bi = 0
|
|
360
|
+
const push = () => {
|
|
361
|
+
if (cur.trim().length > 0) {
|
|
362
|
+
if (curOpener >= 0) fromChunkOfBreak[curOpener] = chunks.length
|
|
363
|
+
chunks.push(cur.trim())
|
|
364
|
+
}
|
|
365
|
+
cur = ''
|
|
366
|
+
curOpener = -1
|
|
367
|
+
}
|
|
368
|
+
const addSentence = (rawSentence) => {
|
|
369
|
+
const sentence = rawSentence.replace(/\s*\n+\s*/g, ' ').trim()
|
|
370
|
+
if (sentence.length === 0) return
|
|
371
|
+
if (cur.length > 0 && cur.length + sentence.length > MAX_TTS_CHARS) push()
|
|
372
|
+
// A single sentence longer than the cap must be split itself.
|
|
373
|
+
if (sentence.length > MAX_TTS_CHARS) {
|
|
374
|
+
if (cur.length > 0) push()
|
|
375
|
+
for (let i = 0; i < sentence.length; i += MAX_TTS_CHARS) {
|
|
376
|
+
if (curOpener >= 0) fromChunkOfBreak[curOpener] = chunks.length
|
|
377
|
+
chunks.push(sentence.slice(i, i + MAX_TTS_CHARS))
|
|
378
|
+
curOpener = -1
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
cur += sentence
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const seg of segs) {
|
|
385
|
+
if (breaks && breaks.length > 0) {
|
|
386
|
+
while (bi < breaks.length && breaks[bi].start < seg.start) bi++
|
|
387
|
+
if (bi < breaks.length && breaks[bi].start < seg.start + seg.s.length) {
|
|
388
|
+
const off = breaks[bi].start - seg.start
|
|
389
|
+
if (off > 0) addSentence(seg.s.slice(0, off))
|
|
390
|
+
push()
|
|
391
|
+
const headingText = breaks[bi].text
|
|
392
|
+
// A clean short heading gets its own dedicated chunk so the TTS reads
|
|
393
|
+
// "第一章 闪电划过星空" alone with a natural pause before the body.
|
|
394
|
+
// Long headings are almost certainly inline-polluted (the parser has
|
|
395
|
+
// already merged the body into the heading string) — keep them merged.
|
|
396
|
+
const cleanShort = typeof headingText === 'string' && headingText.length > 0 && headingText.length <= MAX_HEADING_CHARS
|
|
397
|
+
if (cleanShort) {
|
|
398
|
+
const hEnd = headingEndInSegment(seg.s, off, headingText)
|
|
399
|
+
const headingPiece = seg.s.slice(off, hEnd).trim()
|
|
400
|
+
if (headingPiece.length > 0 && hEnd > off) {
|
|
401
|
+
curOpener = bi // the heading chunk opens this section
|
|
402
|
+
addSentence(headingPiece)
|
|
403
|
+
push() // records fromChunkOfBreak[bi] = heading chunk index
|
|
404
|
+
addSentence(seg.s.slice(hEnd)) // body continues in a fresh chunk
|
|
405
|
+
} else {
|
|
406
|
+
// Could not match the heading in the source — fall back to the
|
|
407
|
+
// original behaviour (heading + body share a chunk).
|
|
408
|
+
curOpener = bi
|
|
409
|
+
addSentence(seg.s.slice(off))
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
curOpener = bi
|
|
413
|
+
addSentence(seg.s.slice(off))
|
|
414
|
+
}
|
|
415
|
+
bi++
|
|
416
|
+
// swallow any further breaks inside this same segment (rare)
|
|
417
|
+
while (bi < breaks.length && breaks[bi].start < seg.start + seg.s.length) bi++
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
addSentence(seg.s)
|
|
422
|
+
}
|
|
423
|
+
push()
|
|
424
|
+
if (chunks.length === 0) chunks.push(text.trim())
|
|
425
|
+
return { chunks, fromChunkOfBreak }
|
|
426
|
+
}
|
|
427
|
+
|
|
280
428
|
export const name = 'dsh-music-player'
|
|
281
429
|
export const inject = ['webServer', 'fs', 'shell', 'tools', 'systemPrompt', 'llm']
|
|
282
430
|
|
|
@@ -355,7 +503,7 @@ export function apply(ctx) {
|
|
|
355
503
|
id: t.id, name: t.name, url: t.url, size: t.size, ext: t.ext, path: t.path,
|
|
356
504
|
}))
|
|
357
505
|
const publicBooks = () => books.map((b) => ({
|
|
358
|
-
id: b.id, name: b.name, size: b.size, url: '/dsh-music/book/' + b.id,
|
|
506
|
+
id: b.id, name: b.name, size: b.size, url: '/dsh-music/book/' + b.id, path: b.path,
|
|
359
507
|
}))
|
|
360
508
|
|
|
361
509
|
// ---- 自建歌单(playlists)----
|
|
@@ -733,6 +881,7 @@ export function apply(ctx) {
|
|
|
733
881
|
let fmtRate = -1
|
|
734
882
|
let fmtCh = -1
|
|
735
883
|
let fmtBits = -1
|
|
884
|
+
let fmtByteRate = -1
|
|
736
885
|
let dataSize = -1
|
|
737
886
|
let dataStart = -1
|
|
738
887
|
let off = 12
|
|
@@ -743,6 +892,7 @@ export function apply(ctx) {
|
|
|
743
892
|
fmtFormat = buf.readUInt16LE(off + 8)
|
|
744
893
|
fmtCh = buf.readUInt16LE(off + 10)
|
|
745
894
|
fmtRate = buf.readUInt32LE(off + 12)
|
|
895
|
+
fmtByteRate = buf.readUInt32LE(off + 16)
|
|
746
896
|
fmtBits = buf.readUInt16LE(off + 22)
|
|
747
897
|
} else if (id === 'data') {
|
|
748
898
|
dataSize = sz
|
|
@@ -752,13 +902,45 @@ export function apply(ctx) {
|
|
|
752
902
|
if (off > buf.length) break
|
|
753
903
|
}
|
|
754
904
|
const actualData = (dataSize >= 0 && dataStart >= 0) ? Math.min(dataSize, Math.max(0, buf.length - dataStart)) : 0
|
|
905
|
+
const available = (dataStart >= 0) ? Math.max(0, buf.length - dataStart) : 0
|
|
755
906
|
const problems = []
|
|
756
907
|
if (fmtFormat !== 1) problems.push('非 PCM 格式(fmt=' + fmtFormat + ')')
|
|
757
908
|
if (!(fmtRate >= 4000 && fmtRate <= 192000)) problems.push('采样率异常(' + fmtRate + 'Hz)')
|
|
758
909
|
if (!(fmtCh >= 1 && fmtCh <= 8)) problems.push('声道数异常(' + fmtCh + ')')
|
|
910
|
+
if (!(fmtBits === 8 || fmtBits === 16 || fmtBits === 24 || fmtBits === 32)) problems.push('位深异常(' + fmtBits + ')')
|
|
911
|
+
// Byte rate must be consistent with the header, else the browser computes a
|
|
912
|
+
// wildly wrong duration (duration = dataSize / byteRate) and "plays" silence
|
|
913
|
+
// for the inflated remainder — the "没声音但时长差 3 分钟" symptom.
|
|
914
|
+
if (fmtBits % 8 === 0 && fmtByteRate > 0 && fmtByteRate !== fmtRate * fmtCh * (fmtBits / 8)) {
|
|
915
|
+
problems.push('字节率异常(byteRate=' + fmtByteRate + ',应为' + (fmtRate * fmtCh * (fmtBits / 8)) + ')')
|
|
916
|
+
}
|
|
759
917
|
if (dataSize < 2 || actualData < 2) problems.push('音频数据为空(data=' + dataSize + ')')
|
|
918
|
+
// Truncated data chunk: declared length bigger than what's actually in the
|
|
919
|
+
// buffer — same inflated-duration/silence symptom in the browser.
|
|
920
|
+
if (dataSize >= 0 && dataStart >= 0 && dataSize > available) {
|
|
921
|
+
problems.push('音频数据被截断(声明=' + dataSize + ',实际=' + available + ')')
|
|
922
|
+
}
|
|
923
|
+
// Sample-level silence detection: reject wavs whose PCM content is
|
|
924
|
+
// effectively silent (all samples near zero). Real speech peaks at 1e4~3e4
|
|
925
|
+
// (16-bit full scale 32767); genuine silence sits at ~0, so a low threshold
|
|
926
|
+
// never rejects real speech. Early-exit on the first audible sample, so real
|
|
927
|
+
// chunks are scanned almost instantly (only silent wavs scan the whole data).
|
|
928
|
+
if (fmtBits === 16 && actualData >= 2 && !problems.some((p) => p.startsWith('音频数据为空') || p.startsWith('音频数据被截断'))) {
|
|
929
|
+
const n = Math.floor(actualData / 2)
|
|
930
|
+
let peak = 0
|
|
931
|
+
for (let i = 0; i < n; i++) {
|
|
932
|
+
const a = Math.abs(buf.readInt16LE(dataStart + i * 2))
|
|
933
|
+
if (a > peak) peak = a
|
|
934
|
+
if (peak > 200) break
|
|
935
|
+
}
|
|
936
|
+
if (peak <= 200) problems.push('音频内容静音(峰值=' + peak + ')')
|
|
937
|
+
}
|
|
760
938
|
if (problems.length > 0) {
|
|
761
|
-
logTts({
|
|
939
|
+
logTts({
|
|
940
|
+
kind: 'degenerate',
|
|
941
|
+
detail: problems.join(','),
|
|
942
|
+
wav: { rate: fmtRate, ch: fmtCh, bits: fmtBits, byteRate: fmtByteRate, declared: dataSize, actual: available, bytes: buf.length },
|
|
943
|
+
})
|
|
762
944
|
throw new Error('TTS 返回的 WAV 异常(' + problems.join(',') + ')——该段无法朗读,请重试或检查 API')
|
|
763
945
|
}
|
|
764
946
|
}
|
|
@@ -844,80 +1026,6 @@ export function apply(ctx) {
|
|
|
844
1026
|
return text
|
|
845
1027
|
}
|
|
846
1028
|
|
|
847
|
-
// Split prose into natural chunks (<= MAX_TTS_CHARS each). Sentences are
|
|
848
|
-
// accumulated up to the cap (so each block is a few sentences of speech),
|
|
849
|
-
// only closing a block when the next sentence would overflow. Paragraph
|
|
850
|
-
// newlines are folded into whitespace. A single over-long sentence becomes
|
|
851
|
-
// its own block (hard-capped).
|
|
852
|
-
// Optional `breaks` = sorted char offsets in `text` where a NEW chunk must
|
|
853
|
-
// start. A break is applied at sub-segment precision: text before the break
|
|
854
|
-
// stays in the previous chunk, text from the break (the chapter heading)
|
|
855
|
-
// opens a fresh chunk — so a chapter jump starts exactly at the heading even
|
|
856
|
-
// when a divider page ("《书名》作者") shares the sentence segment with it.
|
|
857
|
-
// Returns { chunks, fromChunkOfBreak } where fromChunkOfBreak[i] is the chunk
|
|
858
|
-
// index opened by breaks[i] (undefined if that break opened no chunk).
|
|
859
|
-
const splitBookChunks = (text, breaks = null) => {
|
|
860
|
-
const chunks = []
|
|
861
|
-
const fromChunkOfBreak = []
|
|
862
|
-
// Sentence segments with their original char offsets in `text`.
|
|
863
|
-
const segs = []
|
|
864
|
-
let segStart = 0
|
|
865
|
-
for (let i = 0; i < text.length; i++) {
|
|
866
|
-
if ('。!?;…'.indexOf(text[i]) !== -1) {
|
|
867
|
-
segs.push({ s: text.slice(segStart, i + 1), start: segStart })
|
|
868
|
-
segStart = i + 1
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
if (segStart < text.length) segs.push({ s: text.slice(segStart), start: segStart })
|
|
872
|
-
let cur = ''
|
|
873
|
-
let curOpener = -1 // break index that opened the chunk being accumulated
|
|
874
|
-
let bi = 0
|
|
875
|
-
const push = () => {
|
|
876
|
-
if (cur.trim().length > 0) {
|
|
877
|
-
if (curOpener >= 0) fromChunkOfBreak[curOpener] = chunks.length
|
|
878
|
-
chunks.push(cur.trim())
|
|
879
|
-
}
|
|
880
|
-
cur = ''
|
|
881
|
-
curOpener = -1
|
|
882
|
-
}
|
|
883
|
-
const addSentence = (rawSentence) => {
|
|
884
|
-
const sentence = rawSentence.replace(/\s*\n+\s*/g, ' ').trim()
|
|
885
|
-
if (sentence.length === 0) return
|
|
886
|
-
if (cur.length > 0 && cur.length + sentence.length > MAX_TTS_CHARS) push()
|
|
887
|
-
// A single sentence longer than the cap must be split itself.
|
|
888
|
-
if (sentence.length > MAX_TTS_CHARS) {
|
|
889
|
-
if (cur.length > 0) push()
|
|
890
|
-
for (let i = 0; i < sentence.length; i += MAX_TTS_CHARS) {
|
|
891
|
-
if (curOpener >= 0) fromChunkOfBreak[curOpener] = chunks.length
|
|
892
|
-
chunks.push(sentence.slice(i, i + MAX_TTS_CHARS))
|
|
893
|
-
curOpener = -1
|
|
894
|
-
}
|
|
895
|
-
} else {
|
|
896
|
-
cur += sentence
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
for (const seg of segs) {
|
|
900
|
-
if (breaks && breaks.length > 0) {
|
|
901
|
-
while (bi < breaks.length && breaks[bi] < seg.start) bi++
|
|
902
|
-
if (bi < breaks.length && breaks[bi] < seg.start + seg.s.length) {
|
|
903
|
-
const off = breaks[bi] - seg.start
|
|
904
|
-
if (off > 0) addSentence(seg.s.slice(0, off))
|
|
905
|
-
push()
|
|
906
|
-
curOpener = bi
|
|
907
|
-
bi++
|
|
908
|
-
addSentence(seg.s.slice(off))
|
|
909
|
-
// swallow any further breaks inside this same segment (rare)
|
|
910
|
-
while (bi < breaks.length && breaks[bi] < seg.start + seg.s.length) bi++
|
|
911
|
-
continue
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
addSentence(seg.s)
|
|
915
|
-
}
|
|
916
|
-
push()
|
|
917
|
-
if (chunks.length === 0) chunks.push(text.trim())
|
|
918
|
-
return { chunks, fromChunkOfBreak }
|
|
919
|
-
}
|
|
920
|
-
|
|
921
1029
|
// Load a book's text and split into chunks; cache per path to avoid re-reading
|
|
922
1030
|
// the file on every block request. Chunks are aligned so each section heading
|
|
923
1031
|
// starts a new chunk (structure-aware), which makes chapter jumps exact.
|
|
@@ -929,7 +1037,9 @@ export function apply(ctx) {
|
|
|
929
1037
|
const st = bookStructCache[absPath] !== undefined
|
|
930
1038
|
? bookStructCache[absPath]
|
|
931
1039
|
: (bookStructCache[absPath] = parseBookStructure(text, filenameHint || basename(absPath)))
|
|
932
|
-
const breaks = st.sections
|
|
1040
|
+
const breaks = st.sections
|
|
1041
|
+
.filter((s) => Number.isFinite(s.textStart) && s.textStart >= 0)
|
|
1042
|
+
.map((s) => ({ start: s.textStart, text: s.heading }))
|
|
933
1043
|
bookChunksCache[absPath] = splitBookChunks(text, breaks).chunks
|
|
934
1044
|
return bookChunksCache[absPath]
|
|
935
1045
|
}
|
|
@@ -947,7 +1057,9 @@ export function apply(ctx) {
|
|
|
947
1057
|
const st = bookStructCache[absPath] !== undefined
|
|
948
1058
|
? bookStructCache[absPath]
|
|
949
1059
|
: (bookStructCache[absPath] = parseBookStructure(text, filenameHint))
|
|
950
|
-
const breaks = st.sections
|
|
1060
|
+
const breaks = st.sections
|
|
1061
|
+
.filter((s) => Number.isFinite(s.textStart) && s.textStart >= 0)
|
|
1062
|
+
.map((s) => ({ start: s.textStart, text: s.heading }))
|
|
951
1063
|
const { chunks, fromChunkOfBreak } = splitBookChunks(text, breaks)
|
|
952
1064
|
const cum = []
|
|
953
1065
|
let acc = 0
|
|
@@ -1000,6 +1112,35 @@ export function apply(ctx) {
|
|
|
1000
1112
|
return out
|
|
1001
1113
|
}
|
|
1002
1114
|
|
|
1115
|
+
// Breadcrumb segments for an absolute native path, ordered from the root down
|
|
1116
|
+
// to the deepest component. Each crumb carries its cumulative path so the
|
|
1117
|
+
// browser picker can jump straight to any ancestor directory (and, at the
|
|
1118
|
+
// root crumb, back to the filesystem root). The root itself (e.g. "/" or
|
|
1119
|
+
// "C:\") is the leading crumb. The sentinel drive-list view has no real path,
|
|
1120
|
+
// so it yields no crumbs.
|
|
1121
|
+
const buildCrumbs = (abs) => {
|
|
1122
|
+
if (!abs || abs === '__drives__') return []
|
|
1123
|
+
const parsed = pathParse(abs)
|
|
1124
|
+
const root = parsed.root || ''
|
|
1125
|
+
const crumbs = []
|
|
1126
|
+
if (root) crumbs.push({ name: root, path: root })
|
|
1127
|
+
const parts = []
|
|
1128
|
+
let d = parsed.dir
|
|
1129
|
+
if (d && d !== root) {
|
|
1130
|
+
while (d && d !== root) { parts.unshift(basename(d)); d = dirname(d) }
|
|
1131
|
+
}
|
|
1132
|
+
let cur = root
|
|
1133
|
+
for (const p of parts) {
|
|
1134
|
+
cur = cur === '' ? p : pathJoin(cur, p)
|
|
1135
|
+
crumbs.push({ name: p, path: cur })
|
|
1136
|
+
}
|
|
1137
|
+
if (parsed.base && parsed.base !== root) {
|
|
1138
|
+
cur = cur === '' ? parsed.base : pathJoin(cur, parsed.base)
|
|
1139
|
+
crumbs.push({ name: parsed.base, path: cur })
|
|
1140
|
+
}
|
|
1141
|
+
return crumbs
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1003
1144
|
// ---- shared HTTP helpers ----
|
|
1004
1145
|
const writeJson = (res, value, status) => {
|
|
1005
1146
|
res.writeHead(status || 200, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -1070,8 +1211,11 @@ export function apply(ctx) {
|
|
|
1070
1211
|
}
|
|
1071
1212
|
// List the immediate subdirectories of a library-visible path, for the
|
|
1072
1213
|
// browser directory picker used by the playback-list "选择音乐目录" button.
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1214
|
+
// List the immediate subdirectories AND files of a library-visible path,
|
|
1215
|
+
// for the browser directory picker used by the playback-list
|
|
1216
|
+
// "选择音乐目录" button. Directories come first (their entries are
|
|
1217
|
+
// browsable); file entries are informational and not navigable. An
|
|
1218
|
+
// empty/missing path starts from the user's home directory.
|
|
1075
1219
|
if (pathname === '/dsh-music/dir' && req.method === 'GET') {
|
|
1076
1220
|
await ensureStarted()
|
|
1077
1221
|
const raw = url.searchParams.get('path') || ''
|
|
@@ -1088,9 +1232,9 @@ export function apply(ctx) {
|
|
|
1088
1232
|
const root = letter + ':\\'
|
|
1089
1233
|
try { if (existsSync(root)) roots.push({ name: root, path: root }) } catch {}
|
|
1090
1234
|
}
|
|
1091
|
-
writeJson(res, { path: '__drives__', name: '本机磁盘', up: null, dirs: roots })
|
|
1235
|
+
writeJson(res, { path: '__drives__', name: '本机磁盘', up: null, dirs: roots, files: [], crumbs: [] })
|
|
1092
1236
|
} else {
|
|
1093
|
-
writeJson(res, { path: '/', name: '/', up: null, dirs: [] })
|
|
1237
|
+
writeJson(res, { path: '/', name: '/', up: null, dirs: [], files: [], crumbs: buildCrumbs('/') })
|
|
1094
1238
|
}
|
|
1095
1239
|
return
|
|
1096
1240
|
}
|
|
@@ -1114,11 +1258,15 @@ export function apply(ctx) {
|
|
|
1114
1258
|
: dirname(abs)
|
|
1115
1259
|
// Tolerant listing (see listEntries): skip unreadable entries so
|
|
1116
1260
|
// drive roots like C:\ still show their normal folders instead of an
|
|
1117
|
-
// empty list.
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1261
|
+
// empty list. Directories are offered by the picker (browsable);
|
|
1262
|
+
// plain files are listed after them purely as context (not navigable).
|
|
1263
|
+
const dirs = []
|
|
1264
|
+
const files = []
|
|
1265
|
+
for (const e of listEntries(abs)) {
|
|
1266
|
+
const item = { name: e.name, path: pathJoin(abs, e.name) }
|
|
1267
|
+
if (e.isDir) dirs.push(item); else files.push(item)
|
|
1268
|
+
}
|
|
1269
|
+
writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs, files, crumbs: buildCrumbs(abs) })
|
|
1122
1270
|
} catch (err) {
|
|
1123
1271
|
writeJson(res, { error: String((err && err.message) || err) }, 500)
|
|
1124
1272
|
}
|
|
@@ -1309,9 +1457,9 @@ export function apply(ctx) {
|
|
|
1309
1457
|
const root = letter + ':\\'
|
|
1310
1458
|
try { if (existsSync(root)) roots.push({ name: root, path: root }) } catch {}
|
|
1311
1459
|
}
|
|
1312
|
-
writeJson(res, { path: '__drives__', name: '本机磁盘', up: null, dirs: roots, files: [] })
|
|
1460
|
+
writeJson(res, { path: '__drives__', name: '本机磁盘', up: null, dirs: roots, files: [], crumbs: [] })
|
|
1313
1461
|
} else {
|
|
1314
|
-
writeJson(res, { path: '/', name: '/', up: null, dirs: [], files: [] })
|
|
1462
|
+
writeJson(res, { path: '/', name: '/', up: null, dirs: [], files: [], crumbs: buildCrumbs('/') })
|
|
1315
1463
|
}
|
|
1316
1464
|
return
|
|
1317
1465
|
}
|
|
@@ -1339,7 +1487,7 @@ export function apply(ctx) {
|
|
|
1339
1487
|
}
|
|
1340
1488
|
} catch { /* skip unreadable entries */ }
|
|
1341
1489
|
}
|
|
1342
|
-
writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs, files })
|
|
1490
|
+
writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs, files, crumbs: buildCrumbs(abs) })
|
|
1343
1491
|
} catch (err) {
|
|
1344
1492
|
writeJson(res, { error: String((err && err.message) || err) }, 500)
|
|
1345
1493
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-music-player",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|