dsh-music-player 0.3.4 → 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 +243 -140
  2. package/lib/index.js +244 -96
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -19,9 +19,19 @@ window.__ModuleLoader__.load({
19
19
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
20
20
 
21
21
  const React = require('react');
22
+ const ReactDOM = require('react-dom');
22
23
  const useState = React.useState;
23
24
  const useEffect = React.useEffect;
24
25
  const useRef = React.useRef;
26
+ // Directory/file pickers are rendered into the panel DOM, but the panel's
27
+ // initial height is small (empty track list => only ~200px + 60px), which
28
+ // would clamp the picker and show just a few directory rows. Portal the
29
+ // overlay to <body> (position: fixed; inset: 0) so it spans the whole DSH
30
+ // window instead of the panel, regardless of the panel size.
31
+ const createPortal = (ReactDOM && typeof ReactDOM.createPortal === 'function')
32
+ ? (node, container) => ReactDOM.createPortal(node, container)
33
+ : (node) => node; // defensive fallback (react-dom is always provided by DSH)
34
+ const portalToBody = (node) => createPortal(node, document.body);
25
35
 
26
36
  // This host/environment throws a harmless, unhandled rejection from
27
37
  // Chromium's media pipeline — "Cannot read properties of undefined (reading
@@ -605,9 +615,40 @@ window.__ModuleLoader__.load({
605
615
  if (store.mode !== 'shuffle') return;
606
616
  ensureShuffleReady();
607
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
+ }
608
648
  function startPlay(id) {
609
649
  const track = resolvePlayable(id);
610
650
  if (track === null) return;
651
+ lastPlayStartTs = Date.now();
611
652
  restoredMusicPos = null;
612
653
  bookRestorePos = -1;
613
654
  audio.src = track.url;
@@ -619,7 +660,10 @@ window.__ModuleLoader__.load({
619
660
  savePlayback();
620
661
  const promise = audio.play();
621
662
  if (promise !== undefined && typeof promise.catch === 'function') {
622
- promise.catch(() => { set({ error: '\u6d4f\u89c8\u5668\u62e6\u622a\u4e86\u81ea\u52a8\u64ad\u653e\uff0c\u8bf7\u70b9\u51fb\u4e00\u6b21\u64ad\u653e\u6309\u94ae', pendingId: id, pendingName: track.name }); });
663
+ promise.catch((err) => {
664
+ if (!isAutoplayBlocked(err)) return;
665
+ set({ error: '浏览器拦截了自动播放,请点击一次播放按钮', pendingId: id, pendingName: track.name });
666
+ });
623
667
  }
624
668
  }
625
669
  const bookById = (id) => (store.books || []).find((b) => b.id === id) || null;
@@ -654,6 +698,12 @@ window.__ModuleLoader__.load({
654
698
  // continuous book-wide time readout that never resets)
655
699
  let bookStuckTimer = null; // single synthesis-timeout guard (see playBookFrom)
656
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;
657
707
  let restoredMusicPos = null; // restored music position to display until the audio truly reaches it
658
708
  let bookRestorePos = -1; // restored book's in-chunk position, seeked on play
659
709
  // 当前块是否已自动重试过一次(瞬时 LLM 合成失败时先静音重试一次,
@@ -706,9 +756,9 @@ window.__ModuleLoader__.load({
706
756
  }
707
757
  // Label a section type for display in the toc (chapter/分部/前言/后记/分节).
708
758
  const sectionTypeLabel = (t) => ({
709
- chapter: '\u7ae0\u8282', part: '\u5206\u90e8', preface: '\u524d\u8a00',
710
- epilogue: '\u540e\u8bb0', named: '\u5206\u8282', toc: '\u76ee\u5f55',
711
- })[t] || '\u6b63\u6587';
759
+ chapter: '章节', part: '分部', preface: '前言',
760
+ epilogue: '后记', named: '分节', toc: '目录',
761
+ })[t] || '正文';
712
762
  // Heading of the section that contains the given chunk index.
713
763
  function sectionForChunk(sections, chunk) {
714
764
  if (!Array.isArray(sections) || sections.length === 0) return '';
@@ -758,6 +808,9 @@ window.__ModuleLoader__.load({
758
808
  async function playBookFrom(id, from, silent) {
759
809
  const book = bookById(id);
760
810
  if (book === null) return;
811
+ // 用户点击/跳章启动小说同样刷新双击时间窗(与音乐 startPlay 对齐),
812
+ // 保证 detail 不可靠的环境里双击小说的第二次点击也能被忽略。
813
+ lastPlayStartTs = Date.now();
761
814
  restoredMusicPos = null;
762
815
  const wasFresh = from === 0;
763
816
  // `silent` is set for the hidden ended→next auto-advance: the switch is
@@ -893,7 +946,7 @@ window.__ModuleLoader__.load({
893
946
  if (bookTotal >= 0 && bookFromRef + 1 < bookTotal) preloadBook(id, bookFromRef + 1);
894
947
  }
895
948
  const promise = audio.play();
896
- if (promise !== undefined && typeof promise.catch === 'function') promise.catch(() => set({ error: '\u6d4f\u89c8\u5668\u62e6\u622a\u4e86\u81ea\u52a8\u64ad\u653e\uff0c\u8bf7\u70b9\u51fb\u64ad\u653e\u6309\u94ae' }));
949
+ if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' }); });
897
950
  // Envelope decoding is deferred to play (see loadTracks) — decode the
898
951
  // restored track lazily so its spectrum is ready once it resumes.
899
952
  if (!String(store.currentId).startsWith('book:')) {
@@ -997,7 +1050,7 @@ window.__ModuleLoader__.load({
997
1050
  })
998
1051
  .catch((err) => {
999
1052
  if (mySeq !== voiceSwitchSeq) return;
1000
- set({ voiceSwitching: false, voice: prevVoice, bookError: '\u58f0\u97f3\u5207\u6362\u5931\u8d25\uff1a' + String((err && err.message) || err) });
1053
+ set({ voiceSwitching: false, voice: prevVoice, bookError: '声音切换失败:' + String((err && err.message) || err) });
1001
1054
  });
1002
1055
  }
1003
1056
  function stop() {
@@ -1102,6 +1155,19 @@ window.__ModuleLoader__.load({
1102
1155
  // before metadata loads audio.duration is NaN and we'd clobber a
1103
1156
  // restored/stored value with 0 (leaving "0:00").
1104
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
+ }
1105
1171
  set({ duration: (bookTimeBase() + audio.duration) });
1106
1172
  }
1107
1173
  };
@@ -1121,7 +1187,7 @@ window.__ModuleLoader__.load({
1121
1187
  if (store.mode === 'single' && store.currentId !== null) {
1122
1188
  audio.currentTime = 0;
1123
1189
  const promise = audio.play();
1124
- if (promise !== undefined && typeof promise.catch === 'function') promise.catch(() => set({ error: '\u64ad\u653e\u5931\u8d25', playing: false }));
1190
+ if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (!isPlayAborted(err)) set({ error: '播放失败', playing: false }); });
1125
1191
  return;
1126
1192
  }
1127
1193
  step(1);
@@ -1140,7 +1206,7 @@ window.__ModuleLoader__.load({
1140
1206
  playBookFrom(retryId, bookFromRef, true);
1141
1207
  return;
1142
1208
  }
1143
- set({ bookBuffering: false, bookBufferingSince: 0, playing: false, bookError: '\u8bb2\u4e66\u97f3\u9891\u83b7\u53d6\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5' });
1209
+ set({ bookBuffering: false, bookBufferingSince: 0, playing: false, bookError: '讲书音频获取失败,请重试' });
1144
1210
  // Best-effort: fetch the URL to show the server's actual diagnostic
1145
1211
  // (e.g. "TTS 请求失败 401 ..."), which the <audio> error object lacks.
1146
1212
  const id = String(store.currentId).slice('book:'.length);
@@ -1154,7 +1220,7 @@ window.__ModuleLoader__.load({
1154
1220
  }).catch(() => {});
1155
1221
  }
1156
1222
  } else {
1157
- set({ error: '\u97f3\u9891\u52a0\u8f7d\u6216\u89e3\u7801\u5931\u8d25', playing: false });
1223
+ set({ error: '音频加载或解码失败', playing: false });
1158
1224
  }
1159
1225
  };
1160
1226
  audio.addEventListener('timeupdate', onTime);
@@ -1321,7 +1387,7 @@ window.__ModuleLoader__.load({
1321
1387
  restoreScope(result.playlists || []);
1322
1388
  restoreLatest(list);
1323
1389
  } catch (err) {
1324
- set({ loading: false, error: '\u65e0\u6cd5\u8bfb\u53d6\u97f3\u4e50\u5e93\uff1a' + String((err && err.message) || err) });
1390
+ set({ loading: false, error: '无法读取音乐库:' + String((err && err.message) || err) });
1325
1391
  }
1326
1392
  }
1327
1393
  function saveRoot(path, kind) {
@@ -1341,10 +1407,10 @@ window.__ModuleLoader__.load({
1341
1407
  });
1342
1408
  restoreLatest(result.tracks || []);
1343
1409
  } else {
1344
- set({ loading: false, error: (result && result.error) || '\u8bbe\u7f6e\u76ee\u5f55\u5931\u8d25' });
1410
+ set({ loading: false, error: (result && result.error) || '设置目录失败' });
1345
1411
  }
1346
1412
  }).catch((err) => {
1347
- set({ loading: false, error: '\u8bbe\u7f6e\u76ee\u5f55\u5931\u8d25\uff1a' + String((err && err.message) || err) });
1413
+ set({ loading: false, error: '设置目录失败:' + String((err && err.message) || err) });
1348
1414
  });
1349
1415
  }
1350
1416
 
@@ -1369,6 +1435,30 @@ window.__ModuleLoader__.load({
1369
1435
  }
1370
1436
  return Math.round(bytes / 1024) + ' KB';
1371
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
+ }
1372
1462
  function MusicNote(props) {
1373
1463
  const cls = props.className || '';
1374
1464
  return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
@@ -1419,7 +1509,7 @@ window.__ModuleLoader__.load({
1419
1509
  return React.createElement('div',
1420
1510
  { className: 'dsh-music-vol-slider', ref: trackRef,
1421
1511
  onPointerDown, onPointerMove, onPointerUp,
1422
- title: '\u97f3\u91cf ' + pct + '%' },
1512
+ title: '音量 ' + pct + '%' },
1423
1513
  React.createElement('div', { className: 'dsh-music-vol-track' }),
1424
1514
  React.createElement('div', { className: 'dsh-music-vol-fill', style: { height: pct + '%' } }),
1425
1515
  React.createElement('div', { className: 'dsh-music-vol-thumb', style: { bottom: 'calc(' + pct + '% - 7px)' } }),
@@ -1427,30 +1517,30 @@ window.__ModuleLoader__.load({
1427
1517
  }
1428
1518
  // Fallback voice list if /manifest hasn't delivered one (older host / offline).
1429
1519
  const FALLBACK_VOICES = [
1430
- { id: '\u51b0\u7cd6', label: '\u51b0\u7cd6', gender: '\u5973', lang: '\u4e2d\u6587' },
1431
- { id: '\u8309\u8389', label: '\u8309\u8389', gender: '\u5973', lang: '\u4e2d\u6587' },
1432
- { id: '\u82cf\u6253', label: '\u82cf\u6253', gender: '\u7537', lang: '\u4e2d\u6587' },
1433
- { id: '\u767d\u6866', label: '\u767d\u6866', gender: '\u7537', lang: '\u4e2d\u6587' },
1520
+ { id: '冰糖', label: '冰糖', gender: '', lang: '中文' },
1521
+ { id: '茉莉', label: '茉莉', gender: '', lang: '中文' },
1522
+ { id: '苏打', label: '苏打', gender: '', lang: '中文' },
1523
+ { id: '白桦', label: '白桦', gender: '', lang: '中文' },
1434
1524
  ];
1435
1525
  // AI 讲书 voice picker, shown in the volume popup only while reading a book.
1436
1526
  function VoicePicker() {
1437
1527
  const s = useStore();
1438
1528
  const voices = (s.voices && s.voices.length > 0) ? s.voices : FALLBACK_VOICES;
1439
1529
  const cur = voices.find((v) => v.id === s.voice);
1440
- const currentLabel = cur ? (cur.label + (cur.gender && cur.gender !== '\u81ea\u52a8' ? '\uff08' + cur.gender + '\uff09' : '')) : s.voice;
1530
+ const currentLabel = cur ? (cur.label + (cur.gender && cur.gender !== '自动' ? '' + cur.gender + '' : '')) : s.voice;
1441
1531
  return React.createElement('div', { className: 'dsh-music-voice' },
1442
- React.createElement('span', { className: 'dsh-music-voice-label' }, 'AI \u58f0\u97f3'),
1532
+ React.createElement('span', { className: 'dsh-music-voice-label' }, 'AI 声音'),
1443
1533
  React.createElement('select', {
1444
1534
  className: 'dsh-music-voice-select',
1445
- value: voices.some((v) => v.id === s.voice) ? s.voice : '\u767d\u6866',
1446
- title: '\u5f53\u524d\uff1a' + currentLabel,
1535
+ value: voices.some((v) => v.id === s.voice) ? s.voice : '白桦',
1536
+ title: '当前:' + currentLabel,
1447
1537
  onChange: (e) => setVoice(e.target.value),
1448
1538
  },
1449
1539
  voices.map((v) => React.createElement('option', {
1450
1540
  key: v.id, value: v.id,
1451
- }, (v.label || v.id) + (v.lang ? '\u00b7' + v.lang : '') + (v.gender && v.gender !== '\u81ea\u52a8' ? '\uff08' + v.gender + '\uff09' : ''))),
1541
+ }, (v.label || v.id) + (v.lang ? '·' + v.lang : '') + (v.gender && v.gender !== '自动' ? '' + v.gender + '' : ''))),
1452
1542
  ),
1453
- s.voiceSwitching ? React.createElement('span', { className: 'dsh-music-voice-switching' }, '\u5207\u6362\u4e2d\u2026') : null,
1543
+ s.voiceSwitching ? React.createElement('span', { className: 'dsh-music-voice-switching' }, '切换中…') : null,
1454
1544
  );
1455
1545
  }
1456
1546
  // Novel status shown after the title on the now-playing bar: a live
@@ -1470,16 +1560,16 @@ window.__ModuleLoader__.load({
1470
1560
  const secs = s.bookBufferingSince > 0 ? Math.floor((now - s.bookBufferingSince) / 1000) : 0;
1471
1561
  return React.createElement('span', { className: 'dsh-music-bar-buffering' },
1472
1562
  React.createElement('span', { className: 'dsh-music-spinner' }),
1473
- ' AI \u5408\u6210\u4e2d\u2026 ' + secs + 's');
1563
+ ' AI 合成中… ' + secs + 's');
1474
1564
  }
1475
1565
  if (s.bookError) {
1476
1566
  return React.createElement('span', { className: 'dsh-music-bar-berr', title: s.bookError },
1477
1567
  React.createElement('span', { className: 'dsh-music-bar-berr-text' }, s.bookError),
1478
1568
  React.createElement('button', {
1479
1569
  className: 'dsh-music-bar-btn retry',
1480
- title: '\u91cd\u65b0\u5408\u6210\u5f53\u524d\u6bb5\u843d',
1570
+ title: '重新合成当前段落',
1481
1571
  onClick: retryBook,
1482
- }, '\u91cd\u8bd5'));
1572
+ }, '重试'));
1483
1573
  }
1484
1574
  return null;
1485
1575
  }
@@ -1501,9 +1591,9 @@ window.__ModuleLoader__.load({
1501
1591
  if (hasTrack && s.vizState === 'unavailable') {
1502
1592
  vizBadge = React.createElement('button', {
1503
1593
  className: 'dsh-music-bar-warn',
1504
- title: '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5',
1594
+ title: '频谱不可用,点击重试',
1505
1595
  onClick: () => { const t = resolvePlayable(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
1506
- }, '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5');
1596
+ }, '频谱不可用,点击重试');
1507
1597
  }
1508
1598
  const isBook = s.currentId !== null && String(s.currentId).startsWith('book:');
1509
1599
  // 名称前的图标:讲书用话筒图标,音乐用音符图标(空闲态无曲目 = 音乐)。
@@ -1517,7 +1607,7 @@ window.__ModuleLoader__.load({
1517
1607
  let sectionBadge = null;
1518
1608
  if (isBook && s.currentSection) {
1519
1609
  sectionBadge = React.createElement('span', { className: 'dsh-music-bar-section', title: s.currentSection },
1520
- '\u25b8 ' + s.currentSection);
1610
+ ' ' + s.currentSection);
1521
1611
  }
1522
1612
  // 自建歌单:收藏爱心按钮(收藏时用主题色)。
1523
1613
  const faved = hasTrack && !isBook && isCurrentFaved();
@@ -1533,28 +1623,28 @@ window.__ModuleLoader__.load({
1533
1623
  React.createElement('div', { className: 'dsh-music-bar' + (isBook ? ' book' : '') },
1534
1624
  hasTrack
1535
1625
  ? React.createElement('span', { className: 'dsh-music-bar-name', title: name }, note, ' ', name, afterName)
1536
- : React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' \u672c\u5730\u97f3\u4e50\u64ad\u653e\u5668'),
1626
+ : React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' 本地音乐播放器'),
1537
1627
  // 章节名独立占一整行、完整显示(不再被省略号截断)。
1538
1628
  sectionBadge,
1539
1629
  !isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 64, height: 14, ref: (el) => { barCanvasNode = el; } }) : null,
1540
1630
  vizBadge,
1541
1631
  hasTrack
1542
1632
  ? (showHint
1543
- ? React.createElement('span', { className: 'dsh-music-bar-hint' }, '\u26a0 \u81ea\u52a8\u64ad\u653e\u88ab\u62e6\u622a\uff0c\u70b9\u51fb\u25b6\u89e3\u9501')
1633
+ ? React.createElement('span', { className: 'dsh-music-bar-hint' }, ' 自动播放被拦截,点击▶解锁')
1544
1634
  : React.createElement('span', { className: 'dsh-music-bar-time' }, fmtTime(s.position) + ' / ' + fmtTime(s.duration)))
1545
1635
  : null,
1546
1636
  heartBtn,
1547
- hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '\u4e0a\u4e00\u7ae0' : '\u4e0a\u4e00\u9996', onClick: () => (isBook ? stepBook(-1) : step(-1)) }, '\u23ee') : null,
1548
- hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '\u64ad\u653e/\u6682\u505c', onClick: togglePlay }, s.playing ? '\u23f8' : '\u25b6') : null,
1549
- hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '\u4e0b\u4e00\u7ae0' : '\u4e0b\u4e00\u9996', onClick: () => (isBook ? stepBook(1) : step(1)) }, '\u23ed') : null,
1550
- hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '\u505c\u6b62', onClick: stop }, '\u23f9') : null,
1637
+ hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '上一章' : '上一首', onClick: () => (isBook ? stepBook(-1) : step(-1)) }, '') : null,
1638
+ hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '播放/暂停', onClick: togglePlay }, s.playing ? '' : '') : null,
1639
+ hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '下一章' : '下一首', onClick: () => (isBook ? stepBook(1) : step(1)) }, '') : null,
1640
+ hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '停止', onClick: stop }, '') : null,
1551
1641
  // 章节目录按钮:仅讲书(book)时出现,点击弹出章节列表并可跳章。
1552
1642
  // 与音量/播放模式按钮同款圆形样式(dsh-music-mode-trigger);弹层用
1553
1643
  // 与音量/播放模式同款的「相对容器 + 绝对定位」锚在按钮正上方。
1554
1644
  isBook ? React.createElement('div', { className: 'dsh-music-toc-trigger' },
1555
1645
  React.createElement('button', {
1556
1646
  className: 'dsh-music-mode-trigger' + (s.tocOpen ? ' active' : ''),
1557
- title: '\u7ae0\u8282\u76ee\u5f55',
1647
+ title: '章节目录',
1558
1648
  onClick: openToc,
1559
1649
  }, React.createElement('svg', {
1560
1650
  viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
@@ -1566,7 +1656,7 @@ window.__ModuleLoader__.load({
1566
1656
  React.createElement('div', { className: 'dsh-music-bar-vol', ref: volRef },
1567
1657
  React.createElement('button', {
1568
1658
  className: 'dsh-music-mode-trigger' + (volOpen ? ' active' : ''),
1569
- title: '\u97f3\u91cf',
1659
+ title: '音量',
1570
1660
  onClick: () => setVolOpen((o) => !o),
1571
1661
  }, React.createElement('svg', {
1572
1662
  viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
@@ -1578,7 +1668,7 @@ window.__ModuleLoader__.load({
1578
1668
  ),
1579
1669
  React.createElement('button', {
1580
1670
  className: panelCls,
1581
- title: s.panelOpen ? '\u5173\u95ed\u64ad\u653e\u5217\u8868' : '\u6253\u5f00\u64ad\u653e\u5217\u8868',
1671
+ title: s.panelOpen ? '关闭播放列表' : '打开播放列表',
1582
1672
  onClick: togglePanel,
1583
1673
  }, React.createElement('svg', {
1584
1674
  viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
@@ -1592,9 +1682,9 @@ window.__ModuleLoader__.load({
1592
1682
  // with currentColor so they match the accent of the other round transport
1593
1683
  // buttons (green), which a native <select> cannot color.
1594
1684
  const MODES = [
1595
- { id: 'single', label: '\u5355\u66f2\u5faa\u73af', title: '\u5355\u66f2\u5faa\u73af\uff1a\u64ad\u653e\u7ed3\u675f\u91cd\u590d\u5f53\u524d\u66f2\u76ee', d: 'M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z' },
1596
- { id: 'order', label: '\u987a\u5e8f\u64ad\u653e', title: '\u987a\u5e8f\u64ad\u653e\uff1a\u81ea\u52a8\u64ad\u653e\u5217\u8868\u4e2d\u7684\u4e0b\u4e00\u9996', d: 'M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zm14-10v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z' },
1597
- { id: 'shuffle', label: '\u4e71\u5e8f\u64ad\u653e', title: '\u4e71\u5e8f\u64ad\u653e\uff1a\u968f\u673a\u6311\u9009\u4e0b\u4e00\u9996', d: 'M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z' },
1685
+ { id: 'single', label: '单曲循环', title: '单曲循环:播放结束重复当前曲目', d: 'M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z' },
1686
+ { id: 'order', label: '顺序播放', title: '顺序播放:自动播放列表中的下一首', d: 'M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zm14-10v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z' },
1687
+ { id: 'shuffle', label: '乱序播放', title: '乱序播放:随机挑选下一首', d: 'M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z' },
1598
1688
  ];
1599
1689
  function ModeIcon(props) {
1600
1690
  return React.createElement('svg', {
@@ -1665,7 +1755,11 @@ window.__ModuleLoader__.load({
1665
1755
  key: i,
1666
1756
  className: 'dsh-music-toc-item' + (active ? ' active' : ''),
1667
1757
  title: sec.heading,
1668
- 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;
1669
1763
  if (id !== null) playBook(id, sec.fromChunk);
1670
1764
  closeToc();
1671
1765
  },
@@ -1676,14 +1770,14 @@ window.__ModuleLoader__.load({
1676
1770
  });
1677
1771
  const body = (s.bookToc || []).length > 0
1678
1772
  ? rows
1679
- : React.createElement('div', { className: 'dsh-music-empty' }, '\u6682\u65e0\u7ae0\u8282\u7ed3\u6784\uff08\u8be5\u4e66\u65e0\u6cd5\u8bc6\u522b\u5206\u8282\u3002\uff09');
1773
+ : React.createElement('div', { className: 'dsh-music-empty' }, '暂无章节结构(该书无法识别分节。)');
1680
1774
  // 弹层用 CSS 定位在「章节目录」按钮正上方(与音量/播放模式弹窗同款:
1681
1775
  // 相对容器 + position:absolute + bottom:calc(100%+6px) 居中),见
1682
1776
  // .dsh-music-toc-trigger / .dsh-music-toc 样式。
1683
1777
  return React.createElement('div', { className: 'dsh-music-toc', ref },
1684
1778
  React.createElement('div', { className: 'dsh-music-toc-head' },
1685
- React.createElement('span', { className: 'dsh-music-toc-title' }, '\u7ae0\u8282\u76ee\u5f55'),
1686
- React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: closeToc }, '\u2715')),
1779
+ React.createElement('span', { className: 'dsh-music-toc-title' }, '章节目录'),
1780
+ React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: closeToc }, '')),
1687
1781
  React.createElement('div', { className: 'dsh-music-toc-list', ref: listRef }, body),
1688
1782
  );
1689
1783
  }
@@ -1693,7 +1787,7 @@ window.__ModuleLoader__.load({
1693
1787
  const listRef = useRef(null);
1694
1788
  const panelRef = useRef(null);
1695
1789
  // Draggable panel position + size ({x, y, w, h} left/top/width/height once
1696
- // dragged or resized; null = default right/bottom, 380px, auto height).
1790
+ // dragged or resized; null = CSS default: centered, 380px, auto height).
1697
1791
  const [pos, setPos] = useState(loadPanelPos);
1698
1792
  const dragRef = useRef(null); // head-drag state
1699
1793
  const resizeRef = useRef(null); // corner-resize state
@@ -1704,11 +1798,13 @@ window.__ModuleLoader__.load({
1704
1798
  setAddMenu({ track, x: r.right, y: r.top });
1705
1799
  };
1706
1800
 
1707
- // Once the panel is dragged/resized we switch from CSS right/bottom anchoring
1708
- // to explicit left/top/width/height. Locking height and clearing max-height
1709
- // matters: with only top+left and the CSS max-height:72vh still applying, a
1710
- // fixed element whose CSS also sets bottom would collapse/clamp while dragging.
1711
- const style = pos === null ? null : { left: pos.x, top: pos.y, width: pos.w, height: pos.h, maxHeight: 'none' };
1801
+ // Once the panel is dragged/resized we switch from CSS centering
1802
+ // (left:50%; top:50%; translate(-50%,-50%)) to explicit left/top/width/height.
1803
+ // Locking height, clearing max-height and nulling the CSS translate matters:
1804
+ // with only top+left and the CSS max-height:72vh still applying, a fixed
1805
+ // element whose CSS also sets the translate would collapse/clamp and shift
1806
+ // by half its own size while dragging.
1807
+ const style = pos === null ? null : { left: pos.x, top: pos.y, width: pos.w, height: pos.h, maxHeight: 'none', transform: 'none' };
1712
1808
 
1713
1809
  const onHeadDown = (e) => {
1714
1810
  if (e.button !== undefined && e.button !== 0) return;
@@ -1794,8 +1890,14 @@ window.__ModuleLoader__.load({
1794
1890
  if (!s.panelOpen) return;
1795
1891
  // Close the playlist panel when the user clicks outside it
1796
1892
  // (mousedown precedes the toggle's click, so both stay consistent).
1893
+ // The directory/file pickers are portaled to <body>, so a click inside
1894
+ // them is technically outside the panel's DOM — treat those as "inside"
1895
+ // so interacting with the picker never closes the panel underneath.
1797
1896
  const onDown = (e) => {
1798
- if (panelRef.current !== null && !panelRef.current.contains(e.target)) set({ panelOpen: false });
1897
+ if (panelRef.current !== null && !panelRef.current.contains(e.target)
1898
+ && !(e.target.closest && e.target.closest('.dsh-music-picker-overlay'))) {
1899
+ set({ panelOpen: false });
1900
+ }
1799
1901
  };
1800
1902
  document.addEventListener('mousedown', onDown);
1801
1903
  return () => document.removeEventListener('mousedown', onDown);
@@ -1816,17 +1918,23 @@ window.__ModuleLoader__.load({
1816
1918
  return React.createElement('div', { key: t.id, className: 'dsh-music-track-row' + (active ? ' active' : '') },
1817
1919
  React.createElement('button', {
1818
1920
  className: 'dsh-music-track' + (active ? ' active' : ''),
1819
- title: t.url,
1820
- 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'); },
1821
1929
  },
1822
- React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + t.name),
1930
+ React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? ' ' : '') + t.name),
1823
1931
  React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
1824
1932
  ),
1825
1933
  React.createElement('button', {
1826
1934
  className: 'dsh-music-playlist-mini add',
1827
1935
  title: '加入歌单',
1828
1936
  onClick: (e) => { e.stopPropagation(); openAddMenu(t, e); },
1829
- }, '\uff0b'),
1937
+ }, ''),
1830
1938
  );
1831
1939
  });
1832
1940
  const bookRows = s.books.map((b) => {
@@ -1835,10 +1943,10 @@ window.__ModuleLoader__.load({
1835
1943
  return React.createElement('button', {
1836
1944
  key: b.id,
1837
1945
  className: 'dsh-music-track' + (active ? ' active' : ''),
1838
- title: b.url,
1839
- 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); },
1840
1948
  },
1841
- React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + b.name),
1949
+ React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? ' ' : '') + b.name),
1842
1950
  React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(b.size)),
1843
1951
  );
1844
1952
  });
@@ -1854,11 +1962,11 @@ window.__ModuleLoader__.load({
1854
1962
  onClick: () => set({ subTab: key }),
1855
1963
  }, label);
1856
1964
  const musicSubTabs = React.createElement('div', { className: 'dsh-music-subtabs' },
1857
- subTabBtn('library', '\u66f2\u5e93'),
1858
- subTabBtn(FAV_PLAYLIST_ID, '\u2665 \u6211\u6700\u559c\u6b22'),
1965
+ subTabBtn('library', '曲库'),
1966
+ subTabBtn(FAV_PLAYLIST_ID, ' 我最喜欢'),
1859
1967
  // 自建歌单排在 + 号之前;+ 固定在末尾用于新建。
1860
1968
  (s.playlists || []).filter((p) => p.id !== FAV_PLAYLIST_ID).map((p) => subTabBtn(p.id, p.name, null, p.id)),
1861
- React.createElement('button', { className: 'dsh-music-subtab add', title: '新建歌单', onClick: onCreatePlaylist }, '\uff0b'),
1969
+ React.createElement('button', { className: 'dsh-music-subtab add', title: '新建歌单', onClick: onCreatePlaylist }, ''),
1862
1970
  );
1863
1971
  const isPlaylistView = s.subTab !== 'library';
1864
1972
  const plView = isPlaylistView ? playlistById(s.subTab) : null;
@@ -1866,29 +1974,30 @@ window.__ModuleLoader__.load({
1866
1974
  ? React.createElement(PlaylistDetail, { pl: plView })
1867
1975
  : (rows.length > 0
1868
1976
  ? rows
1869
- : React.createElement('div', { className: 'dsh-music-empty' }, '\u6682\u65e0\u97f3\u4e50\u3002\u70b9\u51fb\u4e0a\u65b9\u201c\u9009\u62e9\u97f3\u4e50\u76ee\u5f55\u201d\u5e76\u9009\u62e9\u76ee\u5f55\u540e\u81ea\u52a8\u626b\u63cf\u3002'));
1977
+ : React.createElement('div', { className: 'dsh-music-empty' }, '暂无音乐。点击上方“选择音乐目录”并选择目录后自动扫描。'));
1870
1978
  const listBody = s.tab === 'music'
1871
1979
  ? musicBody
1872
1980
  : (s.books.length > 0
1873
1981
  ? bookRows
1874
1982
  : (s.ttsConfigured
1875
- ? React.createElement('div', { className: 'dsh-music-empty' }, '\u672a\u53d1\u73b0 .txt \u5c0f\u8bf4\u6587\u4ef6\u3002')
1876
- : React.createElement('div', { className: 'dsh-music-error' }, s.ttsReason || '\u672a\u914d\u7f6e xiaomi/MiMo TTS \u6a21\u578b\u3002')));
1983
+ ? React.createElement('div', { className: 'dsh-music-empty' }, '未发现 .txt 小说文件。')
1984
+ : React.createElement('div', { className: 'dsh-music-error' }, s.ttsReason || '未配置xiaomi提供方。')));
1877
1985
  return React.createElement('div', { className: 'dsh-music-panel', ref: panelRef, style },
1878
1986
  React.createElement('div', {
1879
1987
  className: 'dsh-music-panel-head dsh-music-panel-drag',
1880
1988
  onPointerDown: onHeadDown, onPointerMove: onHeadMove, onPointerUp: onHeadUp,
1881
1989
  },
1882
- React.createElement('span', { className: 'dsh-music-panel-grip', 'aria-hidden': true }, '\u283f'),
1883
- React.createElement('span', { className: 'dsh-music-panel-title' }, '\u64ad\u653e\u5217\u8868'),
1884
- React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: () => set({ panelOpen: false }) }, '\u2715')),
1885
- React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '\u97f3\u4e50'), tabBtn('book', '\u5c0f\u8bf4')),
1990
+ React.createElement('span', { className: 'dsh-music-panel-grip', 'aria-hidden': true }, ''),
1991
+ React.createElement('span', { className: 'dsh-music-panel-title' }, '播放列表'),
1992
+ React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: () => set({ panelOpen: false }) }, '')),
1993
+ React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '音乐'), tabBtn('book', '小说')),
1886
1994
  React.createElement(DirectorySetting, null),
1887
1995
  s.tab === 'music' ? musicSubTabs : null,
1888
1996
  // While a novel is playing, keep music-only errors/scanning out of the
1889
1997
  // panel (novel status shows on the playback bar instead).
1890
- !isBook && s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
1891
- !isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '\u626b\u63cf\u4e2d\u2026') : null,
1998
+ // 音乐/小说统一在主列表区上方显示 error(设置块不再重复/分模式显示)。
1999
+ s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
2000
+ !isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '扫描中…') : null,
1892
2001
  React.createElement('div', { className: 'dsh-music-list', style: pos === null ? null : { maxHeight: 'none' }, ref: (el) => { listRef.current = el; } }, listBody),
1893
2002
  React.createElement('div', { className: 'dsh-music-resize', title: '拖动调整面板大小', onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp }),
1894
2003
  addMenu ? React.createElement(AddToPlaylistMenu, {
@@ -1903,47 +2012,50 @@ window.__ModuleLoader__.load({
1903
2012
  const s = useStore();
1904
2013
  const [pickerOpen, setPickerOpen] = useState(false);
1905
2014
  const [dirs, setDirs] = useState([]);
2015
+ const [files, setFiles] = useState([]);
1906
2016
  const [curPath, setCurPath] = useState('');
1907
2017
  const [curName, setCurName] = useState('');
1908
- const [curUp, setCurUp] = useState(null);
2018
+ const [curCrumbs, setCurCrumbs] = useState([]);
1909
2019
  const [dirError, setDirError] = useState(null);
1910
2020
  const isBook = s.tab === 'book';
1911
2021
  const activeRoot = isBook ? s.bookRoot : s.root;
1912
- const pickerTitle = isBook ? '\u9009\u62e9\u5c0f\u8bf4\u76ee\u5f55' : '\u9009\u62e9\u97f3\u4e50\u76ee\u5f55';
2022
+ const pickerTitle = isBook ? '选择小说目录' : '选择音乐目录';
1913
2023
  const hint = isBook
1914
- ? '\u652f\u6301 .txt \u6587\u4ef6\uff0cAI\u8bed\u97f3\u76ee\u524d\u4ec5\u652f\u6301xiaomi\u63d0\u4f9b\u65b9\uff08\u9650\u65f6\u514d\u8d39\uff09\uff0c\u8bf7\u5728\u8bbe\u7f6e\u4e2d\u914d\u7f6e\u597d\u518d\u4f7f\u7528\u6b64\u529f\u80fd\u3002'
1915
- : '\u652f\u6301 mp3 / m4a / flac / wav / ogg / opus / aac / webm \u7b49\u683c\u5f0f\uff0c\u81ea\u52a8\u9012\u5f52\u626b\u63cf\u5b50\u76ee\u5f55\u3002';
2024
+ ? '支持 .txt 文件,AI语音目前仅支持xiaomi提供方(限时免费),请在设置中配置好再使用此功能。'
2025
+ : '支持 mp3 / m4a / flac / wav / ogg / opus / aac / webm 等格式,自动递归扫描子目录。';
1916
2026
  return React.createElement('div', { className: 'dsh-music-settings' },
1917
2027
  React.createElement('div', { className: 'dsh-music-settings-row' },
1918
2028
  React.createElement('span', { className: 'dsh-music-settings-cur', title: activeRoot || '' },
1919
- '\ud83d\udcc1 ' + (activeRoot || '\u672a\u914d\u7f6e')),
2029
+ '📁 ' + (activeRoot || '未配置')),
1920
2030
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle)),
1921
- s.error ? React.createElement('p', { className: 'dsh-music-error' }, s.error) : null,
1922
2031
  React.createElement('p', { className: 'dsh-music-hint' }, hint),
1923
- pickerOpen ? React.createElement('div', { className: 'dsh-music-picker-overlay' },
2032
+ pickerOpen ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
1924
2033
  React.createElement('div', { className: 'dsh-music-picker' },
1925
2034
  React.createElement('div', { className: 'dsh-music-picker-head' },
1926
2035
  React.createElement('span', { className: 'dsh-music-picker-title' }, pickerTitle)),
1927
2036
  React.createElement('div', { className: 'dsh-music-picker-cur', title: curPath },
1928
- curName || curPath || '\u5bb6\u76ee\u5f55'),
2037
+ renderCrumbs(curCrumbs, curPath, curName, browse)),
1929
2038
  React.createElement('div', { className: 'dsh-music-picker-list' },
1930
- dirs.length > 0
1931
- ? dirs.map((d) => React.createElement('button', {
1932
- key: d.path,
1933
- className: 'dsh-music-picker-item',
1934
- title: d.path,
1935
- onClick: () => browse(d.path),
1936
- }, '\ud83d\udcc1 ' + d.name))
1937
- : React.createElement('div', { className: 'dsh-music-picker-empty' }, '\u672c\u76ee\u5f55\u4e0b\u65e0\u5b50\u76ee\u5f55\uff0c\u53ef\u76f4\u63a5\u9009\u62e9\u3002'),
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)),
1938
2051
  dirError ? React.createElement('div', { className: 'dsh-music-error' }, dirError) : null,
1939
2052
  ),
1940
2053
  React.createElement('div', { className: 'dsh-music-picker-foot' },
1941
- React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => goUp() }, '\u8fd4\u56de\u4e0a\u7ea7'),
1942
- React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => pickCurrent() }, '\u9009\u62e9\u6b64\u76ee\u5f55'),
1943
- React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => setPickerOpen(false) }, '\u53d6\u6d88'),
2054
+ React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => pickCurrent() }, '选择此目录'),
2055
+ React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => setPickerOpen(false) }, '取消'),
1944
2056
  ),
1945
2057
  ),
1946
- ) : null,
2058
+ )) : null,
1947
2059
  );
1948
2060
  function openPicker() {
1949
2061
  setPickerOpen(true);
@@ -1959,26 +2071,13 @@ window.__ModuleLoader__.load({
1959
2071
  if (data && data.error) { setDirError(data.error); return; }
1960
2072
  setCurPath(data.path || '');
1961
2073
  setCurName(data.name || '');
1962
- setCurUp(data.up || null);
2074
+ setCurCrumbs(data.crumbs || []);
1963
2075
  setDirs(data.dirs || []);
2076
+ setFiles(data.files || []);
1964
2077
  } catch (err) {
1965
2078
  setDirError('读取目录失败:' + String((err && err.message) || err));
1966
2079
  }
1967
2080
  }
1968
- function goUp() {
1969
- // Prefer the parent path computed by the host (correct separators per OS).
1970
- // At a drive root the host reports the "__drives__" sentinel, so "up"
1971
- // jumps to the drive list and lets the user switch disks.
1972
- if (curUp === '__drives__') { browse('__drives__'); return; }
1973
- if (curUp !== null && curUp !== undefined && curUp !== '') { browse(curUp); return; }
1974
- // fallback: derive the parent locally when the host omitted `up`.
1975
- // Handle both "\" and "/" so Windows paths never dead-end (the old
1976
- // POSIX-only parse did nothing on backslash paths like C:\Users\x).
1977
- if (curPath === '' || curPath === '/' || /^[A-Za-z]:[\\/]?$/.test(curPath)) return;
1978
- const idx = Math.max(curPath.lastIndexOf('/'), curPath.lastIndexOf('\\'));
1979
- if (idx <= 0) return;
1980
- browse(curPath.slice(0, idx));
1981
- }
1982
2081
  function pickCurrent() {
1983
2082
  const p = curPath;
1984
2083
  // The drive-list view ("__drives__") is not a real directory.
@@ -2026,8 +2125,8 @@ window.__ModuleLoader__.load({
2026
2125
  className: 'dsh-music-add-pop-item',
2027
2126
  title: '加入「' + p.name + '」',
2028
2127
  onClick: () => addTo(p.id),
2029
- }, (p.id === FAV_PLAYLIST_ID ? '\u2665 ' : '') + p.name + '\uff08' + p.count + '\uff09')) : null,
2030
- React.createElement('button', { className: 'dsh-music-add-pop-item new', onClick: addNew }, '\uff0b \u65b0\u5efa\u6b4c\u5355'),
2128
+ }, (p.id === FAV_PLAYLIST_ID ? ' ' : '') + p.name + '' + p.count + '')) : null,
2129
+ React.createElement('button', { className: 'dsh-music-add-pop-item new', onClick: addNew }, ' 新建歌单'),
2031
2130
  );
2032
2131
  }
2033
2132
  // 歌单详情:添加歌曲 + 重命名/删除 + 歌曲列表(移除/上移/下移)。
@@ -2039,20 +2138,22 @@ window.__ModuleLoader__.load({
2039
2138
  return React.createElement('div', { key: t.id, className: 'dsh-music-playlist-row' + (active ? ' active' : '') },
2040
2139
  React.createElement('button', {
2041
2140
  className: 'dsh-music-track',
2042
- title: t.url,
2043
- 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); },
2044
2145
  },
2045
- React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + (idx + 1) + '. ' + t.name),
2146
+ React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? ' ' : '') + (idx + 1) + '. ' + t.name),
2046
2147
  React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
2047
2148
  ),
2048
- React.createElement('button', { className: 'dsh-music-playlist-mini', title: '上移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, -1); } }, '\u2191'),
2049
- React.createElement('button', { className: 'dsh-music-playlist-mini', title: '下移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, 1); } }, '\u2193'),
2050
- React.createElement('button', { className: 'dsh-music-playlist-mini del', title: '从歌单移除', onClick: (e) => { e.stopPropagation(); apiPlaylistRemove(pl.id, [t.path]); } }, '\u00d7'),
2149
+ React.createElement('button', { className: 'dsh-music-playlist-mini', title: '上移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, -1); } }, ''),
2150
+ React.createElement('button', { className: 'dsh-music-playlist-mini', title: '下移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, 1); } }, ''),
2151
+ React.createElement('button', { className: 'dsh-music-playlist-mini del', title: '从歌单移除', onClick: (e) => { e.stopPropagation(); apiPlaylistRemove(pl.id, [t.path]); } }, '×'),
2051
2152
  );
2052
2153
  });
2053
2154
  return React.createElement('div', { className: 'dsh-music-playlist' },
2054
2155
  React.createElement('div', { className: 'dsh-music-playlist-head' },
2055
- React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => setPickerOpen(true) }, '\uff0b \u6dfb\u52a0\u6b4c\u66f2'),
2156
+ React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => setPickerOpen(true) }, ' 添加歌曲'),
2056
2157
  React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => onClearPlaylist(pl) }, '清空'),
2057
2158
  !pl.fixed ? React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => onRenamePlaylist(pl) }, '重命名') : null,
2058
2159
  !pl.fixed ? React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => onDeletePlaylist(pl) }, '删除') : null,
@@ -2064,7 +2165,7 @@ window.__ModuleLoader__.load({
2064
2165
  }
2065
2166
  // 文件系统多选器:浏览目录 + 勾选音频文件,用于歌单「添加歌曲」。
2066
2167
  function FilePicker({ pl, onClose }) {
2067
- const [cur, setCur] = useState({ path: '', name: '', up: null, dirs: [], files: [] });
2168
+ const [cur, setCur] = useState({ path: '', name: '', dirs: [], files: [], crumbs: [] });
2068
2169
  const [sel, setSel] = useState(new Set());
2069
2170
  const [err, setErr] = useState(null);
2070
2171
  const [busy, setBusy] = useState(false);
@@ -2073,7 +2174,7 @@ window.__ModuleLoader__.load({
2073
2174
  try {
2074
2175
  const data = await jsonGet('/dsh-music/files?path=' + encodeURIComponent(p || ''));
2075
2176
  if (data && data.error) { setErr(data.error); return; }
2076
- 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 || [] });
2077
2178
  } catch (e) { setErr('读取目录失败:' + String((e && e.message) || e)); }
2078
2179
  };
2079
2180
  // 默认定位到音乐目录(store.root),未配置时回退家目录。
@@ -2089,17 +2190,18 @@ window.__ModuleLoader__.load({
2089
2190
  setBusy(true);
2090
2191
  apiPlaylistAdd(pl.id, paths, () => onClose());
2091
2192
  };
2092
- return React.createElement('div', { className: 'dsh-music-picker-overlay' },
2193
+ return portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
2093
2194
  React.createElement('div', { className: 'dsh-music-picker' },
2094
2195
  React.createElement('div', { className: 'dsh-music-picker-head' },
2095
- React.createElement('span', { className: 'dsh-music-picker-title' }, '\u6dfb\u52a0\u6b4c\u66f2\u5230\u300c' + pl.name + '\u300d'),
2196
+ React.createElement('span', { className: 'dsh-music-picker-title' }, '添加歌曲到「' + pl.name + ''),
2096
2197
  ),
2097
- React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path }, cur.name || cur.path || '\u5bb6\u76ee\u5f55'),
2198
+ React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path },
2199
+ renderCrumbs(cur.crumbs, cur.path, cur.name, browse)),
2098
2200
  React.createElement('div', { className: 'dsh-music-picker-list' },
2099
2201
  (cur.dirs || []).map((d) => React.createElement('button', {
2100
2202
  key: d.path, className: 'dsh-music-picker-item', title: d.path,
2101
2203
  onClick: () => browse(d.path),
2102
- }, '\ud83d\udcc1 ' + d.name)),
2204
+ }, '📁 ' + d.name)),
2103
2205
  (cur.files || []).map((f) => {
2104
2206
  const checked = sel.has(f.path);
2105
2207
  return React.createElement('button', {
@@ -2108,7 +2210,7 @@ window.__ModuleLoader__.load({
2108
2210
  title: f.path,
2109
2211
  onClick: () => toggle(f.path),
2110
2212
  },
2111
- React.createElement('span', { className: 'dsh-music-file-check' }, checked ? '\u2713' : ''),
2213
+ React.createElement('span', { className: 'dsh-music-file-check' }, checked ? '' : ''),
2112
2214
  React.createElement('span', { className: 'dsh-music-file-name' }, f.name),
2113
2215
  React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(f.size)),
2114
2216
  );
@@ -2116,21 +2218,11 @@ window.__ModuleLoader__.load({
2116
2218
  err ? React.createElement('div', { className: 'dsh-music-error' }, err) : null,
2117
2219
  ),
2118
2220
  React.createElement('div', { className: 'dsh-music-picker-foot' },
2119
- React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => goUp() }, '上一级'),
2120
2221
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: confirmAdd, disabled: busy }, '确定添加(' + sel.size + ')'),
2121
2222
  React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: onClose }, '取消'),
2122
2223
  ),
2123
2224
  ),
2124
- );
2125
- function goUp() {
2126
- const u = cur.up;
2127
- if (u === '__drives__') { browse('__drives__'); return; }
2128
- if (u) { browse(u); return; }
2129
- if (cur.path === '' || cur.path === '/' || /^[A-Za-z]:[\\/]?$/.test(cur.path)) return;
2130
- const idx = Math.max(cur.path.lastIndexOf('/'), cur.path.lastIndexOf('\\'));
2131
- if (idx <= 0) return;
2132
- browse(cur.path.slice(0, idx));
2133
- }
2225
+ ));
2134
2226
  }
2135
2227
 
2136
2228
  const inject = ['slots'];
@@ -2185,7 +2277,7 @@ window.__ModuleLoader__.load({
2185
2277
  if (action === 'pause') { audio.pause(); set({ playing: false }); return; }
2186
2278
  if (action === 'resume') {
2187
2279
  const p = audio.play();
2188
- if (p !== undefined && typeof p.catch === 'function') p.catch(() => set({ error: '\u64ad\u653e\u5931\u8d25' }));
2280
+ if (p !== undefined && typeof p.catch === 'function') p.catch((err) => { if (!isPlayAborted(err)) set({ error: '播放失败' }); });
2189
2281
  return;
2190
2282
  }
2191
2283
  if (action === 'stop') { stop(); return; }
@@ -2224,7 +2316,10 @@ window.__ModuleLoader__.load({
2224
2316
  savePlayback();
2225
2317
  const promise = audio.play();
2226
2318
  if (promise !== undefined && typeof promise.catch === 'function') {
2227
- promise.catch(() => set({ error: '\u6d4f\u89c8\u5668\u62e6\u622a\u4e86\u81ea\u52a8\u64ad\u653e\uff0c\u8bf7\u5728\u64ad\u653e\u6761\u70b9\u51fb\u25b6\u89e3\u9501', 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
+ });
2228
2323
  }
2229
2324
  }
2230
2325
  }).catch(() => {});
@@ -2291,7 +2386,7 @@ window.__ModuleLoader__.load({
2291
2386
  '.dsh-music-bar .dsh-music-mode-trigger { width: 24px; height: 24px; }\n' +
2292
2387
  '.dsh-music-bar .dsh-music-mode-trigger svg { flex: none; }\n' +
2293
2388
  '.dsh-music-bar .dsh-music-mode-menu { align-self: center; }\n' +
2294
- '.dsh-music-panel { position: fixed; right: 24px; bottom: 84px; width: 380px; max-height: 72vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; pointer-events: auto; overflow: hidden; }\n' +
2389
+ '.dsh-music-panel { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 380px; max-height: 72vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; pointer-events: auto; overflow: hidden; }\n' +
2295
2390
  '.dsh-music-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; touch-action: none; z-index: 5; }\n' +
2296
2391
  '.dsh-music-resize::after { content: ""; position: absolute; right: 4px; bottom: 4px; width: 5px; height: 5px; border-right: 2px solid var(--dsw-alias-label-secondary, #8a8f98); border-bottom: 2px solid var(--dsw-alias-label-secondary, #8a8f98); opacity: 0.7; }\n' +
2297
2392
  '.dsh-music-resize:hover::after { opacity: 1; }\n' +
@@ -2328,15 +2423,23 @@ window.__ModuleLoader__.load({
2328
2423
  '.dsh-music-settings-cur { flex: 1; min-width: 0; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04)); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
2329
2424
  '.dsh-music-settings-btn { padding: 6px 12px; border-radius: 8px; border: none; background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); cursor: pointer; font-size: 13px; white-space: nowrap; }\n' +
2330
2425
  '.dsh-music-settings-btn.ghost { background: transparent; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
2331
- '.dsh-music-picker-overlay { position: absolute; inset: 0; z-index: 70; display: flex; overflow: auto; padding: 16px; background: rgba(0,0,0,0.45); }\n' +
2332
- '.dsh-music-picker { box-sizing: border-box; width: 88%; 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' +
2426
+ '.dsh-music-picker-overlay { position: fixed; inset: 0; z-index: 2000; display: flex; overflow: auto; padding: 16px; background: rgba(0,0,0,0.45); }\n' +
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' +
2333
2428
  '.dsh-music-picker-head { display: flex; align-items: center; flex: none; }\n' +
2334
2429
  '.dsh-music-picker-title { font-weight: 600; }\n' +
2335
- '.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' +
2336
2438
  '.dsh-music-picker-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }\n' +
2337
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' +
2338
2440
  '.dsh-music-picker-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
2339
- '.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' +
2340
2443
  '.dsh-music-picker-foot { display: flex; gap: 8px; justify-content: flex-end; }\n' +
2341
2444
  '.dsh-music-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
2342
2445
  // 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。