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.
Files changed (3) hide show
  1. package/lib/client.js +140 -55
  2. package/lib/index.js +238 -90
  3. 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(() => { set({ error: '浏览器拦截了自动播放,请点击一次播放按钮', pendingId: id, pendingName: track.name }); });
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
- onClick: () => {
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.url,
1838
- onClick: () => { if (active) togglePlay(); else startPlayFrom(t.id, 'library'); },
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.url,
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
- !isBook && s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
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 [curUp, setCurUp] = useState(null);
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
- curName || curPath || '家目录'),
2037
+ renderCrumbs(curCrumbs, curPath, curName, browse)),
1947
2038
  React.createElement('div', { className: 'dsh-music-picker-list' },
1948
- dirs.length > 0
1949
- ? dirs.map((d) => React.createElement('button', {
1950
- key: d.path,
1951
- className: 'dsh-music-picker-item',
1952
- title: d.path,
1953
- onClick: () => browse(d.path),
1954
- }, '📁 ' + d.name))
1955
- : React.createElement('div', { className: 'dsh-music-picker-empty' }, '本目录下无子目录,可直接选择。'),
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
- setCurUp(data.up || null);
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.url,
2061
- onClick: () => { if (active) togglePlay(); else startPlayFrom(t.id, 'playlist', pl.id); },
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: '', up: null, dirs: [], files: [] });
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 || '', up: data.up || null, dirs: data.dirs || [], files: data.files || [] });
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 }, cur.name || 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(() => set({ error: '浏览器拦截了自动播放,请在播放条点击▶解锁', pendingId: intent.id, pendingName: track.name }));
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: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
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
- '.dsh-music-picker-empty { padding: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
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({ kind: 'degenerate', detail: problems.join(',') })
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.map((s) => s.textStart).filter((n) => Number.isFinite(n) && n >= 0)
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.map((s) => s.textStart).filter((n) => Number.isFinite(n) && n >= 0)
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
- // An empty/missing path starts from the user's home directory. Only
1074
- // directories are returned; file entries are skipped.
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. Only directories are offered by the picker.
1118
- const dirs = listEntries(abs)
1119
- .filter((e) => e.isDir)
1120
- .map((e) => ({ name: e.name, path: pathJoin(abs, e.name) }))
1121
- writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs })
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.5",
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",