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.
- package/lib/client.js +243 -140
- package/lib/index.js +244 -96
- 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(() => {
|
|
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: '
|
|
710
|
-
epilogue: '
|
|
711
|
-
})[t] || '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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) || '
|
|
1410
|
+
set({ loading: false, error: (result && result.error) || '设置目录失败' });
|
|
1345
1411
|
}
|
|
1346
1412
|
}).catch((err) => {
|
|
1347
|
-
set({ loading: false, error: '
|
|
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: '
|
|
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: '
|
|
1431
|
-
{ id: '
|
|
1432
|
-
{ id: '
|
|
1433
|
-
{ id: '
|
|
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 !== '
|
|
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
|
|
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 : '
|
|
1446
|
-
title: '
|
|
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 ? '
|
|
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' }, '
|
|
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
|
|
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: '
|
|
1570
|
+
title: '重新合成当前段落',
|
|
1481
1571
|
onClick: retryBook,
|
|
1482
|
-
}, '
|
|
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: '
|
|
1594
|
+
title: '频谱不可用,点击重试',
|
|
1505
1595
|
onClick: () => { const t = resolvePlayable(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
|
|
1506
|
-
}, '
|
|
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
|
-
'
|
|
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, '
|
|
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' }, '
|
|
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 ? '
|
|
1548
|
-
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '
|
|
1549
|
-
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '
|
|
1550
|
-
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '
|
|
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: '
|
|
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: '
|
|
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 ? '
|
|
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: '
|
|
1596
|
-
{ id: 'order', label: '
|
|
1597
|
-
{ id: 'shuffle', label: '
|
|
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
|
-
|
|
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' }, '
|
|
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' }, '
|
|
1686
|
-
React.createElement('button', { className: 'dsh-music-icon-btn', title: '
|
|
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
|
|
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
|
|
1708
|
-
// to explicit left/top/width/height.
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
|
|
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)
|
|
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.
|
|
1820
|
-
|
|
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 ? '
|
|
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
|
-
}, '
|
|
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.
|
|
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 ? '
|
|
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', '
|
|
1858
|
-
subTabBtn(FAV_PLAYLIST_ID, '
|
|
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 }, '
|
|
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' }, '
|
|
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' }, '
|
|
1876
|
-
: React.createElement('div', { className: 'dsh-music-error' }, s.ttsReason || '
|
|
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 }, '
|
|
1883
|
-
React.createElement('span', { className: 'dsh-music-panel-title' }, '
|
|
1884
|
-
React.createElement('button', { className: 'dsh-music-icon-btn', title: '
|
|
1885
|
-
React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '
|
|
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
|
-
|
|
1891
|
-
|
|
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 [
|
|
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 ? '
|
|
2022
|
+
const pickerTitle = isBook ? '选择小说目录' : '选择音乐目录';
|
|
1913
2023
|
const hint = isBook
|
|
1914
|
-
? '
|
|
1915
|
-
: '
|
|
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
|
-
'
|
|
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
|
-
|
|
2037
|
+
renderCrumbs(curCrumbs, curPath, curName, browse)),
|
|
1929
2038
|
React.createElement('div', { className: 'dsh-music-picker-list' },
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
|
1942
|
-
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () =>
|
|
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
|
-
|
|
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 ? '
|
|
2030
|
-
React.createElement('button', { className: 'dsh-music-add-pop-item new', onClick: addNew }, '
|
|
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.
|
|
2043
|
-
|
|
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 ? '
|
|
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); } }, '
|
|
2049
|
-
React.createElement('button', { className: 'dsh-music-playlist-mini', title: '下移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, 1); } }, '
|
|
2050
|
-
React.createElement('button', { className: 'dsh-music-playlist-mini del', title: '从歌单移除', onClick: (e) => { e.stopPropagation(); apiPlaylistRemove(pl.id, [t.path]); } }, '
|
|
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) }, '
|
|
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: '',
|
|
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 || '',
|
|
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' }, '
|
|
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 },
|
|
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
|
-
}, '
|
|
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 ? '
|
|
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: '
|
|
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(() =>
|
|
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;
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
// 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。
|