dsh-music-player 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/lib/client.js +262 -31
- package/lib/index.js +109 -1
- package/lib/lyric.js +186 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ DeepSeek Harness 本地音乐/小说播放插件。
|
|
|
13
13
|
- 本地音频流式播放(HTTP Range),刷新后断点续播
|
|
14
14
|
- 顺序播放、单曲循环、乱序播放三种模式
|
|
15
15
|
- 实时 8 段频谱可视化(真实 FFT 频段,解码时离线计算、跟随播放位置)
|
|
16
|
-
- **实时歌词/字幕**:本地音频自动匹配同名 `.lrc`
|
|
16
|
+
- **实时歌词/字幕**:本地音频自动匹配同名 `.lrc` 逐行显示;**本地没有同名 `.lrc` 时自动在线兜底**(QQ 音乐官方歌词 → LRCLIB 免费同步歌词,结果按曲目缓存避免重复请求);在线 QQ 歌曲自动取官方歌词(外语歌带逐句翻译「原文 / 翻译」);AI 讲书时显示当前朗读句子(逐句滚动)。歌词/字幕显示在播放条频谱之后、时长之前,仅在闲置(控件组折叠)时展示,鼠标进入操作时自动收起;**AI 讲书还有一条「已读字符/全书字符」的全书进度细线**(按已读字数实时计算,不依赖合成时长,切块不回退,操作时再显示「N%」)
|
|
17
17
|
- 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
|
|
18
18
|
- 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
|
|
19
19
|
- AI 讲书:本地 `.txt` / `.epub` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转(打开即定位到当前正在播放的章节)、章节切歌,可选 4 种中文 AI 声音(默认白桦)
|
|
@@ -147,6 +147,7 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
147
147
|
- **章节目录**(📖 按钮):自动识别全书结构(书名/前言/章节/尾声),点击弹出**位于按钮正上方**的目录(自动定位到当前正在播放的章节),点击任意章节即从该章开头朗读
|
|
148
148
|
- **后退 / 前进**:讲书模式下跳上一章 / 下一章(音乐模式下仍是上一首 / 下一首)
|
|
149
149
|
- **AI 声音**:点音量按钮,在弹层选择声音——冰糖(女)、茉莉(女)、苏打(男)、白桦(男,默认)
|
|
150
|
+
- **全书进度**:播放条底部有一条「已读字符/全书字符」的进度细线,操作时显示「N%」——按已读字数实时计算,无需先合成全书就能给出稳定的整体进度(切块不回退;字符量来自源文本,因此总长总是可知,而总时长得合成完才知道),刷新页面后也会立即恢复显示(无需先点播放)
|
|
150
151
|
4. 刷新页面后从上次位置续读(断点续播)。
|
|
151
152
|
|
|
152
153
|
支持的格式:
|
|
@@ -179,7 +180,7 @@ dsh plugin --profile <profile> add ./ # 或直接改 profile 里的 link 目
|
|
|
179
180
|
点面板顶部「选择音乐目录」,选一个包含音频文件的实际目录(默认 `~/Music`)。目录路径不可读或不存在时会回退到默认目录而不是报错。
|
|
180
181
|
|
|
181
182
|
**改了音乐目录/新增了歌曲,但列表没更新?**
|
|
182
|
-
|
|
183
|
+
播放器在启动时扫描一次,并支持手动重扫:点「选择音乐目录」旁边的新增 **「刷新」按钮**,会重新遍历当前目录并更新列表(音乐与小说通用;无需重选目录)。扫描上限 500 首、递归子目录深度上限 4 层。
|
|
183
184
|
|
|
184
185
|
**`music_play` 工具说"音乐库为空"?**
|
|
185
186
|
说明还没有可用的音乐目录。请先打开播放面板,点「选择音乐目录」配置一次。
|
|
@@ -193,6 +194,9 @@ AI 语音目前仅支持 xiaomi 提供方(限时免费)。请先在 DSH 模
|
|
|
193
194
|
**听书偶尔"没声音但时间还在走"?**
|
|
194
195
|
这种一般是某一段的合成结果异常(返回了退化/静音音频),或瞬时合成失败。0.3.3 起 Host 端会严格校验合成音频(拒绝空数据/非 PCM 等退化 WAV)并自动重试一次瞬时失败,同时把每次合成结果记录在诊断日志里。若再遇到,可访问 `http://<DSH地址>/dsh-music/tts-logs` 查看最近 60 条合成记录(含失败原因、退化音频事件),据此定位具体是哪个块出的问题。
|
|
195
196
|
|
|
197
|
+
**本地音乐没有 .lrc,歌词是怎么来的?**
|
|
198
|
+
播放器会**在线兜底取词**:先用文件名(可带歌手/时长)在 QQ 音乐匿名接口匹配官方歌词(外语歌带逐句翻译),QQ 无果再查 LRCLIB(免费公开歌词库,返回同步 LRC)。结果按曲目在进程内缓存(正命中 6 小时 / 空命中 30 分钟),避免重复请求;无匹配或失败时静默保持无歌词,不影响播放。歌词为版权内容,仅供个人试听。
|
|
199
|
+
|
|
196
200
|
**想支持更多音频格式?**
|
|
197
201
|
格式支持由 Host 端 `AUDIO_TYPES` 表驱动,在 `lib/index.js` 里加扩展名与 MIME 即可(播放器本身用浏览器原生 `<audio>` 解码,最终能否播放还取决于浏览器对该编码的支持)。
|
|
198
202
|
|
package/lib/client.js
CHANGED
|
@@ -453,6 +453,9 @@ window.__ModuleLoader__.load({
|
|
|
453
453
|
// 空串 = 未取到(非 QQ / 取链失败),播放条只显示「QQ音乐」。
|
|
454
454
|
currentQuality: '',
|
|
455
455
|
playing: false, position: 0, duration: 0, volume: 0.8,
|
|
456
|
+
// AI 讲书全书进度(0..1):已读字符 / 全书字符。Host 的 charOffsets 就绪前为 0;
|
|
457
|
+
// 总字符数无需合成即可知,因此能给出稳定的「读到哪了」比例(时长占比做不到)。
|
|
458
|
+
bookProgress: 0,
|
|
456
459
|
panelOpen: false, loading: false, error: null, pendingId: null, pendingName: null,
|
|
457
460
|
mode: 'order', vizState: 'ok', tab: 'music',
|
|
458
461
|
// true once the Host prefs snapshot is loaded (volume/mode/playback from
|
|
@@ -485,6 +488,8 @@ window.__ModuleLoader__.load({
|
|
|
485
488
|
prompt: null,
|
|
486
489
|
// 自定义确认弹窗(替代浏览器 confirm):{ title, message, onOk, okText, danger } | null。
|
|
487
490
|
confirm: null,
|
|
491
|
+
// 「加入歌单」成功/失败提示:{ text, ok, id } | null。面板窗口内居中显示,2s 自动消失。
|
|
492
|
+
toast: null,
|
|
488
493
|
};
|
|
489
494
|
const listeners = new Set();
|
|
490
495
|
function set(patch) {
|
|
@@ -505,6 +510,19 @@ window.__ModuleLoader__.load({
|
|
|
505
510
|
}, []);
|
|
506
511
|
return snap;
|
|
507
512
|
}
|
|
513
|
+
// 「加入歌单」成功/失败提示:统一在面板窗口内居中显示(成功绿色 / 失败红色),
|
|
514
|
+
// 2 秒后自动消失。连续触发时只保留最后一条(前一条立即被顶掉)。
|
|
515
|
+
let toastTimer = null;
|
|
516
|
+
let toastSeq = 0;
|
|
517
|
+
function showToast(text, ok) {
|
|
518
|
+
const id = ++toastSeq;
|
|
519
|
+
set({ toast: { text, ok: !!ok, id } });
|
|
520
|
+
if (toastTimer !== null) clearTimeout(toastTimer);
|
|
521
|
+
toastTimer = setTimeout(() => {
|
|
522
|
+
toastTimer = null;
|
|
523
|
+
if (store.toast && store.toast.id === id) set({ toast: null });
|
|
524
|
+
}, 2000);
|
|
525
|
+
}
|
|
508
526
|
// 自定义输入弹窗(替代浏览器 prompt):openPrompt 打开、closePrompt 关闭,
|
|
509
527
|
// onOk(value) 在用户点「确定」时收到去空格后的值;点「取消」/关闭不回调。
|
|
510
528
|
let promptSeq = 0;
|
|
@@ -585,7 +603,7 @@ window.__ModuleLoader__.load({
|
|
|
585
603
|
method: 'POST', cache: 'no-store',
|
|
586
604
|
headers: { 'content-type': 'application/json' },
|
|
587
605
|
body: JSON.stringify({ id, paths }),
|
|
588
|
-
}).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); if (then) then(r); }).catch(() => {});
|
|
606
|
+
}).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); if (then) then(r); }).catch(() => { if (then) then(null); });
|
|
589
607
|
}
|
|
590
608
|
function apiPlaylistRemove(id, paths, then) {
|
|
591
609
|
fetch('/dsh-music/playlist/remove', {
|
|
@@ -787,13 +805,19 @@ window.__ModuleLoader__.load({
|
|
|
787
805
|
if (store.currentId === null) { clearPref(PREF_PLAYBACK); return; }
|
|
788
806
|
if (String(store.currentId).startsWith('book:')) { saveCurrentBookPlayback(); return; }
|
|
789
807
|
if (String(store.currentId).startsWith('qq:')) {
|
|
790
|
-
// 在线曲目:保存曲目 +
|
|
808
|
+
// 在线曲目:保存曲目 + 队列 + 播放位置(刷新/重启后可从中途续播)。
|
|
809
|
+
// 流地址每次经代理重新获取(Range 透传),所以位置 seek 始终有效。
|
|
791
810
|
const mid = String(store.currentId).slice(3);
|
|
792
811
|
const song = (store.qqQueue || []).find((t) => String(t.songmid || t.id) === mid);
|
|
793
812
|
if (!song) return;
|
|
794
813
|
savePref(PREF_PLAYBACK, JSON.stringify({
|
|
795
814
|
kind: 'qq', id: store.currentId, name: store.currentName,
|
|
796
815
|
artists: store.currentArtists || [],
|
|
816
|
+
// 与本地曲目一致:恢复中的曲目还没真正加载,currentTime 可能短暂为 0,
|
|
817
|
+
// 用钉住的 restoredMusicPos 覆盖,避免把已恢复的位置写成 0。
|
|
818
|
+
position: (restoredMusicPos !== null && restoredMusicPos > 0) ? restoredMusicPos : (audio.currentTime || 0),
|
|
819
|
+
// 顺手持久化已知时长:恢复时浏览器尚未加载元数据,避免显示 "0:00"。
|
|
820
|
+
duration: Number.isFinite(audio.duration) ? audio.duration : 0,
|
|
797
821
|
queue: store.qqQueue || [], source: store.qqSource || '在线',
|
|
798
822
|
ts: Date.now(),
|
|
799
823
|
}));
|
|
@@ -834,6 +858,11 @@ window.__ModuleLoader__.load({
|
|
|
834
858
|
// 右引号字符:断在句尾标点后、若紧跟右引号则连同引号一起收进本行,避免把 「”」
|
|
835
859
|
// 单独留在一行。
|
|
836
860
|
const SUB_QUOTE_CLOSE = new Set('”」』"'.split(''));
|
|
861
|
+
// 断行「容忍」余量:当必须按 SUBTITLE_MAX 边界硬切时,允许本行再多收几个有效字,
|
|
862
|
+
// 以落到边界后不远处的一个自然停顿(断句标点)上,或吞掉本块末尾的极小残留——这样
|
|
863
|
+
// 每行更完整,避免「……他说」被硬切、也避免末尾残留一个极短行。仅在越过边界后累计
|
|
864
|
+
// 额外有效字 ≤ 该值时生效,防止行无限变长。
|
|
865
|
+
const SUBTITLE_TOLERANCE = 3;
|
|
837
866
|
const isSubLowSurrogate = (code) => code >= 0xDC00 && code <= 0xDFFF;
|
|
838
867
|
// 标点集合:不计入「字数」(仍是显示内容,只是长度统计时不算字)。
|
|
839
868
|
const SUB_PUNCT = new Set(',。!?…:;、“”‘’()《》〈〉[]【】—~·`~!@#$%^&*()-_=+[]{};\':",.<>/?\\|'.split(''));
|
|
@@ -849,7 +878,9 @@ window.__ModuleLoader__.load({
|
|
|
849
878
|
// 把「当前讲书块文本」直接切成字幕行:每行 ≤ SUBTITLE_MAX(去标点字数),优先在
|
|
850
879
|
// 逗号/顿号/句号等自然停顿处换行(不避让引号——长对话也按内部标点优雅切断,而不是
|
|
851
880
|
// 触发兜底硬切),断口后紧跟的右引号会随本行收走。仅当窗口内没有任何停顿标点时才
|
|
852
|
-
//
|
|
881
|
+
// 按字数边界硬切;硬切前先做「容忍」:若边界后不远处就是断句标点,或本块马上就要
|
|
882
|
+
// 结束,就把断行位置往后挪一点,让这一行更完整(见 SUBTITLE_TOLERANCE)。标点不计
|
|
883
|
+
// 字数;空白/换行折叠为空格。
|
|
853
884
|
function wrapSubtitleLine(text) {
|
|
854
885
|
const raw = String(text || '');
|
|
855
886
|
const fold = (s) => s.replace(/\s+/g, ' ').trim();
|
|
@@ -872,7 +903,29 @@ window.__ModuleLoader__.load({
|
|
|
872
903
|
for (let j = start; j < end; j++) {
|
|
873
904
|
if (SUBTITLE_CLAUSE_BREAKS.indexOf(raw[j]) !== -1) brk = j;
|
|
874
905
|
}
|
|
875
|
-
let lineEnd
|
|
906
|
+
let lineEnd;
|
|
907
|
+
if (brk > start) {
|
|
908
|
+
lineEnd = brk + 1;
|
|
909
|
+
} else {
|
|
910
|
+
// 需要硬切。先容忍:向后看 SUBTITLE_TOLERANCE 个有效字范围内有没有断句标点,
|
|
911
|
+
// 有则把行尾挪到该标点之后(行更完整)。再容忍「块尾」:若边界后只剩极小残留
|
|
912
|
+
// (≤ SUBTITLE_TOLERANCE 个有效字),直接吞掉,避免末尾出一个极短行。
|
|
913
|
+
let where = -1;
|
|
914
|
+
let extra = 0;
|
|
915
|
+
for (let j = end; j < n; j++) {
|
|
916
|
+
if (isSubContent(raw[j])) {
|
|
917
|
+
extra++;
|
|
918
|
+
if (extra > SUBTITLE_TOLERANCE) break; // 超出容忍范围,不再往后找停顿
|
|
919
|
+
}
|
|
920
|
+
if (SUBTITLE_CLAUSE_BREAKS.indexOf(raw[j]) !== -1) { where = j; break; }
|
|
921
|
+
}
|
|
922
|
+
if (where !== -1) {
|
|
923
|
+
lineEnd = where + 1;
|
|
924
|
+
} else {
|
|
925
|
+
const tailContent = countSubContent(raw.slice(end));
|
|
926
|
+
lineEnd = (tailContent > 0 && tailContent <= SUBTITLE_TOLERANCE) ? n : Math.max(start + 1, end);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
876
929
|
// 断句后把紧跟在后面的右引号一并收进本行(如 「。.””」),避免引号单独成行。
|
|
877
930
|
while (lineEnd < n && SUB_QUOTE_CLOSE.has(raw[lineEnd])) lineEnd++;
|
|
878
931
|
lines.push(fold(raw.slice(start, lineEnd)));
|
|
@@ -914,6 +967,27 @@ window.__ModuleLoader__.load({
|
|
|
914
967
|
}
|
|
915
968
|
return Math.max(0, ans);
|
|
916
969
|
}
|
|
970
|
+
// 讲书「已读字符/全书字符」的实时进度(0..1)。字符数无需合成即可由 Host 精确算出
|
|
971
|
+
// (charOffsets 已缓存于 bookMetaCache),因此能得到稳定的全书占比——而时长占比
|
|
972
|
+
// 做不到:全书总时长在合成本书之前不可知。块内按播放时间占比插值(假设语速大致
|
|
973
|
+
// 恒定),跨块由 charOffsets[from] 阶梯推进,整体单调、切块不回退。meta 未就绪时
|
|
974
|
+
// 返回 0(进度条先不渲染),不阻塞其它功能。
|
|
975
|
+
function bookProgressFor() {
|
|
976
|
+
const id = currentBookId();
|
|
977
|
+
const meta = id !== null ? bookMetaCache.get(id) : undefined;
|
|
978
|
+
const offsets = meta && Array.isArray(meta.charOffsets) ? meta.charOffsets : [];
|
|
979
|
+
if (offsets.length < 2) return 0;
|
|
980
|
+
const total = offsets[offsets.length - 1];
|
|
981
|
+
if (!(total > 0)) return 0;
|
|
982
|
+
const k = bookFromRef;
|
|
983
|
+
if (k < 0 || k >= offsets.length - 1) return 0;
|
|
984
|
+
const dur = Number.isFinite(subtitleDur) && subtitleDur > 0
|
|
985
|
+
? subtitleDur
|
|
986
|
+
: (Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0);
|
|
987
|
+
const frac = dur > 0 ? Math.max(0, Math.min(1, (audio.currentTime || 0) / dur)) : 0;
|
|
988
|
+
const consumed = offsets[k] + (offsets[k + 1] - offsets[k]) * frac;
|
|
989
|
+
return consumed > 0 ? Math.max(0, Math.min(1, consumed / total)) : 0;
|
|
990
|
+
}
|
|
917
991
|
function resetLyric() {
|
|
918
992
|
musicLyric = [];
|
|
919
993
|
subtitleLines = [];
|
|
@@ -953,7 +1027,8 @@ window.__ModuleLoader__.load({
|
|
|
953
1027
|
if (text !== store.lyricText) set({ lyricText: text });
|
|
954
1028
|
}
|
|
955
1029
|
}
|
|
956
|
-
//
|
|
1030
|
+
// 拉取并解析本地曲目的歌词:优先同名 .lrc(/lyric);本地无则在线兜底
|
|
1031
|
+
// (/lyric/online:Host 按 QQ 音乐 → LRCLIB 匹配取词)。无词/切歌后返回皆静默。
|
|
957
1032
|
function loadLyricForTrack(id) {
|
|
958
1033
|
resetLyric();
|
|
959
1034
|
lyricTrackId = id;
|
|
@@ -963,9 +1038,40 @@ window.__ModuleLoader__.load({
|
|
|
963
1038
|
fetch('/dsh-music/lyric?path=' + encodeURIComponent(track.path), { cache: 'no-store' })
|
|
964
1039
|
.then((r) => r.json())
|
|
965
1040
|
.then((d) => {
|
|
966
|
-
if (!d || !d.ok || !Array.isArray(d.lrc)) return;
|
|
967
1041
|
if (store.currentId !== id) return; // 取词期间已切歌
|
|
968
|
-
|
|
1042
|
+
if (d && d.ok && Array.isArray(d.lrc) && d.lrc.length > 0) {
|
|
1043
|
+
musicLyric = d.lrc;
|
|
1044
|
+
updateLyric();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
// 本地无同名 .lrc → 在线兜底(QQ → LRCLIB)
|
|
1048
|
+
loadOnlineLyric(id, track);
|
|
1049
|
+
})
|
|
1050
|
+
.catch(() => {});
|
|
1051
|
+
}
|
|
1052
|
+
// 从文件名 stem 拆「歌手 - 歌名」。常见命名:'周杰伦 - 乱舞春秋'、'周杰伦、温岚 - 屋顶'、
|
|
1053
|
+
// '周杰伦 余妮 - 懦夫'、'周杰伦 - 李玟 - 刀马旦'。以最后一个 ' - '/'–'/'—' 为界:
|
|
1054
|
+
// 前段当歌手(可含多位/空格/顿号),后段当歌名;没有分隔符时整段当歌名(歌手留空)。
|
|
1055
|
+
// 拆出干净歌名 + 歌手能显著提高在线歌词匹配精度(否则前缀会拖低匹配分)。
|
|
1056
|
+
function splitArtistTitle(stem) {
|
|
1057
|
+
const m = /^(.*)[-–—]\s*(.+)$/.exec(String(stem || ''));
|
|
1058
|
+
if (!m) return { title: String(stem || ''), artist: '' };
|
|
1059
|
+
return { title: m[2].trim(), artist: m[1].trim() };
|
|
1060
|
+
}
|
|
1061
|
+
// 本地歌曲在线歌词兜底:把文件名拆出的歌名/歌手 + 时长交给 Host 去 QQ/LRCLIB 匹配取词。
|
|
1062
|
+
function loadOnlineLyric(id, track) {
|
|
1063
|
+
const base = String(track.name || '').split(/[\\/]/).pop();
|
|
1064
|
+
const { title, artist } = splitArtistTitle(stripExt(base));
|
|
1065
|
+
const params = new URLSearchParams({ path: track.path, title });
|
|
1066
|
+
if (artist !== '') params.set('artist', artist);
|
|
1067
|
+
else if (Array.isArray(track.artists) && track.artists.length > 0) params.set('artist', track.artists.join('/'));
|
|
1068
|
+
if (Number.isFinite(audio.duration) && audio.duration > 0) params.set('duration', String(Math.round(audio.duration)));
|
|
1069
|
+
fetch('/dsh-music/lyric/online?' + params.toString(), { cache: 'no-store' })
|
|
1070
|
+
.then((r) => r.json())
|
|
1071
|
+
.then((d) => {
|
|
1072
|
+
if (!d || !d.ok || !Array.isArray(d.lrc) || d.lrc.length === 0) return;
|
|
1073
|
+
if (store.currentId !== id) return; // 取词期间已切歌
|
|
1074
|
+
musicLyric = mergeLyricTrans(d.lrc, d.trans);
|
|
969
1075
|
updateLyric();
|
|
970
1076
|
})
|
|
971
1077
|
.catch(() => {});
|
|
@@ -1359,7 +1465,7 @@ window.__ModuleLoader__.load({
|
|
|
1359
1465
|
bookRestorePos = -1;
|
|
1360
1466
|
audio.src = track.url;
|
|
1361
1467
|
audio.load();
|
|
1362
|
-
set({ currentId: id, currentName: track.name, currentArtists: track.artists || [], pendingId: null, pendingName: null, error: null, tocOpen: false, currentSection: '', qqFaved: false, currentQuality: (track && track.quality) || '' });
|
|
1468
|
+
set({ currentId: id, currentName: track.name, currentArtists: track.artists || [], pendingId: null, pendingName: null, error: null, tocOpen: false, currentSection: '', qqFaved: false, currentQuality: (track && track.quality) || '', bookProgress: 0 });
|
|
1363
1469
|
syncShufflePos();
|
|
1364
1470
|
loadEnvelope(id, track.url);
|
|
1365
1471
|
loadLyricForTrack(id);
|
|
@@ -1456,6 +1562,10 @@ window.__ModuleLoader__.load({
|
|
|
1456
1562
|
title: (m && m.title) || '',
|
|
1457
1563
|
author: (m && m.author) || '',
|
|
1458
1564
|
sections: (m && Array.isArray(m.sections)) ? m.sections : [],
|
|
1565
|
+
// 逐块累积字符偏移 + 全书总字符:用于「已读字符/全书字符」的实时进度。
|
|
1566
|
+
// 旧 Host 没有这俩字段时优雅降级(进度条保持 0/不显示),不影响其它功能。
|
|
1567
|
+
charOffsets: (m && Array.isArray(m.charOffsets) && m.charOffsets.length > 1) ? m.charOffsets : [],
|
|
1568
|
+
totalChars: m && typeof m.totalChars === 'number' && m.totalChars > 0 ? m.totalChars : -1,
|
|
1459
1569
|
};
|
|
1460
1570
|
bookMetaCache.set(id, meta);
|
|
1461
1571
|
}
|
|
@@ -1534,7 +1644,7 @@ window.__ModuleLoader__.load({
|
|
|
1534
1644
|
// near-instant (server-side synthesis cache) so we don't flash a spinner
|
|
1535
1645
|
// at every chunk boundary. Only user-initiated plays show it.
|
|
1536
1646
|
const showBuffer = !silent;
|
|
1537
|
-
set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, vizState: 'ok', currentSection: '', bookToc: [], currentQuality: '' });
|
|
1647
|
+
set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, vizState: 'ok', currentSection: '', bookToc: [], currentQuality: '', bookProgress: 0 });
|
|
1538
1648
|
// Load the chapter structure for the toc + the current-section label (the
|
|
1539
1649
|
// current section is derived from the chunk index once the meta arrives).
|
|
1540
1650
|
const startFrom = from;
|
|
@@ -1675,6 +1785,9 @@ window.__ModuleLoader__.load({
|
|
|
1675
1785
|
try { audio.currentTime = bookRestorePos; } catch (e) {}
|
|
1676
1786
|
}
|
|
1677
1787
|
if (bookTotal >= 0 && bookFromRef + 1 < bookTotal) preloadBook(id, bookFromRef + 1);
|
|
1788
|
+
// 恢复续播的这块之前没有拉取过字幕(restore 不动 audio、playBookFrom 不经过
|
|
1789
|
+
// 这里),必须补一次 loadBookSubtitle,否则续播后整块无字幕、直到切块才出现。
|
|
1790
|
+
loadBookSubtitle(id, bookFromRef);
|
|
1678
1791
|
}
|
|
1679
1792
|
const promise = audio.play();
|
|
1680
1793
|
if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' }); });
|
|
@@ -1798,7 +1911,7 @@ window.__ModuleLoader__.load({
|
|
|
1798
1911
|
audio.load();
|
|
1799
1912
|
stopBookHelper();
|
|
1800
1913
|
bookAutoRetried = false;
|
|
1801
|
-
set({ currentId: null, currentName: null, playing: false, position: 0, duration: 0, pendingId: null, pendingName: null, vizState: 'ok', bookBuffering: false, bookError: '', bookBufferingSince: 0, bookBufferingSilent: false, tocOpen: false, currentSection: '', lyricText: '', voiceSwitching: false });
|
|
1914
|
+
set({ currentId: null, currentName: null, playing: false, position: 0, duration: 0, pendingId: null, pendingName: null, vizState: 'ok', bookBuffering: false, bookError: '', bookBufferingSince: 0, bookBufferingSilent: false, tocOpen: false, currentSection: '', lyricText: '', voiceSwitching: false, bookProgress: 0 });
|
|
1802
1915
|
resetLyric();
|
|
1803
1916
|
clearPref(PREF_PLAYBACK);
|
|
1804
1917
|
if (stoppedBook !== null) clearBookPlayback(stoppedBook.name);
|
|
@@ -1838,6 +1951,12 @@ window.__ModuleLoader__.load({
|
|
|
1838
1951
|
// block (it reads like one long audiobook). Music is unchanged.
|
|
1839
1952
|
const bookTimeBase = () => (store.currentId !== null && String(store.currentId).startsWith('book:')) ? bookBaseTime : 0;
|
|
1840
1953
|
const onTime = () => {
|
|
1954
|
+
// 讲书:按「已读字符/全书字符」实时推进全书进度条。不用 position/duration 的
|
|
1955
|
+
// 时间占比——那是当前块内比例,切块会回退;且全书总时长在合成本书前不可知。
|
|
1956
|
+
if (String(store.currentId).startsWith('book:')) {
|
|
1957
|
+
const bp = bookProgressFor();
|
|
1958
|
+
if (bp !== store.bookProgress) set({ bookProgress: bp });
|
|
1959
|
+
}
|
|
1841
1960
|
// A restored music track keeps showing its restored position until real
|
|
1842
1961
|
// playback has clearly advanced past it. The <audio> currentTime is
|
|
1843
1962
|
// unreliable right after a restore — it can seek to the spot and then
|
|
@@ -1871,6 +1990,9 @@ window.__ModuleLoader__.load({
|
|
|
1871
1990
|
try { audio.currentTime = bookRestorePos; } catch (e) {}
|
|
1872
1991
|
}
|
|
1873
1992
|
set({ position: bookBaseTime + bookRestorePos });
|
|
1993
|
+
// 补齐:pin 阶段也推进字幕——续播时该块的实时字幕可能还没就绪,这里在
|
|
1994
|
+
// 每次 timeupdate 都尝试定位当前句,避免「先空白、切块后才出字幕」。
|
|
1995
|
+
updateLyric();
|
|
1874
1996
|
return;
|
|
1875
1997
|
}
|
|
1876
1998
|
}
|
|
@@ -2005,8 +2127,10 @@ window.__ModuleLoader__.load({
|
|
|
2005
2127
|
function restorePlayback(list) {
|
|
2006
2128
|
const saved = loadPlayback();
|
|
2007
2129
|
if (saved === null) return;
|
|
2008
|
-
// 在线 QQ 曲目:恢复当前曲目 + 队列 + 在线 scope + 在线 tab。
|
|
2130
|
+
// 在线 QQ 曲目:恢复当前曲目 + 队列 + 播放位置 + 在线 scope + 在线 tab。
|
|
2009
2131
|
if (saved.kind === 'qq' || (saved.id && String(saved.id).startsWith('qq:'))) {
|
|
2132
|
+
const pos = Number.isFinite(saved.position) && saved.position > 0 ? saved.position : 0;
|
|
2133
|
+
const savedDur = Number.isFinite(saved.duration) && saved.duration > 0 ? saved.duration : 0;
|
|
2010
2134
|
set({
|
|
2011
2135
|
currentId: saved.id,
|
|
2012
2136
|
currentName: saved.name || 'QQ音乐',
|
|
@@ -2014,10 +2138,14 @@ window.__ModuleLoader__.load({
|
|
|
2014
2138
|
scope: { kind: 'qq' },
|
|
2015
2139
|
qqQueue: Array.isArray(saved.queue) ? saved.queue : [],
|
|
2016
2140
|
qqSource: saved.source || '在线',
|
|
2141
|
+
// 恢复在线曲目的中途位置:播放条立即显示保存的位置;用户点 ▶ 续播时
|
|
2142
|
+
// togglePlay 会经 restoredMusicPos 把流 seek 到该位置(与本地曲目一致)。
|
|
2143
|
+
position: pos, duration: savedDur,
|
|
2017
2144
|
pendingId: null, pendingName: null, error: null,
|
|
2018
2145
|
tab: 'qq',
|
|
2019
2146
|
currentQuality: '',
|
|
2020
2147
|
});
|
|
2148
|
+
restoredMusicPos = pos > 0 ? pos : null;
|
|
2021
2149
|
// 更新时间戳,避免被当作旧数据。
|
|
2022
2150
|
savePref(PREF_PLAYBACK, JSON.stringify({ ...saved, ts: Date.now() }));
|
|
2023
2151
|
// 恢复在线曲目后按「我喜欢」集合刷新爱心状态。
|
|
@@ -2098,8 +2226,13 @@ window.__ModuleLoader__.load({
|
|
|
2098
2226
|
// 立刻显示正在播放的章节(而不是等用户点 ▶、playBookFrom 才开始加载)。
|
|
2099
2227
|
// ensureBookToc 内部会 set bookToc;这里再补上 currentSection。
|
|
2100
2228
|
void ensureBookToc(book.id).then((meta) => {
|
|
2101
|
-
if (meta === null
|
|
2102
|
-
|
|
2229
|
+
if (meta === null) return;
|
|
2230
|
+
// 刷新后书尚未播放(自动播放被拦截),onTime 不会触发、bookProgress 仍为 0,
|
|
2231
|
+
// 必须先在这里按「已读字符/全书字符」补一次,进度条才能立即显示正确的整体进度
|
|
2232
|
+
// (audio 未加载时 frac=0,即当前块起点位移 offset[from];播放后由 onTime 微调)。
|
|
2233
|
+
const bp = bookProgressFor();
|
|
2234
|
+
if (bp !== store.bookProgress) set({ bookProgress: bp });
|
|
2235
|
+
if (meta.sections.length > 0) set({ currentSection: sectionForChunk(meta.sections, from) });
|
|
2103
2236
|
});
|
|
2104
2237
|
// Refresh the chunk total in the background (the book file may have
|
|
2105
2238
|
// changed since the save); fall back to the saved total on failure.
|
|
@@ -2182,6 +2315,29 @@ window.__ModuleLoader__.load({
|
|
|
2182
2315
|
set({ loading: false, error: '设置目录失败:' + String((err && err.message) || err) });
|
|
2183
2316
|
});
|
|
2184
2317
|
}
|
|
2318
|
+
// 手动刷新:重新扫描当前音乐/小说目录并更新列表(面板「刷新」按钮)。
|
|
2319
|
+
// 与 saveRoot 一致只刷新列表、不调用 restoreLatest,避免把播放中的在线 QQ
|
|
2320
|
+
// 曲目/tab 跳走。返回 promise 供按钮等待扫描完成以复位「刷新中…」。
|
|
2321
|
+
function rescanLibrary() {
|
|
2322
|
+
return fetch('/dsh-music/rescan', {
|
|
2323
|
+
method: 'POST', cache: 'no-store',
|
|
2324
|
+
headers: { 'content-type': 'application/json' },
|
|
2325
|
+
}).then((r) => r.json()).then((result) => {
|
|
2326
|
+
if (result && result.ok) {
|
|
2327
|
+
set({
|
|
2328
|
+
root: result.root || null, bookRoot: result.bookRoot || null,
|
|
2329
|
+
tracks: result.tracks || [], books: result.books || [],
|
|
2330
|
+
playlists: result.playlists || [],
|
|
2331
|
+
count: result.count || 0, error: null,
|
|
2332
|
+
voices: Array.isArray(result.voices) ? result.voices : [],
|
|
2333
|
+
});
|
|
2334
|
+
} else {
|
|
2335
|
+
set({ error: (result && result.error) || '刷新失败' });
|
|
2336
|
+
}
|
|
2337
|
+
}).catch((err) => {
|
|
2338
|
+
set({ error: '刷新失败:' + String((err && err.message) || err) });
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2185
2341
|
|
|
2186
2342
|
function fmtTime(seconds) {
|
|
2187
2343
|
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
|
@@ -2466,8 +2622,12 @@ window.__ModuleLoader__.load({
|
|
|
2466
2622
|
// 无内容(点击停止 / 插件刚安装)时恒定工作态:不透明度 100%、控件组展开,无任何特效。
|
|
2467
2623
|
const active = barHover || anyPopOpen || !hasTrack;
|
|
2468
2624
|
const barDimmed = !active;
|
|
2469
|
-
//
|
|
2470
|
-
|
|
2625
|
+
// 播放进度细线:音乐按 position/duration(单曲时长),讲书按「已读字符/全书字符」
|
|
2626
|
+
// 的 bookProgress——全书总时长在合成本书前不可知,用时长占比会切块回退,字符占比
|
|
2627
|
+
// 才稳定且只增不减。
|
|
2628
|
+
const progressPct = isBook
|
|
2629
|
+
? Math.min(100, Math.max(0, s.bookProgress * 100))
|
|
2630
|
+
: ((hasTrack && s.duration > 0) ? Math.min(100, Math.max(0, (s.position / s.duration) * 100)) : 0);
|
|
2471
2631
|
return React.createElement('div', { className: 'dsh-music-bar-wrap' },
|
|
2472
2632
|
React.createElement('div',
|
|
2473
2633
|
{ className: 'dsh-music-bar' + (isBook ? ' book' : '') + (barDimmed ? ' dimmed' : ''), onMouseEnter: showBarBtns, onMouseLeave: onBarLeave },
|
|
@@ -2488,7 +2648,9 @@ window.__ModuleLoader__.load({
|
|
|
2488
2648
|
hasTrack
|
|
2489
2649
|
? (showHint
|
|
2490
2650
|
? React.createElement('span', { className: 'dsh-music-bar-hint' }, '⚠ 自动播放被拦截,点击▶解锁')
|
|
2491
|
-
: (isBook
|
|
2651
|
+
: (isBook
|
|
2652
|
+
? (!barDimmed ? React.createElement('span', { className: 'dsh-music-bar-time' }, Math.round(s.bookProgress * 100) + '%') : null)
|
|
2653
|
+
: (!barDimmed ? React.createElement('span', { className: 'dsh-music-bar-time' }, fmtTime(s.position) + ' / ' + fmtTime(s.duration)) : null)))
|
|
2492
2654
|
: null,
|
|
2493
2655
|
React.createElement('div', { className: 'dsh-music-bar-btns' },
|
|
2494
2656
|
heartBtn,
|
|
@@ -2544,8 +2706,8 @@ window.__ModuleLoader__.load({
|
|
|
2544
2706
|
React.createElement(VolumeSlider, null),
|
|
2545
2707
|
)) : null,
|
|
2546
2708
|
// 播放进度细线:绝对定位在播放条底部,与播放条等宽、高约 1px,随播放实时填充。
|
|
2547
|
-
//
|
|
2548
|
-
hasTrack &&
|
|
2709
|
+
// 音乐按单曲时长,讲书按「已读字符/全书字符」的 bookProgress(见 progressPct)。
|
|
2710
|
+
hasTrack && (isBook || s.duration > 0)
|
|
2549
2711
|
? React.createElement('div', { className: 'dsh-music-bar-progress' },
|
|
2550
2712
|
React.createElement('div', { className: 'dsh-music-bar-progress-fill', style: { width: progressPct + '%' } }))
|
|
2551
2713
|
: null,
|
|
@@ -2739,6 +2901,9 @@ window.__ModuleLoader__.load({
|
|
|
2739
2901
|
const [histOpen, setHistOpen] = useState(false);
|
|
2740
2902
|
const histRef = useRef(null);
|
|
2741
2903
|
const histPopRef = useRef(null); // portal 到 body 的历史下拉(点击内部不关闭)
|
|
2904
|
+
// 恢复「在线播放列表」层:prefsReady 早于 restorePlayback 灌 qqQueue,队列就绪前
|
|
2905
|
+
// 先标记待恢复,等 qqQueue 非空后由下方 effect 补上(避免刷新后直接显示「暂无歌曲」)。
|
|
2906
|
+
const queueRestorePending = useRef(false);
|
|
2742
2907
|
// 浏览。
|
|
2743
2908
|
const [minePlays, setMinePlays] = useState([]);
|
|
2744
2909
|
const [mineLoaded, setMineLoaded] = useState(false);
|
|
@@ -2750,7 +2915,6 @@ window.__ModuleLoader__.load({
|
|
|
2750
2915
|
const [browseLoading, setBrowseLoading] = useState(false);
|
|
2751
2916
|
// 「加入歌单」弹窗:{ song, x, y }(锚点=「+」按钮的视口坐标),点击弹出我的歌单列表。
|
|
2752
2917
|
const [qqJoin, setQqJoin] = useState(null);
|
|
2753
|
-
const [qqJoinMsg, setQqJoinMsg] = useState('');
|
|
2754
2918
|
const qqJoinRef = useRef(null);
|
|
2755
2919
|
// 点击弹窗外关闭「加入歌单」弹窗。
|
|
2756
2920
|
useEffect(() => {
|
|
@@ -2821,11 +2985,40 @@ window.__ModuleLoader__.load({
|
|
|
2821
2985
|
if (!ui || ui.layer !== 'playlist') return;
|
|
2822
2986
|
setLayer('playlist');
|
|
2823
2987
|
if (ui.plId) {
|
|
2824
|
-
|
|
2988
|
+
// 与 openPlaylist 同款加载态:先显示「加载中…」而不是空列表。QQ 歌单详情
|
|
2989
|
+
// 接口(fcg_ucc_getcdinfo_byids)在页面刚加载时首次请求常返回空 songlist
|
|
2990
|
+
// (冷缓存/会话未就绪),单次请求就会停在「暂无歌曲」——这正是「刷新后
|
|
2991
|
+
// 播放列表为空、返回重进才出来」的原因。这里对空结果/失败自动重试几次。
|
|
2992
|
+
setPlLoading(true);
|
|
2993
|
+
void loadPlaylistForRestore(ui.plId, ui.plName);
|
|
2825
2994
|
} else {
|
|
2826
|
-
|
|
2995
|
+
// 「在线播放列表」(openQueue 保存的 plId=''):prefsReady 置位早于
|
|
2996
|
+
// restorePlayback 把 qqQueue 灌好,直接快照 s.qqQueue 会拿到空数组导致
|
|
2997
|
+
// 「暂无歌曲」。这里标记待恢复,由下方 [s.qqQueue] effect 在队列就绪后补上。
|
|
2998
|
+
queueRestorePending.current = true;
|
|
2999
|
+
setActivePl({ name: store.qqSource || '在线播放列表', songs: store.qqQueue || [], source: store.qqSource || '在线' });
|
|
2827
3000
|
}
|
|
2828
3001
|
};
|
|
3002
|
+
// 恢复的「在线播放列表」:等 restorePlayback 把 qqQueue 灌好后应用(修复刷新后
|
|
3003
|
+
// 该层显示为空的问题);用户手动离开该层(返回/进入歌单)时取消待恢复。
|
|
3004
|
+
useEffect(() => {
|
|
3005
|
+
if (queueRestorePending.current && store.qqQueue && store.qqQueue.length > 0) {
|
|
3006
|
+
queueRestorePending.current = false;
|
|
3007
|
+
setActivePl({ name: store.qqSource || '在线播放列表', songs: store.qqQueue, source: store.qqSource || '在线' });
|
|
3008
|
+
}
|
|
3009
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3010
|
+
}, [s.qqQueue]);
|
|
3011
|
+
// 恢复歌单层:拉取歌单详情,空结果/失败自动重试(退避递增),成功后置入 activePl。
|
|
3012
|
+
const loadPlaylistForRestore = async (plId, plName, attempts = 3) => {
|
|
3013
|
+
let d = null;
|
|
3014
|
+
for (let i = 0; i < attempts; i++) {
|
|
3015
|
+
d = await json('/dsh-music/qq/playlist/' + encodeURIComponent(plId));
|
|
3016
|
+
if (d && d.ok && d.playlist && Array.isArray(d.playlist.songs) && d.playlist.songs.length > 0) break;
|
|
3017
|
+
if (i + 1 < attempts) await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
3018
|
+
}
|
|
3019
|
+
if (d && d.ok && d.playlist) setActivePl({ ...d.playlist, source: plName || '歌单' });
|
|
3020
|
+
setPlLoading(false);
|
|
3021
|
+
};
|
|
2829
3022
|
// ① 挂载时只做本地初始化:读搜索历史(Host 持久化)+ 查登录态(status 由
|
|
2830
3023
|
// host 读本地 cookie 文件,不发任何外部网络请求)。外部数据(分类/推荐/我的
|
|
2831
3024
|
// 歌单)一律延后到登录后(见下方 [loggedIn] effect),未登录零外部请求——
|
|
@@ -2986,6 +3179,7 @@ window.__ModuleLoader__.load({
|
|
|
2986
3179
|
}
|
|
2987
3180
|
function backToTops() { setTopDetail(null); setTopTotal(0); setTopHasMore(false); }
|
|
2988
3181
|
async function openPlaylist(pl, mine) {
|
|
3182
|
+
queueRestorePending.current = false; // 手动进入歌单,取消「在线播放列表」待恢复
|
|
2989
3183
|
setActivePl(null); setLayer('playlist'); setPlLoading(true); saveUi('playlist', pl.id, pl.name);
|
|
2990
3184
|
// _dirId:从歌单移除歌曲需要 QQ 歌单的目录 id(「我的歌单」卡片带 dirId);
|
|
2991
3185
|
// mine 标记这是「我自己的歌单」(含「我喜欢」),详情里才允许用「−」从该歌单移除。
|
|
@@ -2999,7 +3193,7 @@ window.__ModuleLoader__.load({
|
|
|
2999
3193
|
setActivePl({ name: s.qqSource || '在线播放列表', songs: s.qqQueue || [], source: s.qqSource || '播放中' });
|
|
3000
3194
|
setLayer('playlist'); saveUi('playlist', '', '');
|
|
3001
3195
|
}
|
|
3002
|
-
function backToMain() { setLayer('main'); setActivePl(null); saveUi('main', '', ''); }
|
|
3196
|
+
function backToMain() { queueRestorePending.current = false; setLayer('main'); setActivePl(null); saveUi('main', '', ''); }
|
|
3003
3197
|
async function doSearch(kwOverride) {
|
|
3004
3198
|
const kw = (kwOverride !== undefined ? String(kwOverride) : q).trim();
|
|
3005
3199
|
if (kw === '') { setSearched(false); setQError(''); return; }
|
|
@@ -3152,15 +3346,16 @@ window.__ModuleLoader__.load({
|
|
|
3152
3346
|
} catch (err) { setQError(String((err && err.message) || err)); }
|
|
3153
3347
|
}
|
|
3154
3348
|
// 「加入歌单」:打开弹窗(列出我的歌单),并把歌曲加入所选歌单 / 新建歌单。
|
|
3349
|
+
// 成功/失败统一走面板居中的 toast 提示(成功关闭弹窗;失败保留弹窗可重试)。
|
|
3155
3350
|
function openQqJoin(song, e) {
|
|
3156
3351
|
const r = e.currentTarget.getBoundingClientRect();
|
|
3157
3352
|
if (minePlays.length === 0 && !mineLoaded) loadMine();
|
|
3158
|
-
setQqJoinMsg('');
|
|
3159
3353
|
setQqJoin({ song, x: r.right, y: r.top });
|
|
3160
3354
|
}
|
|
3161
3355
|
async function qqJoinAddTo(pl) {
|
|
3162
3356
|
const song = qqJoin && qqJoin.song;
|
|
3163
3357
|
if (!song) return;
|
|
3358
|
+
const name = (pl && pl.name) || '歌单';
|
|
3164
3359
|
try {
|
|
3165
3360
|
// dirId 用歌单的权威 dirId(dirid),tid 固定 0(「我喜欢」dirId=201,tid=0 的可用模式)。
|
|
3166
3361
|
const d = await jsonPost('/dsh-music/qq/playlist-add', { song, dirId: (pl && (pl.dirId || pl.tid || pl.id)) || 0, tid: 0 });
|
|
@@ -3172,7 +3367,8 @@ window.__ModuleLoader__.load({
|
|
|
3172
3367
|
loadMine();
|
|
3173
3368
|
}
|
|
3174
3369
|
setQqJoin(null);
|
|
3175
|
-
|
|
3370
|
+
showToast('添加到' + name + '成功', true);
|
|
3371
|
+
} catch (err) { showToast('添加到' + name + '失败', false); }
|
|
3176
3372
|
}
|
|
3177
3373
|
async function qqJoinCreate() {
|
|
3178
3374
|
const song = qqJoin && qqJoin.song;
|
|
@@ -3189,7 +3385,8 @@ window.__ModuleLoader__.load({
|
|
|
3189
3385
|
}
|
|
3190
3386
|
loadMine();
|
|
3191
3387
|
setQqJoin(null);
|
|
3192
|
-
|
|
3388
|
+
showToast('添加到' + ((created && created.name) || trimmed) + '成功', true);
|
|
3389
|
+
} catch (err) { showToast('添加到' + trimmed + '失败', false); }
|
|
3193
3390
|
});
|
|
3194
3391
|
}
|
|
3195
3392
|
const qqJoinMenu = qqJoin ? portalToBody((() => {
|
|
@@ -3200,7 +3397,6 @@ window.__ModuleLoader__.load({
|
|
|
3200
3397
|
transform: openUp ? 'translateY(-100%)' : 'none',
|
|
3201
3398
|
};
|
|
3202
3399
|
return React.createElement('div', { className: 'dsh-music-add-pop', ref: qqJoinRef, style },
|
|
3203
|
-
qqJoinMsg ? React.createElement('div', { className: 'dsh-music-hint', style: { padding: '2px 8px', color: 'var(--dsw-alias-state-error-primary, #e5534b)' } }, qqJoinMsg) : null,
|
|
3204
3400
|
minePlays.length > 0 ? minePlays.map((p) => React.createElement('button', {
|
|
3205
3401
|
key: p.id, className: 'dsh-music-add-pop-item',
|
|
3206
3402
|
title: '加入「' + p.name + '」',
|
|
@@ -3767,6 +3963,7 @@ window.__ModuleLoader__.load({
|
|
|
3767
3963
|
}) : null,
|
|
3768
3964
|
s.prompt ? React.createElement(PromptModal, { key: s.prompt.id, panelRef }) : null,
|
|
3769
3965
|
s.confirm ? React.createElement(ConfirmModal, { key: s.confirm.title, panelRef }) : null,
|
|
3966
|
+
s.toast ? React.createElement('div', { className: 'dsh-music-panel-toast' + (s.toast.ok ? ' ok' : ' err') }, s.toast.text) : null,
|
|
3770
3967
|
);
|
|
3771
3968
|
}
|
|
3772
3969
|
// 自定义输入弹窗(替代浏览器 prompt):新建/重命名歌单等需要名称输入的场景。
|
|
@@ -3838,9 +4035,12 @@ window.__ModuleLoader__.load({
|
|
|
3838
4035
|
const [curName, setCurName] = useState('');
|
|
3839
4036
|
const [curCrumbs, setCurCrumbs] = useState([]);
|
|
3840
4037
|
const [dirError, setDirError] = useState(null);
|
|
4038
|
+
// 手动刷新按钮:扫描进行中禁用并显示「刷新中…」。
|
|
4039
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
3841
4040
|
const isBook = s.tab === 'book';
|
|
3842
4041
|
const activeRoot = isBook ? s.bookRoot : s.root;
|
|
3843
4042
|
const pickerTitle = isBook ? '选择小说目录' : '选择音乐目录';
|
|
4043
|
+
const refreshTitle = isBook ? '重新扫描小说目录' : '重新扫描音乐目录';
|
|
3844
4044
|
const hint = isBook
|
|
3845
4045
|
? '支持 .txt / .epub 文件,AI语音目前仅支持xiaomi提供方(限时免费),请在设置中配置好再使用此功能。'
|
|
3846
4046
|
: '支持 mp3 / m4a / flac / wav / ogg / opus / aac / webm 等格式,自动递归扫描子目录。';
|
|
@@ -3848,7 +4048,19 @@ window.__ModuleLoader__.load({
|
|
|
3848
4048
|
React.createElement('div', { className: 'dsh-music-settings-row' },
|
|
3849
4049
|
React.createElement('span', { className: 'dsh-music-settings-cur', title: activeRoot || '' },
|
|
3850
4050
|
'📁 ' + (activeRoot || '未配置')),
|
|
3851
|
-
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle)
|
|
4051
|
+
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle),
|
|
4052
|
+
// 手动刷新:重新扫描当前目录(新增文件后无需重选目录即可看到)。
|
|
4053
|
+
React.createElement('button', {
|
|
4054
|
+
className: 'dsh-music-settings-btn ghost',
|
|
4055
|
+
title: refreshTitle,
|
|
4056
|
+
disabled: refreshing,
|
|
4057
|
+
onClick: async () => {
|
|
4058
|
+
if (refreshing) return;
|
|
4059
|
+
setRefreshing(true);
|
|
4060
|
+
await rescanLibrary();
|
|
4061
|
+
setRefreshing(false);
|
|
4062
|
+
},
|
|
4063
|
+
}, refreshing ? '刷新中…' : '刷新')),
|
|
3852
4064
|
React.createElement('p', { className: 'dsh-music-hint' }, hint),
|
|
3853
4065
|
pickerOpen ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
|
|
3854
4066
|
React.createElement('div', { className: 'dsh-music-picker', style: panelCenterStyle(panelRef, pickerOpen, 320, Math.round(window.innerHeight * 0.72)) },
|
|
@@ -3924,7 +4136,15 @@ window.__ModuleLoader__.load({
|
|
|
3924
4136
|
transform: openUp ? 'translateY(-100%)' : 'none',
|
|
3925
4137
|
};
|
|
3926
4138
|
const list = store.playlists || [];
|
|
3927
|
-
|
|
4139
|
+
// 加入已有歌单:成功关弹窗并居中提示,失败保留弹窗(可换歌单重试)居中提示。
|
|
4140
|
+
const addTo = (id) => {
|
|
4141
|
+
const pl = list.find((p) => p.id === id);
|
|
4142
|
+
const name = (pl && pl.name) || '歌单';
|
|
4143
|
+
apiPlaylistAdd(id, [track.path], (r) => {
|
|
4144
|
+
if (r && r.ok && r.playlist) { onClose(); showToast('添加到' + name + '成功', true); }
|
|
4145
|
+
else showToast('添加到' + name + '失败', false);
|
|
4146
|
+
});
|
|
4147
|
+
};
|
|
3928
4148
|
const addNew = () => {
|
|
3929
4149
|
openPrompt('新建歌单名称', '', (trimmed) => {
|
|
3930
4150
|
if (!trimmed) return;
|
|
@@ -3935,9 +4155,14 @@ window.__ModuleLoader__.load({
|
|
|
3935
4155
|
}).then((r) => r.json()).then((r) => {
|
|
3936
4156
|
if (r && r.playlist) {
|
|
3937
4157
|
set({ playlists: [...(store.playlists || []), r.playlist] });
|
|
3938
|
-
apiPlaylistAdd(r.playlist.id, [track.path], () =>
|
|
4158
|
+
apiPlaylistAdd(r.playlist.id, [track.path], (add) => {
|
|
4159
|
+
if (add && add.ok && add.playlist) { onClose(); showToast('添加到' + r.playlist.name + '成功', true); }
|
|
4160
|
+
else showToast('添加到' + r.playlist.name + '失败', false);
|
|
4161
|
+
});
|
|
4162
|
+
} else {
|
|
4163
|
+
showToast('添加到' + trimmed + '失败', false);
|
|
3939
4164
|
}
|
|
3940
|
-
}).catch(() =>
|
|
4165
|
+
}).catch(() => showToast('添加到' + trimmed + '失败', false));
|
|
3941
4166
|
});
|
|
3942
4167
|
};
|
|
3943
4168
|
return React.createElement('div', { className: 'dsh-music-add-pop', ref, style },
|
|
@@ -4190,7 +4415,7 @@ window.__ModuleLoader__.load({
|
|
|
4190
4415
|
// (宽度 0.12s 平滑过渡)。播放条容器已 overflow:hidden,细线两端会被裁剪到
|
|
4191
4416
|
// 圆角形状内,不会「戳出」圆角之外;pointer-events:none 避免挡住下方交互。
|
|
4192
4417
|
'.dsh-music-bar-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; overflow: hidden; pointer-events: none; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.1)); }\n' +
|
|
4193
|
-
'.dsh-music-bar-progress-fill { height: 100%; background: var(--dsh-music-accent, #2f9e6e); transition: width 0.12s linear; }\n' +
|
|
4418
|
+
'.dsh-music-bar-progress-fill { height: 100%; background: color-mix(in srgb, var(--dsh-music-accent, #2f9e6e) 55%, var(--dsw-alias-bg-base)); transition: width 0.12s linear; }\n' +
|
|
4194
4419
|
'.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
|
|
4195
4420
|
'.dsh-music-bar-name { max-width: 36%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-flex; align-items: center; min-width: 0; }\n' +
|
|
4196
4421
|
'.dsh-music-note { color: var(--dsh-music-accent, #2f9e6e); flex: none; margin-right: 4px; }\n' +
|
|
@@ -4449,6 +4674,12 @@ window.__ModuleLoader__.load({
|
|
|
4449
4674
|
'.dsh-music-add-pop-item { display: block; width: 100%; text-align: left; padding: 5px 8px; border: none; background: transparent; border-radius: 6px; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n' +
|
|
4450
4675
|
'.dsh-music-add-pop-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
4451
4676
|
'.dsh-music-add-pop-item.new { color: var(--dsh-music-accent, #2f9e6e); border-top: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); margin-top: 2px; padding-top: 6px; }\n' +
|
|
4677
|
+
// 「加入歌单」成功/失败提示:面板窗口内绝对居中,颜色跟随 DSH 主题
|
|
4678
|
+
// (成功 = 主题强调色 --dsh-music-accent;失败 = 主题错误色),2s 自动消失。
|
|
4679
|
+
'.dsh-music-panel-toast { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 20; padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--dsh-music-accent-fg, #fff); background: rgba(30,31,34,0.92); box-shadow: 0 6px 20px rgba(0,0,0,0.35); pointer-events: none; white-space: nowrap; max-width: 90%; text-align: center; animation: dsh-music-toast-in 0.18s ease; }\n' +
|
|
4680
|
+
'.dsh-music-panel-toast.ok { background: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
4681
|
+
'.dsh-music-panel-toast.err { background: var(--dsw-alias-state-error-primary, #e5534b); }\n' +
|
|
4682
|
+
'@keyframes dsh-music-toast-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.94); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }\n' +
|
|
4452
4683
|
'.dsh-music-bar-btn.fav { color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
4453
4684
|
'.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n';
|
|
4454
4685
|
|
package/lib/index.js
CHANGED
|
@@ -314,6 +314,7 @@ import { dirname, basename, parse as pathParse, join as pathJoin } from 'node:pa
|
|
|
314
314
|
import { inflateRawSync, inflateSync } from 'node:zlib'
|
|
315
315
|
import * as os from 'node:os'
|
|
316
316
|
import * as QQ from './qq.js'
|
|
317
|
+
import { getOnlineLyric } from './lyric.js'
|
|
317
318
|
|
|
318
319
|
// ---- book structure parsing (title / preface / chapters / epilogue) ----
|
|
319
320
|
// Heuristic, rule-based parser that splits a novel's normalized text into
|
|
@@ -1378,6 +1379,27 @@ export function apply(ctx) {
|
|
|
1378
1379
|
return null
|
|
1379
1380
|
}
|
|
1380
1381
|
|
|
1382
|
+
// ---- 本地歌曲在线歌词兜底缓存(按 track.path,LRU + TTL)----
|
|
1383
|
+
// 正命中 6h、空命中 30min:避免每次播放重复打 QQ/LRCLIB;容量上限 500 条。
|
|
1384
|
+
const lyricOnlineCache = new Map()
|
|
1385
|
+
const LYRIC_ONLINE_TTL = 6 * 60 * 60 * 1000
|
|
1386
|
+
const LYRIC_ONLINE_TTL_EMPTY = 30 * 60 * 1000
|
|
1387
|
+
const LYRIC_ONLINE_MAX = 500
|
|
1388
|
+
const lyricOnlineCacheGet = (path) => {
|
|
1389
|
+
const e = lyricOnlineCache.get(path)
|
|
1390
|
+
if (!e) return null
|
|
1391
|
+
if (Date.now() - e.ts > e.ttl) { lyricOnlineCache.delete(path); return null }
|
|
1392
|
+
return e.payload
|
|
1393
|
+
}
|
|
1394
|
+
const lyricOnlineCacheSet = (path, payload, ttl) => {
|
|
1395
|
+
if (lyricOnlineCache.has(path)) lyricOnlineCache.delete(path)
|
|
1396
|
+
lyricOnlineCache.set(path, { ts: Date.now(), ttl, payload })
|
|
1397
|
+
if (lyricOnlineCache.size > LYRIC_ONLINE_MAX) {
|
|
1398
|
+
const k = lyricOnlineCache.keys().next().value
|
|
1399
|
+
if (k !== undefined) lyricOnlineCache.delete(k)
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1381
1403
|
const scan = async (rootPath, kinds = { music: true, books: false }) => {
|
|
1382
1404
|
const target = await ctx.fs.resolve(rootPath)
|
|
1383
1405
|
const info = await ctx.fs.stat(target)
|
|
@@ -1868,6 +1890,13 @@ export function apply(ctx) {
|
|
|
1868
1890
|
const cum = []
|
|
1869
1891
|
let acc = 0
|
|
1870
1892
|
for (const c of chunks) { acc += c.length; cum.push(acc) }
|
|
1893
|
+
// 逐块累积字符偏移:charOffsets[k] = 前 k 个块的字符数之和(charOffsets[0]=0,
|
|
1894
|
+
// charOffsets[total] = 全书总字符数)。浏览器端用它做「已读字符 / 全书字符」的
|
|
1895
|
+
// 实时进度——每块字符数已知,无需合成也能得到稳定的全书占比(时长做不到:
|
|
1896
|
+
// 全书总时长在合成本书之前是不可知的)。
|
|
1897
|
+
const charOffsets = new Array(chunks.length + 1)
|
|
1898
|
+
charOffsets[0] = 0
|
|
1899
|
+
for (let i = 0; i < chunks.length; i++) charOffsets[i + 1] = charOffsets[i] + chunks[i].length
|
|
1871
1900
|
const upperBound = (x) => {
|
|
1872
1901
|
let lo = 0, hi = cum.length - 1, ans = 0
|
|
1873
1902
|
while (lo <= hi) {
|
|
@@ -1889,7 +1918,10 @@ export function apply(ctx) {
|
|
|
1889
1918
|
startLine: s.startLine,
|
|
1890
1919
|
}
|
|
1891
1920
|
})
|
|
1892
|
-
bookMetaCache[absPath] = {
|
|
1921
|
+
bookMetaCache[absPath] = {
|
|
1922
|
+
title: st.title, author: st.author, total: chunks.length,
|
|
1923
|
+
sections, charOffsets, totalChars: charOffsets[chunks.length],
|
|
1924
|
+
}
|
|
1893
1925
|
return bookMetaCache[absPath]
|
|
1894
1926
|
}
|
|
1895
1927
|
|
|
@@ -1973,6 +2005,24 @@ export function apply(ctx) {
|
|
|
1973
2005
|
})
|
|
1974
2006
|
return
|
|
1975
2007
|
}
|
|
2008
|
+
// 手动重扫:重新遍历当前音乐/小说目录并返回最新列表(面板「刷新」按钮调用)。
|
|
2009
|
+
if (pathname === '/dsh-music/rescan' && req.method === 'POST') {
|
|
2010
|
+
await ensureStarted()
|
|
2011
|
+
try {
|
|
2012
|
+
const r = await refresh()
|
|
2013
|
+
const ts = await ttsState()
|
|
2014
|
+
writeJson(res, {
|
|
2015
|
+
ok: true, root: r.root, bookRoot: r.bookRoot,
|
|
2016
|
+
tracks: r.tracks, books: r.books, count: r.count,
|
|
2017
|
+
playlists: publicPlaylists(),
|
|
2018
|
+
ttsConfigured: ts.configured, ttsReason: ts.reason, voices: MIMO_VOICES,
|
|
2019
|
+
qqLoggedIn: !!qq.cookie, qqUin: qqUin(), qqNickname: qq.nickname,
|
|
2020
|
+
})
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
writeJson(res, { ok: false, error: String((err && err.message) || err) }, 500)
|
|
2023
|
+
}
|
|
2024
|
+
return
|
|
2025
|
+
}
|
|
1976
2026
|
// 播放偏好读写(volume/mode/voice/scope/panel-pos/playback/books-playback)。
|
|
1977
2027
|
// GET 返回完整快照;POST 为合并语义:{ prefs: {k:v,...} } 写入,
|
|
1978
2028
|
// { remove: [k,...] } 删除指定键。见 lib/index.js 的 serverPrefs 说明。
|
|
@@ -2135,6 +2185,9 @@ export function apply(ctx) {
|
|
|
2135
2185
|
id, name: book.name, size: book.size,
|
|
2136
2186
|
total: chunks.length,
|
|
2137
2187
|
title: meta.title, author: meta.author, sections: meta.sections,
|
|
2188
|
+
// 逐块累积字符偏移 + 全书总字符:浏览器端据此算「已读字符/全书字符」
|
|
2189
|
+
// 的实时进度条(见 check-book progress)。
|
|
2190
|
+
charOffsets: meta.charOffsets, totalChars: meta.totalChars,
|
|
2138
2191
|
})
|
|
2139
2192
|
return
|
|
2140
2193
|
}
|
|
@@ -2596,6 +2649,61 @@ export function apply(ctx) {
|
|
|
2596
2649
|
}
|
|
2597
2650
|
return
|
|
2598
2651
|
}
|
|
2652
|
+
// ---- 本地歌曲在线歌词兜底(QQ 音乐 → LRCLIB)----
|
|
2653
|
+
// 客户端先请求 /lyric;本地无同名 .lrc 时再请求本端点:先复查本地(幂等),
|
|
2654
|
+
// 无则在线取词(QQ 匿名官方歌词含逐句翻译;LRCLIB 免费同步 LRC),按 track.path
|
|
2655
|
+
// LRU 缓存避免重复请求;无匹配/失败返回 hasLyric:false,播放不受影响。
|
|
2656
|
+
if (pathname === '/dsh-music/lyric/online' && req.method === 'GET') {
|
|
2657
|
+
await ensureStarted()
|
|
2658
|
+
const rawPath = url.searchParams.get('path') || ''
|
|
2659
|
+
if (rawPath === '') { writeJson(res, { ok: false, error: 'missing path' }, 400); return }
|
|
2660
|
+
if (!isAudioName(rawPath) || !isRegisteredAudioPath(rawPath)) { writeJson(res, { ok: false, error: 'forbidden' }, 403); return }
|
|
2661
|
+
// 本地同名 .lrc 优先(幂等:若已出现则直接返回本地,不浪费在线请求)。
|
|
2662
|
+
const lrcPath = findLrcForAudio(rawPath)
|
|
2663
|
+
if (lrcPath !== null) {
|
|
2664
|
+
try {
|
|
2665
|
+
const lrc = parseLrc(readBufToString(readFileSync(lrcPath)))
|
|
2666
|
+
writeJson(res, { ok: true, hasLyric: lrc.length > 0, source: 'local', lrc })
|
|
2667
|
+
} catch (err) {
|
|
2668
|
+
writeJson(res, { ok: false, hasLyric: true, error: String((err && err.message) || err) }, 500)
|
|
2669
|
+
}
|
|
2670
|
+
return
|
|
2671
|
+
}
|
|
2672
|
+
// 在线兜底:LRU 缓存命中直接返回(正命中 6h / 空命中 30min)。
|
|
2673
|
+
const cached = lyricOnlineCacheGet(rawPath)
|
|
2674
|
+
if (cached !== null) { writeJson(res, cached); return }
|
|
2675
|
+
// 搜索关键词:优先客户端给的标题(文件名去扩展名),否则从路径派生命名。
|
|
2676
|
+
let title = (url.searchParams.get('title') || '').trim()
|
|
2677
|
+
if (title === '') {
|
|
2678
|
+
const fn = basename(rawPath)
|
|
2679
|
+
const dot = fn.lastIndexOf('.')
|
|
2680
|
+
title = dot > 0 ? fn.slice(0, dot) : fn
|
|
2681
|
+
}
|
|
2682
|
+
const artist = (url.searchParams.get('artist') || '').trim()
|
|
2683
|
+
const durRaw = Number(url.searchParams.get('duration'))
|
|
2684
|
+
const duration = Number.isFinite(durRaw) && durRaw > 0 ? durRaw : null
|
|
2685
|
+
await loadQQCookie()
|
|
2686
|
+
let payload = { ok: true, hasLyric: false }
|
|
2687
|
+
try {
|
|
2688
|
+
const hit = await getOnlineLyric({ title, artist, duration, qqCookie: qq.cookie })
|
|
2689
|
+
if (hit !== null) {
|
|
2690
|
+
const lrc = parseLrc(hit.lrcText)
|
|
2691
|
+
payload = {
|
|
2692
|
+
ok: true, hasLyric: lrc.length > 0, source: hit.source,
|
|
2693
|
+
lrc, matched: hit.matched,
|
|
2694
|
+
...(hit.transText !== '' ? { trans: parseLrc(hit.transText) } : {}),
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
} catch (err) {
|
|
2698
|
+
payload = { ok: false, error: String((err && err.message) || err) }
|
|
2699
|
+
}
|
|
2700
|
+
// 只缓存成功响应(含空结果):错误/瞬时故障不缓存,避免之后 30 分钟都不再重试。
|
|
2701
|
+
if (payload.ok) {
|
|
2702
|
+
lyricOnlineCacheSet(rawPath, payload, payload.hasLyric ? LYRIC_ONLINE_TTL : LYRIC_ONLINE_TTL_EMPTY)
|
|
2703
|
+
}
|
|
2704
|
+
writeJson(res, payload)
|
|
2705
|
+
return
|
|
2706
|
+
}
|
|
2599
2707
|
// ---- 通用文件流式路由(歌单成员专用,Range/seek)----
|
|
2600
2708
|
// 只放行已登记路径(任一歌单成员 ∪ 曲库扫描集)。直接用 node:fs 读写,
|
|
2601
2709
|
// 避免 DSH ctx.fs 对工作区外路径的围栏(与讲书读取小说文件的方式一致)。
|
package/lib/lyric.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/lyric.js — 本地歌曲「在线歌词兜底」:QQ 音乐 → LRCLIB。
|
|
3
|
+
*
|
|
4
|
+
* 场景:本地音乐没有同名 .lrc 时,先用文件名(可带歌手/时长)在 QQ 匿名接口搜歌、
|
|
5
|
+
* 取官方歌词(含逐句翻译);QQ 无果再查 LRCLIB(免费、无需 key、返回同步 LRC)。
|
|
6
|
+
*
|
|
7
|
+
* 纯 Node(Node ≥ 20,用全局 fetch),无第三方依赖,无编译步骤。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ 合规:歌词文本/翻译版权归著作权人及对应平台所有,仅供个人学习、技术研究、
|
|
10
|
+
* 日常个人试听使用;严禁商业用途、公开传播、二次分发。非官方接口随时可能变更,
|
|
11
|
+
* 所有出网请求带超时,失败一律静默回退(保持「无歌词」的现状,不打扰播放)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as QQ from './qq.js'
|
|
15
|
+
|
|
16
|
+
const LRCLIB_API = 'https://lrclib.net/api'
|
|
17
|
+
const LRCLIB_UA = 'dsh-music-player/1.0 (local lyric fallback; rate-limited+cached)'
|
|
18
|
+
|
|
19
|
+
// 出网统一超时(与 qq.js 同构):Node fetch 默认无超时,端点挂起会让播放流程卡住。
|
|
20
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = 10000) {
|
|
21
|
+
const ctrl = new AbortController()
|
|
22
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
23
|
+
try {
|
|
24
|
+
return await fetch(url, { ...opts, signal: ctrl.signal })
|
|
25
|
+
} finally {
|
|
26
|
+
clearTimeout(timer)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// =====================================================================
|
|
31
|
+
// LRCLIB(https://lrclib.net)— 免费公开歌词 API,无需 key、CORS 开启、返回同步 LRC。
|
|
32
|
+
// 端点:GET /api/search?q= (模糊,返回数组 ≤20 条,无结果返回空数组)
|
|
33
|
+
// GET /api/get/<id> (按 id 取单条)
|
|
34
|
+
// 记录字段:id / trackName / artistName / albumName / duration / instrumental /
|
|
35
|
+
// plainLyrics / syncedLyrics(标准 LRC:[mm:ss.xx] 时间戳 + [ti:]/[ar:] 元数据)
|
|
36
|
+
// 注意:URL 查询参数必须百分号编码(直接放中文会 400)——用 URLSearchParams 构造。
|
|
37
|
+
// =====================================================================
|
|
38
|
+
|
|
39
|
+
// 搜索并归一化为候选数组(只保留带同步歌词的非纯音乐候选;纯音乐没有逐字歌词意义)。
|
|
40
|
+
export async function lrclibSearch(keyword) {
|
|
41
|
+
const p = new URLSearchParams({ q: String(keyword || '') })
|
|
42
|
+
const res = await fetchWithTimeout(`${LRCLIB_API}/search?${p.toString()}`, {
|
|
43
|
+
headers: { Accept: 'application/json', 'User-Agent': LRCLIB_UA },
|
|
44
|
+
})
|
|
45
|
+
if (res.status !== 200) throw new Error('lrclib search http ' + res.status)
|
|
46
|
+
const arr = await res.json().catch(() => null)
|
|
47
|
+
if (!Array.isArray(arr)) throw new Error('lrclib search payload')
|
|
48
|
+
return arr
|
|
49
|
+
.filter((r) => r && typeof r.trackName === 'string'
|
|
50
|
+
&& typeof r.syncedLyrics === 'string' && r.syncedLyrics.trim() !== '')
|
|
51
|
+
.map((r) => ({
|
|
52
|
+
id: r.id,
|
|
53
|
+
title: r.trackName,
|
|
54
|
+
artist: r.artistName || '',
|
|
55
|
+
duration: Number(r.duration),
|
|
56
|
+
instrumental: !!r.instrumental,
|
|
57
|
+
synced: r.syncedLyrics,
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =====================================================================
|
|
62
|
+
// 匹配打分:把候选(标题/歌手/时长/是否纯音乐)与期望值比对,返回 0..100 置信度。
|
|
63
|
+
// 标题为主信号(精确 60 / 包含 42 / 分词重叠),歌手为强副信号(精确 30 / 包含 20),
|
|
64
|
+
// 时长相近加分,纯音乐重罚。仅当分数 ≥ MIN_SCORE 才采信,避免「错配歌词」比「没有歌词」更糟。
|
|
65
|
+
// 导出供测试。
|
|
66
|
+
// =====================================================================
|
|
67
|
+
|
|
68
|
+
// 归一化用于比对:小写、去空白与常见分隔/标点/括号/引号。
|
|
69
|
+
export function normalizeText(s) {
|
|
70
|
+
return String(s || '')
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[\s\u3000\-_/\\,.;:,。;:!?!?·'"“”‘’()()\[\]【】《》〈〉<>「」『』…—~~]+/g, '')
|
|
73
|
+
.trim()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 轻量分词:拉丁词 + CJK 二元组(2-gram),供「包含」都不满足时做重叠匹配。
|
|
77
|
+
function tokenize(s) {
|
|
78
|
+
const out = []
|
|
79
|
+
for (const w of s.match(/[a-z0-9]+/g) || []) out.push(w)
|
|
80
|
+
const cjk = s.replace(/[a-z0-9]+/g, '')
|
|
81
|
+
for (let i = 0; i + 1 < cjk.length; i++) out.push(cjk.slice(i, i + 2))
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function scoreCandidate(cand, want) {
|
|
86
|
+
const wt = normalizeText(want && want.title)
|
|
87
|
+
const wa = want && want.artist ? normalizeText(want.artist) : ''
|
|
88
|
+
const ct = normalizeText(cand && cand.title)
|
|
89
|
+
const ca = cand && cand.artist ? normalizeText(cand.artist) : ''
|
|
90
|
+
let score = 0
|
|
91
|
+
if (wt !== '' && ct !== '') {
|
|
92
|
+
if (ct === wt) score += 60
|
|
93
|
+
else if (ct.includes(wt) || wt.includes(ct)) score += 42
|
|
94
|
+
else {
|
|
95
|
+
const wts = tokenize(wt); const cts = tokenize(ct)
|
|
96
|
+
const inter = wts.filter((t) => cts.includes(t)).length
|
|
97
|
+
if (inter > 0) score += Math.min(40, inter * 12)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (wa !== '' && ca !== '') {
|
|
101
|
+
if (ca === wa) score += 30
|
|
102
|
+
else if (ca.includes(wa) || wa.includes(ca)) score += 20
|
|
103
|
+
} else if (wa === '') {
|
|
104
|
+
score += 5 // 未知歌手:中性偏袒(不惩罚,也不确认)
|
|
105
|
+
}
|
|
106
|
+
const cd = Number(cand && cand.duration)
|
|
107
|
+
const wd = Number(want && want.duration)
|
|
108
|
+
if (Number.isFinite(cd) && cd > 0 && Number.isFinite(wd) && wd > 0) {
|
|
109
|
+
const d = Math.abs(cd - wd)
|
|
110
|
+
if (d <= 5) score += 12
|
|
111
|
+
else if (d <= 15) score += 6
|
|
112
|
+
}
|
|
113
|
+
if (cand && cand.instrumental) score -= 40
|
|
114
|
+
return score
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 在候选中挑最高分者;返回 { best, score }(best 可为 null)。
|
|
118
|
+
export function pickBest(candidates, want) {
|
|
119
|
+
let best = null, bestScore = -1
|
|
120
|
+
for (const c of candidates) {
|
|
121
|
+
const s = scoreCandidate(c, want)
|
|
122
|
+
if (s > bestScore) { bestScore = s; best = c }
|
|
123
|
+
}
|
|
124
|
+
return { best, score: bestScore }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 采信阈值:标题精确(60)即接受;「包含 + 时长接近」也可达 ~54;含糊不取。
|
|
128
|
+
const MIN_SCORE = 52
|
|
129
|
+
|
|
130
|
+
// =====================================================================
|
|
131
|
+
// 在线兜底主入口:依次尝试 QQ 音乐 → LRCLIB。
|
|
132
|
+
// 入参 { title, artist, duration?, qqCookie? };返回统一结构或 null:
|
|
133
|
+
// { source: 'qq'|'lrclib', matched: {title, artist, songmid?|id?, score},
|
|
134
|
+
// lrcText, transText } // transText 仅 QQ(逐句翻译,可为空串)
|
|
135
|
+
// 两个源都失败/无好匹配时返回 null(调用方按「无歌词」处理)。
|
|
136
|
+
// =====================================================================
|
|
137
|
+
export async function getOnlineLyric({ title = '', artist = '', duration = null, qqCookie = '' } = {}) {
|
|
138
|
+
const want = {
|
|
139
|
+
title: String(title || '').trim(),
|
|
140
|
+
artist: String(artist || '').trim(),
|
|
141
|
+
duration: Number.isFinite(duration) ? duration : null,
|
|
142
|
+
}
|
|
143
|
+
if (want.title === '') return null
|
|
144
|
+
|
|
145
|
+
// 1) QQ 音乐(匿名搜索 + 官方歌词,可带逐句翻译)
|
|
146
|
+
try {
|
|
147
|
+
const kw = want.artist !== '' ? want.title + ' ' + want.artist : want.title
|
|
148
|
+
const s = await QQ.search(kw, qqCookie, 1)
|
|
149
|
+
const cands = (s.results || []).map((r) => ({
|
|
150
|
+
title: r.title,
|
|
151
|
+
artist: (r.artists || []).join('/'),
|
|
152
|
+
duration: Number(r.interval),
|
|
153
|
+
songmid: r.songmid,
|
|
154
|
+
instrumental: false,
|
|
155
|
+
}))
|
|
156
|
+
const { best, score } = pickBest(cands, want)
|
|
157
|
+
if (best && best.songmid && score >= MIN_SCORE) {
|
|
158
|
+
const g = await QQ.getLyric(best.songmid, qqCookie)
|
|
159
|
+
const lrcText = (g.lyric || '').trim()
|
|
160
|
+
if (lrcText !== '') {
|
|
161
|
+
return {
|
|
162
|
+
source: 'qq',
|
|
163
|
+
matched: { title: best.title, artist: best.artist, songmid: best.songmid, score },
|
|
164
|
+
lrcText,
|
|
165
|
+
transText: (g.trans || '').trim(),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch { /* QQ 失败/无匹配 → 尝试下一源 */ }
|
|
170
|
+
|
|
171
|
+
// 2) LRCLIB(免费、无需 key、同步 LRC)
|
|
172
|
+
try {
|
|
173
|
+
const cands = await lrclibSearch(want.title)
|
|
174
|
+
const { best, score } = pickBest(cands, want)
|
|
175
|
+
if (best && best.synced && score >= MIN_SCORE) {
|
|
176
|
+
return {
|
|
177
|
+
source: 'lrclib',
|
|
178
|
+
matched: { title: best.title, artist: best.artist, id: best.id, score },
|
|
179
|
+
lrcText: best.synced.trim(),
|
|
180
|
+
transText: '',
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch { /* 失败静默 */ }
|
|
184
|
+
|
|
185
|
+
return null
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-music-player",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|