dsh-music-player 1.0.2 → 1.0.3

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 CHANGED
@@ -16,6 +16,7 @@ DeepSeek Harness 音乐/小说播放插件。
16
16
  - **实时歌词/字幕**:本地歌曲自动显示同步歌词——优先读取文件**内嵌歌词**(FLAC `LYRICS`/MP3 `USLT` 标签,无需额外 `.lrc`),其次同名 `.lrc`,都没有时自动在线匹配;在线 QQ 歌曲显示官方歌词(外语歌带逐句翻译);AI 讲书时显示当前朗读句子。**单击播放条歌词**可打开完整歌词/字幕面板(标识当前进度、可拖动/拉伸、位置独立记忆),面板默认开启**透明模式**——歌词像直接悬浮在页面上,可在「系统配置」关闭
17
17
  - 播放时防止电脑熄屏/休眠(需浏览器支持,如 Chrome/Edge)
18
18
  - **播放条位置可换**:在「系统配置 → 播放条位置」(配置页最后一项)里三选一——**输入框上方**(默认)/ **页面底部** / **悬浮可拖动**。悬浮条是紧凑的三行卡片(歌词整行 / 歌名+频谱 / 播放控制,行高固定不随歌词有无跳动),整条可以直接拖着挪地方,位置会记住(配置页可「重置位置」回到默认右下角);改到底部/悬浮后,播放条不再挂在输入框区域,因此对话里的询问弹窗(会接管输入区)也不会再把它一起盖住——弹窗开着照样能暂停/切歌
19
+ - **滚轮调音量**:鼠标停在音量条上直接滚轮即可加减音量,按滚动快慢分两档步进——**慢速滚动每次 1%**(细调)、**快速滚动每次 5%**(快速拉大/拉小);上滚加、下滚减,滚轮被音量条消费,不会连带把外层对话滚走。鼠标滚轮慢慢拨与触控板细碎轻推都算慢速,鼠标快速连拨、触控板用力快滑算快速(阈值见 `lib/client.js` 的 `VOL_WHEEL_*` 常量)
19
20
  - 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
20
21
  - AI 讲书:本地 `.txt` / `.epub` 小说 AI 语音朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转(打开即定位到当前正在播放的章节)、章节切换,可选 4 种中文 AI 声音(默认白桦)
21
22
  - `music_play` 模型工具:agent 可按关键词播放本地音乐,也可按小说名启动 AI 讲书
package/lib/client.js CHANGED
@@ -57,7 +57,12 @@ window.__ModuleLoader__.load({
57
57
  const half = Math.min(maxW / 2, vw / 2 - margin);
58
58
  const left = Math.max(margin + half, Math.min(vw - margin - half, clampLeft));
59
59
  if (estH > 0 && r && r.height > 0 && r.top - 6 - estH < margin) {
60
- return { position: 'fixed', left: Math.round(left), top: Math.round(r.bottom + 6), transform: 'translate(-50%, 0)' };
60
+ // 翻到锚点下方时必须**显式 bottom:'auto'**:弹层基础 CSS 里还留着
61
+ // `bottom: calc(100% + 6px)`(它们当年是 bar 内部的 absolute 弹层)。固定高度的弹层
62
+ // 靠「over-constrained 忽略 bottom」侥幸正常,而 height:auto 的弹层(讲书音量弹窗)
63
+ // 会把 top 与 bottom 一起算 → 高度算成负值 → 盒子塌成 0、背景卡片消失、内容溢出。
64
+ // 显式清掉后由内联 top 独占定位。
65
+ return { position: 'fixed', left: Math.round(left), top: Math.round(r.bottom + 6), bottom: 'auto', transform: 'translate(-50%, 0)' };
61
66
  }
62
67
  const top = (r && r.height > 0) ? Math.max(0, r.top - 6) : vh - 40;
63
68
  return { position: 'fixed', left: Math.round(left), top: Math.round(top), transform: 'translate(-50%, -100%)' };
@@ -89,9 +94,13 @@ window.__ModuleLoader__.load({
89
94
  // 顶边贴按钮底 + 6px,高度改用下方可用空间,避免弹窗顶出视口被裁。
90
95
  if (availAbove < 160) {
91
96
  const availBelow = Math.max(120, vh - r.bottom - 6 - topGap);
97
+ // bottom:'auto' 的理由同 anchorAbove:这两个弹层(章节目录 / 讲书音量)是 height:auto,
98
+ // 基础 CSS 的 `bottom: calc(100% + 6px)` 不减掉的话,top 与 bottom 会一起参与计算,
99
+ // 盒子高度算成负值 → 卡片壳塌成 0、只剩溢出的内容(悬浮条被拖到页面顶部时必现)。
92
100
  return {
93
101
  ...base,
94
102
  top: Math.round(r.bottom + 6),
103
+ bottom: 'auto',
95
104
  maxHeight: Math.round(Math.min(60 * vh / 100, 480, availBelow)),
96
105
  };
97
106
  }
@@ -980,7 +989,7 @@ window.__ModuleLoader__.load({
980
989
  if (cmd === 'kgPlay') { startKGPlayback(a.song, a.queue, a.label, a.from); return; }
981
990
  if (cmd === 'ncPlay') { startNCPlayback(a.song, a.queue, a.label, a.from); return; }
982
991
  if (cmd === 'radioPlay') { startRadioPlayback(a.station); return; }
983
- if (cmd === 'newsPlay') { playNewsFrom(a.id, Number(a.from) || 0); return; }
992
+ if (cmd === 'newsPlay') { playNewsFrom(a.id, Number(a.from) || 0, !!a.whole); return; }
984
993
  }
985
994
  function xtabHandle(ev) {
986
995
  const m = ev && ev.data;
@@ -1326,7 +1335,10 @@ window.__ModuleLoader__.load({
1326
1335
  .then((r) => r.json().then((d) => ({ d, status: r.status })).catch(() => ({ d: null, status: r.status })))
1327
1336
  .then(({ d, status }) => {
1328
1337
  if (d && d.ok) {
1329
- set({ qqFaved: !!d.faved });
1338
+ // 成功即清掉上一次失败的红色报错(面板顶部那条 dsh-music-error):
1339
+ // 失败留下的报错只由「播放」清(onPlay 里 set({error:null})),重试成功却不清
1340
+ // 会一直挂在面板顶部,像是操作失败了。
1341
+ set({ qqFaved: !!d.faved, error: null });
1330
1342
  // 同步本地「我喜欢」id/mid 集合,便于后续曲目判断(并持久化兜底)。
1331
1343
  if (sid || smid) {
1332
1344
  const ids = new Set(store.qqFavIds || []);
@@ -1504,7 +1516,8 @@ window.__ModuleLoader__.load({
1504
1516
  .then(({ d, status }) => {
1505
1517
  if (isKgAuthDead(d)) markKgAuthDead(d.error);
1506
1518
  if (d && d.ok) {
1507
- set({ kgFaved: action === 'add' });
1519
+ // QQ:重试成功即清掉上一次失败的红色报错。
1520
+ set({ kgFaved: action === 'add', error: null });
1508
1521
  // 同步本地「我喜欢」hash 集合 + fileId 映射,便于后续曲目判断。
1509
1522
  const hashes = new Set(store.kgFavHashes || []);
1510
1523
  const files = (store.kgFavFiles || []).slice();
@@ -1550,7 +1563,7 @@ window.__ModuleLoader__.load({
1550
1563
  body: JSON.stringify({ action: faved ? 'remove' : 'add', station: st }),
1551
1564
  }).then((r) => r.json()).then((d) => {
1552
1565
  if (d && d.ok) {
1553
- set({ radioFavs: (d.favs || []).slice(), radioFavLoaded: true });
1566
+ set({ radioFavs: (d.favs || []).slice(), radioFavLoaded: true, error: null });
1554
1567
  } else set({ error: (d && d.error) || '收藏操作失败' });
1555
1568
  }).catch(() => set({ error: '收藏操作失败,请重试' }));
1556
1569
  }
@@ -1886,6 +1899,21 @@ window.__ModuleLoader__.load({
1886
1899
  const consumed = offsets[k] + (offsets[k + 1] - offsets[k]) * frac;
1887
1900
  return consumed > 0 ? Math.max(0, Math.min(1, consumed / total)) : 0;
1888
1901
  }
1902
+ // 指定块「起点」的进度(0..1):切块/跳类/跳条目/换声音时用作进度条的种子值。
1903
+ // 旧实现是在这些时刻把 bookProgress 直接清 0,等下一块首个 timeupdate 才跳回
1904
+ // offsets[k]/total——期间细线会塌到 0%(下一块若在冷合成「AI 合成中…」,整个
1905
+ // 等待期都停在 0%)。用块起点做种子可以做到:自动切块时与上一块末端的值无缝相接
1906
+ // (不闪、不缩),跳转时直接显示目标位置而不是先归零再跳。
1907
+ // meta 尚未缓存(全新期次首次播放)时返回 0,与旧行为一致,不阻塞播放。
1908
+ function bookProgressAtChunk(id, from) {
1909
+ const meta = id !== null ? bookMetaCache.get(id) : undefined;
1910
+ const offsets = meta && Array.isArray(meta.charOffsets) ? meta.charOffsets : [];
1911
+ if (offsets.length < 2) return 0;
1912
+ const total = offsets[offsets.length - 1];
1913
+ if (!(total > 0)) return 0;
1914
+ const k = Math.max(0, Math.min(offsets.length - 2, Number(from) || 0));
1915
+ return Math.max(0, Math.min(1, offsets[k] / total));
1916
+ }
1889
1917
  // 卡拉OK帧级驱动:timeupdate 只有 ~4Hz——行切换平均迟到 125ms、最差 250ms,
1890
1918
  // 观感就是「每句歌词慢半拍」。这里提供 karaokeFrame(),挂在既有的可视化 rAF
1891
1919
  // 循环(startRaf)里每帧执行:播放中直接读 audio.currentTime 定位行,换行延迟
@@ -2874,6 +2902,9 @@ window.__ModuleLoader__.load({
2874
2902
  // <audio> error 时自动跳到下一首;连续跳过次数达到队列长度(整列都试过)
2875
2903
  // 即停止报错——且停止后不再 step,杜绝无限循环跳歌。成功播放(onPlay)清零。
2876
2904
  let qqErrorSkipCount = 0;
2905
+ // 本地曲目连续解码/加载失败的跳过次数:与 qqErrorSkipCount 同理,避免整库坏文件时
2906
+ // 无限跳歌。成功播放(onPlaying)清零。
2907
+ let localErrorSkipCount = 0;
2877
2908
  function buildShuffleQueue(anchorId) {
2878
2909
  const ids = activeIds();
2879
2910
  // Fisher-Yates
@@ -3018,6 +3049,16 @@ window.__ModuleLoader__.load({
3018
3049
  const e = newsById(id);
3019
3050
  return e === null ? null : { id: e.id, name: e.title, url: '/dsh-music/news/' + e.id, path: '', isNews: true };
3020
3051
  };
3052
+ // 按「播放条主文案」(本地书=文件名,新闻期次=期次标题)反查虚拟书。
3053
+ // 每本讲书的续播记录就是按这个名字存的(见 saveCurrentBookPlayback),刷新恢复要用它
3054
+ // 把记录还原成可播放的 book 对象——新闻期次不在 store.books(曲库)里,必须单独兜一层。
3055
+ const bookByName = (name) => {
3056
+ if (typeof name !== 'string' || name === '') return null;
3057
+ const local = (store.books || []).find((b) => b.name === name);
3058
+ if (local !== undefined) return local;
3059
+ const e = (newsEditions || []).find((x) => x && x.title === name);
3060
+ return e === undefined ? null : { id: e.id, name: e.title, url: '/dsh-music/news/' + e.id, path: '', isNews: true };
3061
+ };
3021
3062
  const markNewsPlayed = (id) => {
3022
3063
  fetch('/dsh-music/news/played', {
3023
3064
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }),
@@ -3025,13 +3066,22 @@ window.__ModuleLoader__.load({
3025
3066
  .catch(() => {});
3026
3067
  };
3027
3068
  // 从新闻期次某块起播(条目跳播/类别跳播/整期播放都归到这里)。
3028
- function playNewsFrom(id, from) {
3069
+ // `whole` =「播放整期」语义(面板上的整期 ▶、定时任务/agent 的播报意图)。
3070
+ // 已在播同一期时**不重复播放**:整期 ▶ 被再次点击(或意图重复投递)不该把正在听的
3071
+ // 期次从头重播、让听众丢掉当前位置;目标块与当前块相同的条目/类别跳转同理(那只是
3072
+ // 又点了一次正在播的那一条)。真正的「从这一条/这一类重新开始」仍照常跳转。
3073
+ function playNewsFrom(id, from, whole) {
3074
+ const f = from || 0;
3029
3075
  // 多标签页:镜像态下由出声页来播这一期(否则会出现「本页在念新闻、出声页还在放歌」)。
3030
- if (xtabForward('newsPlay', { id, from: from || 0 })) return;
3076
+ // 守卫交给出声页判定——块号(bookFromRef)不跨页同步,只有出声页知道真实位置。
3077
+ if (xtabForward('newsPlay', { id, from: f, whole: !!whole })) return;
3078
+ if (store.currentId === 'book:' + id && (whole || f === bookFromRef)) return;
3031
3079
  void loadNewsEditions().then((list) => {
3032
3080
  if (!list.some((e) => e.id === id)) return;
3081
+ // 列表回来时这一期可能已经被上一次点击起播了(连点/双击两次单击):同样不重复播放。
3082
+ if (store.currentId === 'book:' + id && (whole || f === bookFromRef)) return;
3033
3083
  markNewsPlayed(id);
3034
- playBook(id, from || 0);
3084
+ playBook(id, f);
3035
3085
  });
3036
3086
  }
3037
3087
  const bookById = (id) => (store.books || []).find((b) => b.id === id) || newsBookById(id) || null;
@@ -3217,6 +3267,10 @@ window.__ModuleLoader__.load({
3217
3267
  let restoredMusicPos = null; // restored music position to display until the audio truly reaches it
3218
3268
  let newsResume = null; // 新闻打断快照:{ newsId, kind, id? }(新闻播完自动切回原内容;null=无快照)
3219
3269
  let bookRestorePos = -1; // restored book's in-chunk position, seeked on play
3270
+ // 换声音的块内续播点:{ from, pos }(只对 from 这一块有效;null=无)。
3271
+ // 与 bookRestorePos 刻意分开——那个带「恢复点越界就跳下一块」的 pastEnd 语义,适合
3272
+ // 刷新续播;换声音时新合成的块时长会变,沿用它会直接吞掉本条新闻的剩余内容。
3273
+ let voiceResume = null;
3220
3274
  // 刚发生过 ended→切块:其后到达的重复 ended 属于旧块陈旧事件(吞掉);
3221
3275
  // 下一块真正开始播放(play promise 兑现)或用户主动重播/停止时清除。
3222
3276
  let bookJustAdvanced = false;
@@ -3362,7 +3416,13 @@ window.__ModuleLoader__.load({
3362
3416
  // near-instant (server-side synthesis cache) so we don't flash a spinner
3363
3417
  // at every chunk boundary. Only user-initiated plays show it.
3364
3418
  const showBuffer = !silent;
3365
- set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, currentSection: '', bookToc: [], currentQuality: '', bookProgress: 0, scope: { kind: 'book' }, bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0 });
3419
+ // 进度条种子(见 bookProgressAtChunk):切块时显示新块起点,而不是先归零再跳。
3420
+ // 换声音(同一块的重新合成)例外——内容位置没变,保持当前显示不回退,真正的
3421
+ // 位置随后由 applyVoiceResume 的 seek + timeupdate 校正。
3422
+ const seedProgress = (voiceResume !== null && voiceResume.from === from)
3423
+ ? Math.max(bookProgressAtChunk(id, from), store.bookProgress)
3424
+ : bookProgressAtChunk(id, from);
3425
+ set({ currentId: 'book:' + id, currentName: book.name, currentArtists: [], pendingId: null, pendingName: null, error: null, currentSection: '', bookToc: [], currentQuality: '', bookProgress: seedProgress, scope: { kind: 'book' }, bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0 });
3366
3426
  // Load the chapter structure for the toc + the current-section label (the
3367
3427
  // current section is derived from the chunk index once the meta arrives).
3368
3428
  const startFrom = from;
@@ -3370,6 +3430,13 @@ window.__ModuleLoader__.load({
3370
3430
  if (meta !== null && meta.sections.length > 0) {
3371
3431
  set({ currentSection: sectionForChunk(meta.sections, startFrom) });
3372
3432
  }
3433
+ // meta 是这一步才到位的(本会话从未缓存过该书的 charOffsets → 上面种子的
3434
+ // 计算结果是 0):补一次块起点进度,让它立即显示正确位置而不是停在 0%。
3435
+ // 只在严格前进时写(bp > 当前值),因此绝不会把已经播放推进的显示拉回去。
3436
+ if (currentBookId() === id && bookFromRef === startFrom) {
3437
+ const bp = bookProgressAtChunk(id, startFrom);
3438
+ if (bp > store.bookProgress) set({ bookProgress: bp });
3439
+ }
3373
3440
  });
3374
3441
  set(wasFresh
3375
3442
  ? { bookBuffering: true, bookBufferingSilent: !showBuffer, bookError: '', bookBufferingSince: Date.now() }
@@ -3460,7 +3527,7 @@ window.__ModuleLoader__.load({
3460
3527
  }
3461
3528
  }
3462
3529
  // `from` lets the toc jump straight to a chapter's chunk index.
3463
- function playBook(id, from = 0) { if (xtabForward('playBook', { id, from })) return; unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookAutoRetried = false; bookJustAdvanced = false; clearBookUnwedge(); clearBookGrace(); bookPlaySeq++; bookFallbackFor = ''; revokeBookBlob(); playBookFrom(id, from, false); saveCurrentBookPlayback(); }
3530
+ function playBook(id, from = 0) { if (xtabForward('playBook', { id, from })) return; unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; voiceResume = null; bookAutoRetried = false; bookJustAdvanced = false; clearBookUnwedge(); clearBookGrace(); bookPlaySeq++; bookFallbackFor = ''; revokeBookBlob(); playBookFrom(id, from, false); saveCurrentBookPlayback(); }
3464
3531
  // Play a novel from its saved progress when available (e.g. switching to
3465
3532
  // music and back), otherwise start fresh from the beginning. Explicit
3466
3533
  // chapter jumps keep using playBook(id, fromChunk) and are unaffected.
@@ -3541,6 +3608,7 @@ window.__ModuleLoader__.load({
3541
3608
  // 恢复定位钉只属于「被恢复的那一块」:切到下一块必须丢弃,否则下一块的
3542
3609
  // 音频会被 seek 回上一块的恢复位置(跳到末尾即刻结束 → 卡住/无声音/字幕不动)。
3543
3610
  bookRestorePos = -1;
3611
+ voiceResume = null; // 换声音的续播点同样只属于那一块
3544
3612
  restoredMusicPos = null;
3545
3613
  const endedDur = Number.isFinite(audio.duration) ? audio.duration : (audio.currentTime || 0);
3546
3614
  if (Number.isFinite(endedDur)) bookBaseTime += endedDur;
@@ -3579,6 +3647,30 @@ window.__ModuleLoader__.load({
3579
3647
  // 关键:onDur(durationchange)也调用——只挂在 timeupdate 上时,若元素被钳在
3580
3648
  // 块尾(不再播放、不再派发 timeupdate),pastEnd 补救永远不会被评估,续播就会
3581
3649
  // 永久卡在「没声音、字幕不动」。
3650
+
3651
+ // 换声音的块内续播:新声音的块就绪后把播放位置 seek 回断点,而不是从本条新闻的
3652
+ // 开头重播。与 bookRestorePos(刷新续播)刻意分开:那个带着「恢复点越界就跳下一块」
3653
+ // 的 pastEnd 语义——换声音时新合成的块时长会变,沿用它会直接吞掉本条新闻的剩余内容。
3654
+ // 这里越界只夹到块尾(最坏情况=本条重播一遍),绝不前进块号。
3655
+ // 只尝试一次:本环境对讲书 WAV 的 seek 可能不生效,反复 seek 会形成紧密循环
3656
+ // (seek→timeupdate→seek…,实测 ct 永远停在 0.00、无声、字幕不动)。
3657
+ // 返回 true 表示「刚 seek 过」——调用方应跳过本帧的位置/进度更新,让 post-seek 的
3658
+ // timeupdate 去校正(否则会把 seek 前的旧位置写回显示)。
3659
+ function applyVoiceResume() {
3660
+ const vr = voiceResume;
3661
+ if (vr === null) return false;
3662
+ if (!String(store.currentId || '').startsWith('book:') || bookFromRef !== vr.from) {
3663
+ voiceResume = null; // 已切到别的块/别的书:本次续播点作废
3664
+ return false;
3665
+ }
3666
+ const dur = (Number.isFinite(audio.duration) && audio.duration > 0) ? audio.duration : 0;
3667
+ if (!(dur > 0)) return false; // 时长未知:留给 onDur / 下一帧
3668
+ voiceResume = null; // 一次性:无论 seek 是否生效都不再重试
3669
+ const target = Math.max(0, Math.min(vr.pos, Math.max(0, dur - 0.25)));
3670
+ if (target < 0.2) return false; // 断点本来就在块首:无需 seek
3671
+ try { audio.currentTime = target; } catch (e) {}
3672
+ return true;
3673
+ }
3582
3674
  function evaluateBookRestorePin() {
3583
3675
  if (!(bookRestorePos >= 0) || !String(store.currentId || '').startsWith('book:')) return false;
3584
3676
  const ct = audio.currentTime || 0;
@@ -3624,7 +3716,7 @@ window.__ModuleLoader__.load({
3624
3716
  bookPlaySeq++;
3625
3717
  bookFallbackFor = '';
3626
3718
  revokeBookBlob();
3627
- bookTotal = -1; bookFromRef = 0; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookJustAdvanced = false;
3719
+ bookTotal = -1; bookFromRef = 0; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookJustAdvanced = false; voiceResume = null;
3628
3720
  }
3629
3721
  function togglePlay() {
3630
3722
  // 多标签页:镜像态下播放/暂停归出声页——本页的 <audio> 是暂停的,直接执行会
@@ -3895,6 +3987,10 @@ window.__ModuleLoader__.load({
3895
3987
  set({ voiceSwitching: false });
3896
3988
  if (currentBookId() === id && bookFromRef === from) {
3897
3989
  unlockAutoplay();
3990
+ // 断点取「旧声音真正停下的这一刻」(合成期间旧声音一直在播,所以不能在
3991
+ // 函数入口取)+ 新块就绪后由 applyVoiceResume 把位置 seek 回去:换声音
3992
+ // 不再从本条新闻开头重播。
3993
+ voiceResume = { from, pos: audio.currentTime || 0 };
3898
3994
  playBookFrom(id, from, true);
3899
3995
  }
3900
3996
  })
@@ -3990,6 +4086,9 @@ window.__ModuleLoader__.load({
3990
4086
  // 讲书:按「已读字符/全书字符」实时推进全书进度条。不用 position/duration 的
3991
4087
  // 时间占比——那是当前块内比例,切块会回退;且全书总时长在合成本书前不可知。
3992
4088
  if (String(store.currentId).startsWith('book:')) {
4089
+ // 换声音的兜底:onDur 没到达时(个别环境 duration 早就绪)也要把位置 seek 回
4090
+ // 断点。刚 seek 过则跳过本帧更新,避免把 seek 前的旧位置写回进度条。
4091
+ if (voiceResume !== null && applyVoiceResume()) return;
3993
4092
  const bp = bookProgressFor();
3994
4093
  if (bp !== store.bookProgress) set({ bookProgress: bp });
3995
4094
  }
@@ -4056,6 +4155,8 @@ window.__ModuleLoader__.load({
4056
4155
  // 钳到块尾、不再播放、不再派发 timeupdate 时永远轮不到补救(冷启动续播
4057
4156
  // 「响一下→没声音、字幕不动」的残留卡死点)。
4058
4157
  if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:')) evaluateBookRestorePin();
4158
+ // 换声音:时长一就绪就把位置 seek 回块内断点(只试一次,见 applyVoiceResume)。
4159
+ if (voiceResume !== null) applyVoiceResume();
4059
4160
  set({ duration: (bookTimeBase() + audio.duration) });
4060
4161
  }
4061
4162
  };
@@ -4187,6 +4288,20 @@ window.__ModuleLoader__.load({
4187
4288
  markOnlineAuthFailed('nc', '网易云音乐');
4188
4289
  return;
4189
4290
  }
4291
+ // 本地曲目解码/加载失败:最常见的原因是**文件末帧被截断**——Chrome 改用 Rust
4292
+ // 写的 Symphonia 解码器后比旧解码器严格,最后一个不完整帧会直接报
4293
+ // MEDIA_ERR_DECODE("malformed stream: mpa: invalid packet length"),
4294
+ // 表现为「放到 99.9% 突然停住、不切下一首」。
4295
+ // 一个坏文件不该终止整个播放:自动跳到下一首继续;连续跳过次数达到当前队列
4296
+ // 长度(整列都是坏文件)才停下报错,且停下后不再 step,杜绝无限跳歌。
4297
+ if (!isOnlineTrack) {
4298
+ const localQueueLen = activeIds().length;
4299
+ if (localQueueLen > 1 && localErrorSkipCount < localQueueLen) {
4300
+ localErrorSkipCount++;
4301
+ step(1);
4302
+ return;
4303
+ }
4304
+ }
4190
4305
  set({ error: '音频加载或解码失败', playing: false });
4191
4306
  }
4192
4307
  };
@@ -4204,7 +4319,7 @@ window.__ModuleLoader__.load({
4204
4319
  // 时清零——绝不能挂在 'play' 上:play() 把 paused 置 false 就会派发 'play',加载失败
4205
4320
  // 的源同样会先收到它,用它清零会让计数每轮归零,整列曲目全失败时无限回绕切歌
4206
4321
  // (实测:QQ 登录过期后整列 VIP 曲目 404,播放条无限循环切歌、不报错、停不下来)。
4207
- const onPlaying = () => { if (xtabMirroring) return; qqErrorSkipCount = 0; if (!String(store.currentId || '').startsWith('book:')) setupLiveViz(); resumeVizCtx(); };
4322
+ const onPlaying = () => { if (xtabMirroring) return; qqErrorSkipCount = 0; localErrorSkipCount = 0; if (!String(store.currentId || '').startsWith('book:')) setupLiveViz(); resumeVizCtx(); };
4208
4323
  audio.addEventListener('playing', onPlaying);
4209
4324
  return () => {
4210
4325
  audio.pause();
@@ -4356,6 +4471,9 @@ window.__ModuleLoader__.load({
4356
4471
  savePref(PREF_PLAYBACK_NC, JSON.stringify({ ...saved, ts: Date.now() }));
4357
4472
  }
4358
4473
 
4474
+ // 新闻期次恢复的重试守卫:记下「已经为哪个名字补拉过 /news 列表」,每个名字至多一次,
4475
+ // 避免期次已删除时每次恢复都发一轮请求。
4476
+ let newsRestoreRetryName = null;
4359
4477
  // Restore a novel's playback after a refresh (or when resuming a book):
4360
4478
  // same book, same chunk, same cumulative clock, and same in-chunk position —
4361
4479
  // paused (tap ▶ to resume), matching how music is restored. When targetName
@@ -4373,8 +4491,25 @@ window.__ModuleLoader__.load({
4373
4491
  }
4374
4492
  }
4375
4493
  if (entry === null || !name) return;
4376
- const book = store.books.find((b) => b.name === name);
4377
- if (book === undefined) return; // the book is no longer in the library
4494
+ // 名字 虚拟书:本地讲书(store.books)或**新闻期次**(期次标题,见 bookByName)。
4495
+ const book = bookByName(name);
4496
+ if (book === null) {
4497
+ // 新闻期次列表是异步拉取的,而刷新恢复跑在 /manifest 之后、面板拉 /news 之前——
4498
+ // 这时 bookByName 找不到期次标题。拉一次期次列表再重试(每个名字至多一次,
4499
+ // 避免「期次已删除」时反复请求)。
4500
+ if (newsRestoreRetryName !== name) {
4501
+ newsRestoreRetryName = name;
4502
+ void loadNewsEditions().then((list) => {
4503
+ if (newsRestoreRetryName !== name) return; // 已被后续恢复取代
4504
+ // 请求期间用户已经自己播了别的(currentId 非空)→ 不抢走播放器。
4505
+ if (store.currentId !== null) { newsRestoreRetryName = null; return; }
4506
+ if (!(list || []).some((e) => e && e.title === name)) { newsRestoreRetryName = null; return; }
4507
+ restoreBookPlayback(name);
4508
+ });
4509
+ }
4510
+ return; // 不在曲库也不是现存的期次:放弃(书/期次已删)
4511
+ }
4512
+ newsRestoreRetryName = null;
4378
4513
  const from = Number.isFinite(entry.from) && entry.from >= 0 ? entry.from : 0;
4379
4514
  const base = Number.isFinite(entry.base) ? entry.base : 0;
4380
4515
  const pos = Number.isFinite(entry.pos) ? entry.pos : 0;
@@ -4730,6 +4865,20 @@ window.__ModuleLoader__.load({
4730
4865
  const StopIcon = iconSvg('M6 6h12v12H6z');
4731
4866
 
4732
4867
  // ---- components ----
4868
+ // ---- 音量滚轮调速参数 ----
4869
+ // 在音量条上滚动滚轮即可调音量,步进按滚动快慢分两档:慢速 1%、快速 5%。
4870
+ // 「快慢」同时看两个信号——连续两次滚轮事件的间隔、以及单次滚动量——这样鼠标
4871
+ // 滚轮和触控板都能得到符合直觉的结果:
4872
+ // - 鼠标滚轮:一格约 100(像素模式),慢慢拨 → 间隔大 → 慢速 1%;快速连拨 → 5%。
4873
+ // - 触控板:轻推是每帧 1~5 的细碎增量,即使事件密集(间隔很小)也判为慢速(1%);
4874
+ // 用力快滑时单次增量明显变大 → 5%。
4875
+ // - 归一化后单次滚动量达到 VOL_WHEEL_FAST_DELTA(多行/惯性滑动)直接算快速。
4876
+ // 要改手感只调下面这几个数即可。
4877
+ const VOL_WHEEL_STEP_SLOW = 0.01; // 慢速滚动:每次 1%
4878
+ const VOL_WHEEL_STEP_FAST = 0.05; // 快速滚动:每次 5%
4879
+ const VOL_WHEEL_FAST_GAP_MS = 80; // 两次滚轮事件间隔小于此值算「连续快速」
4880
+ const VOL_WHEEL_MIN_DELTA = 20; // 判为快速所需的单次最小滚动量(滤掉触控板细碎滚动)
4881
+ const VOL_WHEEL_FAST_DELTA = 200; // 单次滚动量达到此值直接算快速(多行/惯性滑动)
4733
4882
  // Custom vertical volume slider. The native <input type=range> cannot be
4734
4883
  // fully restyled in current Chrome (track keeps gray border lines and the
4735
4884
  // thumb ignores width/height once appearance:none is set), so the slider is
@@ -4739,6 +4888,8 @@ window.__ModuleLoader__.load({
4739
4888
  const s = useStore();
4740
4889
  const trackRef = useRef(null);
4741
4890
  const draggingRef = useRef(false);
4891
+ // 上一次滚轮事件的时刻,用于判断两次滚动是否「连续快速」。
4892
+ const wheelAtRef = useRef(0);
4742
4893
  const valueFor = (clientY) => {
4743
4894
  const el = trackRef.current;
4744
4895
  if (el === null) return s.volume;
@@ -4762,6 +4913,34 @@ window.__ModuleLoader__.load({
4762
4913
  draggingRef.current = false;
4763
4914
  if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
4764
4915
  };
4916
+ // 滚轮调音量。必须用原生监听器注册(passive: false):React 的 onWheel 走被动
4917
+ // 监听,在被动监听里 preventDefault 无效,滚轮会连带把外层对话/面板一起滚走。
4918
+ useEffect(() => {
4919
+ const el = trackRef.current;
4920
+ if (el === null) return undefined;
4921
+ const onWheel = (e) => {
4922
+ // deltaMode: 0=像素 1=行 2=页,先统一折算成像素量再比较快慢。
4923
+ const unit = e.deltaMode === 1 ? 16 : (e.deltaMode === 2 ? 100 : 1);
4924
+ const d = e.deltaY * unit;
4925
+ if (d === 0) return;
4926
+ e.preventDefault(); // 这一次滚轮由音量条消费,不再冒泡去滚动页面
4927
+ if (draggingRef.current) return; // 拖拽中音量由指针位置决定,忽略滚轮
4928
+ const now = Date.now();
4929
+ const gap = now - wheelAtRef.current;
4930
+ wheelAtRef.current = now;
4931
+ const fast = (gap < VOL_WHEEL_FAST_GAP_MS && Math.abs(d) >= VOL_WHEEL_MIN_DELTA)
4932
+ || Math.abs(d) >= VOL_WHEEL_FAST_DELTA;
4933
+ const step = fast ? VOL_WHEEL_STEP_FAST : VOL_WHEEL_STEP_SLOW;
4934
+ // 上滚(deltaY < 0)加音量、下滚减音量。先钳位再交给 changeVolume:
4935
+ // 它转发给「出声页」的是钳位前的原值,钳位放在这里可避免越界值被广播出去。
4936
+ // 结果按 0.1% 取整,防止浮点累加出 0.8300000000000001 这类脏值落进 prefs。
4937
+ const raw = store.volume + (d < 0 ? step : -step);
4938
+ const next = Math.round(Math.min(1, Math.max(0, raw)) * 1000) / 1000;
4939
+ changeVolume(next);
4940
+ };
4941
+ el.addEventListener('wheel', onWheel, { passive: false });
4942
+ return () => el.removeEventListener('wheel', onWheel);
4943
+ }, []);
4765
4944
  const pct = Math.round(s.volume * 100);
4766
4945
  return React.createElement('div',
4767
4946
  { className: 'dsh-music-vol-slider', ref: trackRef,
@@ -5519,7 +5698,14 @@ window.__ModuleLoader__.load({
5519
5698
  .catch(() => showToast('删除失败', false));
5520
5699
  }, '删除', true);
5521
5700
  };
5522
- const playFrom = (id, from) => playNewsFrom(id, from || 0);
5701
+ const playFrom = (id, from, whole) => playNewsFrom(id, from || 0, whole);
5702
+ // 「播放整期」:该期已在播时绝不从头重播——正在播就禁用(title 提示),暂停中则
5703
+ // 从当前位置继续。与讲书列表「点当前这本 = 播放/暂停」同一约定:列表里的播放控件
5704
+ // 不重播正在播的内容(误触一次就会丢掉听到的位置)。
5705
+ const playWhole = (id) => {
5706
+ if (playingEditionId() === id) { if (!s.playing) togglePlay(); return; }
5707
+ playFrom(id, 0, true);
5708
+ };
5523
5709
 
5524
5710
  // ---- 定时编辑器辅助 ----
5525
5711
  // 范围摘要文本(手动执行指令用):scope 必填;空/旧 null 范围兜底展示全部预设类别。
@@ -5646,7 +5832,15 @@ window.__ModuleLoader__.load({
5646
5832
  fmtTs(e.createdAt) + (chips ? ' · ' + chips : '')),
5647
5833
  ),
5648
5834
  React.createElement('div', { className: 'dsh-music-news-card-actions' },
5649
- React.createElement('button', { className: 'dsh-music-settings-btn', title: '播放整期', onClick: (ev) => { ev.stopPropagation(); playFrom(e.id, 0); } }, '▶'),
5835
+ // 正在播的这一期:▶ 禁用(不重播、也不提供第二个暂停入口);暂停中的这一期:
5836
+ // ▶ 变「继续播放」,点了从当前位置接着播。
5837
+ React.createElement('button', {
5838
+ className: 'dsh-music-settings-btn',
5839
+ title: playing ? (s.playing ? '正在播放中' : '继续播放') : '播放整期',
5840
+ disabled: playing && s.playing,
5841
+ style: (playing && s.playing) ? { opacity: 0.45, cursor: 'default' } : undefined,
5842
+ onClick: (ev) => { ev.stopPropagation(); playWhole(e.id); },
5843
+ }, '▶'),
5650
5844
  React.createElement('button', {
5651
5845
  className: 'dsh-music-icon-btn', title: '删除',
5652
5846
  onClick: (ev) => { ev.stopPropagation(); deleteEdition(e.id); },
@@ -5705,10 +5899,42 @@ window.__ModuleLoader__.load({
5705
5899
  if (meta === null) return React.createElement('div', { className: 'dsh-music-empty' }, '加载中…');
5706
5900
  const id = meta.id;
5707
5901
  let seq = 0;
5902
+ // 正在播的那一条:由「已读字符占比 + charOffsets」反推当前块号(bookProgress 本就
5903
+ // 跨页同步、meta 本地就有,因此镜像页也算得对),再按 itemChunk 区间落到具体条目
5904
+ // ——一条新闻一个块,超长条目被切成多块时归到它的起始区间。播放中/暂停中都算
5905
+ // 「正在播这一条」,用于标注 + 禁用它自己的 ▶(点了也不会重播,见 playNewsFrom)。
5906
+ const curChunk = (() => {
5907
+ if (playingEditionId() !== id) return -1;
5908
+ const offs = meta.charOffsets || [];
5909
+ const total = offs.length > 0 ? offs[offs.length - 1] : 0;
5910
+ if (!(total > 0)) return -1;
5911
+ const consumed = (s.bookProgress || 0) * total;
5912
+ let k = 0;
5913
+ for (let i = 0; i < offs.length - 1; i++) { if (offs[i] <= consumed) k = i; else break; }
5914
+ return k;
5915
+ })();
5916
+ // itemChunk 是按条目顺序升序的「该条起始块号」:当前块落在哪一条,就是起始块号
5917
+ // ≤ 当前块的最后一条(一条一个块时即「起始块号 == 当前块」;超长条目被切成多块时
5918
+ // 也不会误标到别的条目上)。
5919
+ const curItemIdx = (() => {
5920
+ if (curChunk < 0) return -1;
5921
+ const chunks = meta.itemChunk || [];
5922
+ let best = -1;
5923
+ for (let i = 0; i < chunks.length; i++) {
5924
+ if (typeof chunks[i] === 'number' && chunks[i] <= curChunk) best = i;
5925
+ else break;
5926
+ }
5927
+ return best;
5928
+ })();
5929
+ const itemPlaying = (idx) => idx === curItemIdx;
5708
5930
  const sections = (meta.sections || []).map((sec, si) => {
5931
+ // 正在播这一期时标出当前类别(currentSection 由块号映射得到):让「现在在念哪一类」
5932
+ // 一眼可见,而不是靠点一下才知道。
5933
+ const curSec = playingEditionId() === id && s.currentSection === sec.heading;
5709
5934
  const rows = (meta.categories ? meta.categories[si].items : []).map((it) => {
5710
5935
  seq += 1;
5711
5936
  const chunk = (meta.itemChunk || [])[seq - 1] || 0;
5937
+ const curItem = itemPlaying(seq - 1);
5712
5938
  // 文字版(readOnly):文章排版——标题段 + 完整摘要段 + 来源行;不截断、无 ▶。
5713
5939
  if (readOnly) {
5714
5940
  return React.createElement('div', { key: seq, style: { marginBottom: 10 } },
@@ -5724,15 +5950,27 @@ window.__ModuleLoader__.load({
5724
5950
  title: it.summary,
5725
5951
  onClick: () => { if (!readOnly) playFrom(id, chunk); },
5726
5952
  },
5727
- React.createElement('span', { className: 'dsh-music-track-name' }, seq + '. ' + it.title),
5953
+ React.createElement('span', { className: 'dsh-music-track-name' },
5954
+ curItem ? React.createElement('span', { className: 'dsh-music-news-card-badge live', style: { marginRight: 6 } }, '正在播') : null,
5955
+ seq + '. ' + it.title),
5728
5956
  React.createElement('span', { className: 'dsh-music-track-size' }, (it.source || '来源未知') + (it.publishedAt ? ' · ' + it.publishedAt : '') + ' · ' + it.summary),
5729
5957
  ),
5730
- readOnly ? null : React.createElement('button', { className: 'dsh-music-settings-btn', title: '从这条开始播', onClick: () => playFrom(id, chunk) }, '▶'),
5958
+ // 正在播的这一条:▶ 禁用(点了也不会重播——守卫在 playNewsFrom,这里只是把
5959
+ // 状态显示出来,免得用户以为按钮坏了)。
5960
+ readOnly ? null : React.createElement('button', {
5961
+ className: 'dsh-music-settings-btn',
5962
+ title: curItem ? '正在播放中' : '从这条开始播',
5963
+ disabled: curItem,
5964
+ style: curItem ? { opacity: 0.45, cursor: 'default' } : undefined,
5965
+ onClick: () => playFrom(id, chunk),
5966
+ }, '▶'),
5731
5967
  );
5732
5968
  });
5733
5969
  return React.createElement('div', { key: si },
5734
5970
  React.createElement('div', { style: { fontWeight: 600, margin: '8px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
5735
- React.createElement('span', null, sec.heading + '(' + sec.itemCount + ')'),
5971
+ React.createElement('span', null,
5972
+ curSec ? React.createElement('span', { className: 'dsh-music-news-card-badge live', style: { marginRight: 6 } }, '正在播') : null,
5973
+ sec.heading + '(' + sec.itemCount + ')'),
5736
5974
  readOnly ? null : React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => playFrom(id, sec.fromChunk) }, '▶'),
5737
5975
  ),
5738
5976
  rows,
@@ -5762,7 +6000,17 @@ window.__ModuleLoader__.load({
5762
6000
  React.createElement('div', { className: 'dsh-music-news-head' },
5763
6001
  headBar(edition.title || '期次详情', { kind: 'list' }),
5764
6002
  React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 6 } },
5765
- readOnly ? null : React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => playFrom(view.id, 0) }, '▶ 播放整期'),
6003
+ readOnly ? null : (() => {
6004
+ const cur = playingEditionId() === view.id;
6005
+ const busy = cur && s.playing;
6006
+ return React.createElement('button', {
6007
+ className: 'dsh-music-settings-btn',
6008
+ title: busy ? '正在播放中' : (cur ? '继续播放' : '播放整期'),
6009
+ disabled: busy,
6010
+ style: busy ? { opacity: 0.45, cursor: 'default' } : undefined,
6011
+ onClick: () => playWhole(view.id),
6012
+ }, busy ? '正在播放' : (cur ? '▶ 继续播放' : '▶ 播放整期'));
6013
+ })(),
5766
6014
  React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => setView(readOnly ? { kind: 'detail', id: view.id } : { kind: 'read', id: view.id }) }, readOnly ? '◀ 条目视图' : '文字版'),
5767
6015
  readOnly ? null : React.createElement('button', { className: 'dsh-music-settings-btn ghost', style: { color: '#c0392b' }, onClick: () => deleteEdition(view.id) }, '删除'),
5768
6016
  ),
@@ -6458,7 +6706,9 @@ window.__ModuleLoader__.load({
6458
6706
  setLoginReason(''); // 已重新登录:清掉失效原因
6459
6707
  resetKgAuthExpired(); // 新登录态:清掉「登录已过期」标记
6460
6708
  // 同步全局 store:让「关于」页运行状态即时显示酷狗昵称(对齐 QQ 登录同步 qqLoggedIn 的先例)。
6461
- set({ kgLoggedIn: true, kgNickname: c.nickname || '' });
6709
+ // 重新登录成功 = 失效原因已消除:连面板顶部那条陈旧报错一起清掉
6710
+ // (否则它会一直挂到下次播放歌曲,见 QQ 面板 [s.error] 的渲染)。
6711
+ set({ kgLoggedIn: true, kgNickname: c.nickname || '', error: null });
6462
6712
  // 登录成功直接关闭二维码弹窗,不显示任何提示。
6463
6713
  setLoginOpen(false); setLoginMsg('');
6464
6714
  } else if (c.status === 'expired') { setLoginMsg('二维码已过期,请点击刷新'); }
@@ -7430,7 +7680,8 @@ window.__ModuleLoader__.load({
7430
7680
  // 同步全局 store(对齐 QQ/酷狗):引擎侧判定「整列播不出来 → 疑似登录失效」
7431
7681
  // 依赖 store.ncLoggedIn,不同步就会漏判(它原本只来自 manifest,登录发生在页面
7432
7682
  // 加载之后时是旧的)。
7433
- set({ ncLoggedIn: true, ncNickname: c.nickname || '' });
7683
+ // 同酷狗:重新登录成功即清掉陈旧报错。
7684
+ set({ ncLoggedIn: true, ncNickname: c.nickname || '', error: null });
7434
7685
  // 切到「我的歌单」+ 加载由 [loggedIn] effect 统一处理(刷新/登录两路都覆盖)
7435
7686
  } else if (c.status === 'expired') { setLoginMsg('二维码已过期,请点击刷新'); }
7436
7687
  else if (c.status === 'scanned') { setLoginMsg('已扫码,请在手机上确认'); pollTimer.current = setTimeout(tick, 1500); }
@@ -8237,6 +8488,11 @@ window.__ModuleLoader__.load({
8237
8488
  const topBusy = useRef({});
8238
8489
  const [cnMap, setCnMap] = useState({});
8239
8490
  const cnBusy = useRef({});
8491
+ // 「加载更多」进行中的**可见**状态('top:all' / 'cn:music' … → true)。
8492
+ // topBusy/cnBusy 是 ref(防重入用,不触发渲染),因而点「加载更多」后直到请求返回
8493
+ // 都不会重渲染 → 按钮文案永远停在「加载更多」、看不到「加载中…」(与在线音乐页面
8494
+ // 用 state 驱动的表现不一致)。这里用一份 state 专供按钮文案/可用态。
8495
+ const [dirBusyUi, setDirBusyUi] = useState({});
8240
8496
  const [err, setErr] = useState('');
8241
8497
  // 目录首屏加载失败态(自动重试耗尽):{ kind:'top'|'cn', group }。命中时该组视图
8242
8498
  // 显示「加载失败 + 手动重试」,而不是永远停在「加载中…」(此前失败只能切走再切回)。
@@ -8288,6 +8544,7 @@ window.__ModuleLoader__.load({
8288
8544
  };
8289
8545
  const sleep = (ms) => new Promise((r) => { dirRetryTimer.current = setTimeout(r, ms); });
8290
8546
  busyRef.current[g] = true;
8547
+ setDirBusyUi((m) => ({ ...m, [kind + ':' + g]: true })); // 让按钮立刻变「加载中…」
8291
8548
  setErr('');
8292
8549
  setDirFail((f) => (f && f.kind === kind && f.group === g ? null : f));
8293
8550
  const offset = cur ? cur.offset : 0;
@@ -8304,6 +8561,7 @@ window.__ModuleLoader__.load({
8304
8561
  }
8305
8562
  const showResult = stillActive();
8306
8563
  busyRef.current[g] = false;
8564
+ setDirBusyUi((m) => { const n = { ...m }; delete n[kind + ':' + g]; return n; }); // 按钮恢复「加载更多」
8307
8565
  if (d && d.ok && Array.isArray(d.stations)) {
8308
8566
  const page = d.stations;
8309
8567
  const prev = cur ? cur.rows : [];
@@ -8427,13 +8685,21 @@ window.__ModuleLoader__.load({
8427
8685
  }, label);
8428
8686
  // 「加载更多」底部按钮:目录还有下一页(未到底)时显示;点击追加下一页。
8429
8687
  // kind: 'top' | 'cn';data 为该组的 { rows, done } 状态。
8688
+ // 与在线音乐(QQ / 酷狗 / 网易云)同款:`.dsh-music-qq-loadmore` +
8689
+ // `.dsh-music-qq-loadmore-btn` 的胶囊按钮(类名源自 QQ 页,但早已是各在线页共用的
8690
+ // 样式)。此前电台页用的是通用 .dsh-music-settings-btn + 内联 padding,两处观感不一致。
8430
8691
  const loadMoreFooter = (kind, data) => {
8431
- const busy = kind === 'top' ? topBusy.current[topGroup] : cnBusy.current[cnGroup];
8692
+ const g = kind === 'top' ? topGroup : cnGroup;
8693
+ // 文案/可用态取自 state(dirBusyUi)而不是 topBusy/cnBusy 这两个 ref:ref 变化不触发
8694
+ // 渲染,点了按钮也看不到「加载中…」(见 dirBusyUi 处说明)。
8695
+ const busy = !!dirBusyUi[kind + ':' + g];
8432
8696
  if (data === undefined || data.done) return null;
8433
- return React.createElement('div', { style: { display: 'flex', justifyContent: 'center', padding: '10px 0 4px' } },
8697
+ return React.createElement('div', { className: 'dsh-music-qq-loadmore' },
8434
8698
  React.createElement('button', {
8435
- className: 'dsh-music-settings-btn', disabled: !!busy,
8436
- onClick: () => { if (kind === 'top') loadTop(topGroup, true); else loadCn(cnGroup, true); },
8699
+ className: 'dsh-music-qq-loadmore-btn',
8700
+ // 不置 disabled(与在线音乐一致:忙碌态只把文案换成「加载中…」,不换个灰按钮的
8701
+ // 样子);重入由 loadDirPage 的 busyRef 守卫兜住。
8702
+ onClick: () => { if (busy) return; if (kind === 'top') loadTop(topGroup, true); else loadCn(cnGroup, true); },
8437
8703
  }, busy ? '加载中…' : '加载更多'));
8438
8704
  };
8439
8705
  const listEl = (() => {
@@ -9104,7 +9370,7 @@ window.__ModuleLoader__.load({
9104
9370
  d = await fetch('/dsh-music/qq/login/check?key=' + encodeURIComponent(key), { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }).then((r) => r.json());
9105
9371
  } finally { if (timer) clearTimeout(timer); }
9106
9372
  if (!d || !d.ok) { setLoginStatus((d && d.error) || '查询失败,正在重试…'); schedulePoll(); return; }
9107
- if (d.status === 'success') { clearPoll(); setLoggedIn(true); setUin(d.uin || ''); setNickname(d.nickname || ''); setLayer('main'); setActivePl(null); saveUi('main', '', ''); closeLogin(); refreshQQFavIds(); resetQQAuthExpired(); set({ qqLoggedIn: true, qqLoginFrom: d.loginFrom || '' }); }
9373
+ if (d.status === 'success') { clearPoll(); setLoggedIn(true); setUin(d.uin || ''); setNickname(d.nickname || ''); setLayer('main'); setActivePl(null); saveUi('main', '', ''); closeLogin(); refreshQQFavIds(); resetQQAuthExpired(); set({ qqLoggedIn: true, qqLoginFrom: d.loginFrom || '', error: null }); }
9108
9374
  else if (d.status === 'scanned') { setLoginStatus('已扫码,请在手机上确认'); schedulePoll(); }
9109
9375
  else if (d.status === 'expired') { clearPoll(); setLoginStatus('二维码已过期'); }
9110
9376
  else if (d.status === 'failed') { clearPoll(); setLoginStatus(d.message || '登录失败'); }
@@ -10849,7 +11115,8 @@ window.__ModuleLoader__.load({
10849
11115
  return;
10850
11116
  }
10851
11117
  newsResume = snapshotNewsResume(intent.id);
10852
- playNewsFrom(intent.id, 0);
11118
+ // whole=true:「播这一期」语义——同一期已在播时(重复投递/冷却窗回推)不从头重播。
11119
+ playNewsFrom(intent.id, 0, true);
10853
11120
  return;
10854
11121
  }
10855
11122
  // 网络电台(agent source=radio 或面板点击):intent 带完整 station 字段
@@ -0,0 +1,84 @@
1
+ /**
2
+ * lib/fetch-limit.js — 极简出网限流器(并发上限 + 最小启动间隔 + **有界**队列)。
3
+ *
4
+ * 背景:插件里有若干「不依赖用户交互」的自动出网请求(典型:本地歌曲没歌词时按歌自动
5
+ * 触发的在线歌词兜底)。连续切歌时它们会对第三方非官方接口形成无上限的并发请求,既可能
6
+ * 耗尽配额/被限流封禁,也没有任何资源上限(CWE-770)。本模块提供一个小而明确的限流器:
7
+ *
8
+ * - maxConcurrent:同时在跑的请求数上限;
9
+ * - minGapMs:两次**启动**之间的最小间隔(上一次请求过去足够久时,下一次立即启动——
10
+ * 即空闲时首个请求不额外等待);
11
+ * - maxQueue:排队长度上限,超出立即失败(**资源有界**,而不是无限堆积)。
12
+ *
13
+ * 关键语义(与「超时兜底」配合使用,别写反):
14
+ * 调用方应**先**起自己的超时计时器,再把请求交给 limiter.run(),并把 signal 传进来。
15
+ * 这样排队等待也吃这份超时预算:排队超时 → 立刻出队失败、**根本不会发出上游请求**;
16
+ * 否则队列一长,超时兜底会被排队拖成 ⌊k/并发⌋×timeout,退化成「明明做了超时却还是会
17
+ * 长时间卡住」。
18
+ *
19
+ * 无第三方依赖;实例是进程内共享的,请按调用方(模块/端点)各建一个实例,避免相互排队。
20
+ */
21
+
22
+ function makeAbortError() {
23
+ // 与 fetch 被 abort 时抛出的错误同形(调用方一律按「失败 → 静默回退」处理)。
24
+ const err = new Error('The operation was aborted')
25
+ err.name = 'AbortError'
26
+ return err
27
+ }
28
+
29
+ /**
30
+ * @param {{ maxConcurrent?: number, minGapMs?: number, maxQueue?: number }} [opts]
31
+ * @returns {{ run: (task: () => any, o?: { signal?: AbortSignal }) => Promise<any>, queued: () => number, running: () => number }}
32
+ */
33
+ export function createLimiter({ maxConcurrent = 2, minGapMs = 250, maxQueue = 8 } = {}) {
34
+ let running = 0
35
+ let lastStartAt = 0
36
+ let gapTimer = null
37
+ const queue = []
38
+
39
+ const pump = () => {
40
+ if (gapTimer !== null) return // 已安排了下一次启动(间隔未到)
41
+ if (running >= maxConcurrent) return // 并发已满
42
+ if (queue.length === 0) return
43
+ const wait = Math.max(0, lastStartAt + minGapMs - Date.now())
44
+ if (wait > 0) {
45
+ // 只在「距上次启动不足 minGapMs」时才等——空闲后的第一个请求 wait=0、立即启动。
46
+ gapTimer = setTimeout(() => { gapTimer = null; pump() }, wait)
47
+ return
48
+ }
49
+ const item = queue.shift()
50
+ running += 1
51
+ lastStartAt = Date.now()
52
+ item.start()
53
+ pump() // 还有额度就继续(仍受 minGapMs 约束)
54
+ }
55
+
56
+ const release = () => {
57
+ running = Math.max(0, running - 1)
58
+ pump()
59
+ }
60
+
61
+ const run = (task, { signal } = {}) => new Promise((resolve, reject) => {
62
+ if (signal && signal.aborted) { reject(makeAbortError()); return }
63
+ if (queue.length >= maxQueue) { reject(new Error('fetch queue full (' + maxQueue + ')')); return }
64
+ const item = { start: null }
65
+ // 排队期间被 abort(调用方的超时到点):直接出队、失败,不发上游请求。
66
+ const onAbort = () => {
67
+ const i = queue.indexOf(item)
68
+ if (i >= 0) queue.splice(i, 1)
69
+ reject(makeAbortError())
70
+ }
71
+ if (signal) signal.addEventListener('abort', onAbort, { once: true })
72
+ item.start = () => {
73
+ if (signal) signal.removeEventListener('abort', onAbort)
74
+ Promise.resolve().then(task).then(
75
+ (value) => { release(); resolve(value) },
76
+ (err) => { release(); reject(err) },
77
+ )
78
+ }
79
+ queue.push(item)
80
+ pump()
81
+ })
82
+
83
+ return { run, queued: () => queue.length, running: () => running }
84
+ }
package/lib/index.js CHANGED
@@ -2846,7 +2846,7 @@ export function apply(ctx) {
2846
2846
  const kgTakeInFlight = new Map() // hash -> Promise<entry>
2847
2847
 
2848
2848
  // 登录态动作的统一包装:直接原样请求,不做任何前置续命/刷新。
2849
- // 决策(2026-09-06):主动续命已停用(见下方 maybeRefreshKgSession 注释)——
2849
+ // 决策(2026-09-06 停用 / 2026-09-20 删除实现):不做主动续命——
2850
2850
  // 社区实证 login_by_token 是「探活/续期」而非换发,token 常态可活数周;
2851
2851
  // 与其定时打扰官方(反感频繁调用),不如 token 真过期时由业务请求撞
2852
2852
  // 20017/20018 → 让用户重新扫码一次(酷狗无其他续命手段,重扫是唯一正道)。
@@ -2905,62 +2905,9 @@ export function apply(ctx) {
2905
2905
  const file = await kgCookieFile()
2906
2906
  if (file !== null) { try { writeFileSync(file, JSON.stringify({ session: kg.session, loggedIn: false, savedAt: Date.now() }), { encoding: 'utf8', mode: 0o600 }) } catch { /* ignore */ } }
2907
2907
  }
2908
- // ══════════════════════════════════════════════════════════════════
2909
- // 【停用】酷狗主动续命(maybeRefreshKgSession)——2026-09-06 起不再调用。
2910
- //
2911
- // 决策背景:社区实证(MakcRe issue #117 作者亲述 + lx-music + kgqd)表明——
2912
- // 1. login_by_token 是「用原 token+userid 重新登录探活」而非换发新 token,
2913
- // token 无硬性 24h 寿命,常态可活数周(官方有时半月~一月才作废);
2914
- // 2. 官方反感频繁调用续命接口;且登录后立刻 refresh / 定时 refresh 的额外
2915
- // 调用,实测曾导致 token 被提前作废(登录后 ~24h 就失效的异常现象);
2916
- // 3. 与其定时打扰官方,不如 token 真过期时由业务请求撞 20017/20018 → 让用户
2917
- // 重新扫码一次(酷狗无其他续命手段,重扫是唯一正道)。
2918
- // 停用后 token 生命周期 = 完全由酷狗侧决定;过期时 kgWithFreshToken 的 catch
2919
- // 分支清 cookie + kgLoginDead,前端跳回扫码页提示重新登录。
2920
- //
2921
- // 保留下方实现注释供未来需要时恢复(若酷狗 token 寿命显著缩短再评估启用)。
2922
- // ══════════════════════════════════════════════════════════════════
2923
- // 主动续命:登录态还在但距上次刷新超过 KG_REFRESH_TTL(24h)时,先静默调一次
2924
- // v5/login_by_token(社区惯例:每天一次/每次重启一次,官方反感频繁调用)。
2925
- // ⚠️ 续命失败【不登出】——对齐 lx-music(refresh 失败仅记日志、保留旧 token,
2926
- // 下轮再试)。login_by_token 是「用原 token 探活/续期」而非换发(MakcRe issue
2927
- // #117:token 无硬性 24h 寿命,常态可活数周);续命失败多数是临时网络/风控,
2928
- // 直接 clearKGCookie 会把用户硬登出。token 是否真死由后续业务请求的
2929
- // 20017/20018 判定(kgWithFreshToken 的 catch 分支负责,那里才登出)。
2930
- // ⚠️ 在途去重:TTL 到期瞬间的并发请求(取链 HEAD/GET 几乎同时到达)会各自
2931
- // 用同一个旧 token 发两次 login_by_token——若酷狗换发新 token 时作废旧
2932
- // token,第二发必收 20018,会把还活着的会话误判成「登录已死」清掉。这里把
2933
- // 在途刷新的 Promise 共享给并发调用:同一时刻最多只有一次真实刷新请求。
2934
- // const KG_REFRESH_TTL = 24 * 60 * 60 * 1000
2935
- // let kgRefreshInFlight = null
2936
- // const maybeRefreshKgSession = async () => {
2937
- // await loadKGCookie()
2938
- // if (!kg.loggedIn) return
2939
- // if (kgSavedAt > 0 && Date.now() - kgSavedAt < KG_REFRESH_TTL) return
2940
- // if (kgRefreshInFlight) return kgRefreshInFlight
2941
- // const tokenAtStart = kg.session.token
2942
- // kgRefreshInFlight = (async () => {
2943
- // try {
2944
- // const refreshed = await KG.refreshSession(kg.session)
2945
- // // 刷新期间发生了重扫/登出(token 已换)→ 本次结果作废:不回写旧 token 的
2946
- // // 刷新产物,避免覆盖刚登录的新会话。
2947
- // if (kg.session.token !== tokenAtStart) return
2948
- // if (!refreshed.token) return
2949
- // kg.session.token = refreshed.token
2950
- // kg.session.vip_type = refreshed.vip_type || kg.session.vip_type
2951
- // kg.session.vip_token = refreshed.vip_token || kg.session.vip_token
2952
- // if (refreshed.t1) kg.session.t1 = refreshed.t1
2953
- // await saveKGCookie()
2954
- // } catch (e) {
2955
- // // 续命失败不登出:可能只是瞬时网络/风控抖动。token 是否真死交给后续业务
2956
- // // 请求的 20017/20018 判定(kgWithFreshToken catch 分支才 clearKGCookie)。
2957
- // kg.logger && kg.logger.warn && kg.logger.warn('酷狗主动续命失败(保留登录态):' + String((e && e.message) || e))
2958
- // } finally {
2959
- // kgRefreshInFlight = null
2960
- // }
2961
- // })()
2962
- // return kgRefreshInFlight
2963
- // }
2908
+ // 这里原本是一整段注释形式的 maybeRefreshKgSession 实现(含 KG_REFRESH_TTL / 在途去重),
2909
+ // 已随 lib/kugou.js 的 refreshSession 于 2026-09-20 一起删除(停用理由见上一条注释;
2910
+ // 需要旧实现时从 git 历史找回),避免读者误以为这条路径还活着。
2964
2911
 
2965
2912
  // 取链结果短缓存(同 QQ):同一首歌的 <audio> 加载与频谱信封两次请求命中 /kg/play,
2966
2913
  // 缓存保证两拿到的档位一致且省一次 tracker 往返。失败原因随 entry 带回给路由展示。
package/lib/kugou.js CHANGED
@@ -931,99 +931,9 @@ export function explainKgError(json, httpStatus) {
931
931
  return `${msg || ('error_code=' + code)}(HTTP ${httpStatus})`
932
932
  }
933
933
 
934
- // =====================================================================
935
- // Token 刷新(v5/login_by_token):kgqd 生产环境每次用前先刷新。
936
- // 把扫码签发的 token 兑换为标准作用域 token,顺带回填 vip_type/vip_token/t1,
937
- // 可消除部分网关接口的 20017 类拒绝。方案(MakcRe module/login_token.js 移植):
938
- // p3 = AES(固定 key32/iv16) 加密 {clienttime, token} → HEX
939
- // params = AES(md5(randKey)前32位 作 AES-256 key、其后16位作 iv) 加密 {} → HEX
940
- // pk = RSA-NO-PADDING(右侧补零到模长) 加密 {clienttime_ms, params 盐}
941
- // 应答 data.secu_params 用同一派生密钥解出新 token 集合
942
- // =====================================================================
943
-
944
- function kxAes256Hex(jsonObj, keyStr) {
945
- const encKey = Buffer.from(keyStr, 'utf8') // 32 字符 hex 串按 utf8 → AES-256
946
- const encIv = Buffer.from(keyStr.slice(keyStr.length - 16), 'utf8')
947
- const cipher = crypto.createCipheriv('aes-256-cbc', encKey, encIv)
948
- return Buffer.concat([cipher.update(JSON.stringify(jsonObj), 'utf8'), cipher.final()]).toString('hex')
949
- }
950
-
951
- function kxRandomSeed16() {
952
- const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
953
- let out = ''
954
- const bytes = crypto.randomBytes(16)
955
- for (let i = 0; i < 16; i++) out += alphabet[bytes[i] % alphabet.length]
956
- return out
957
- }
958
-
959
- export async function refreshSession(session = {}) {
960
- const s = normSession(session)
961
- if (!s.token) throw new Error('未登录,无法刷新登录态')
962
- const ms = Date.now()
963
- const p3 = kxAes256Hex({ clienttime: Math.floor(ms / 1000), token: s.token }, '90b8382a1bb4ccdcf063102053fd75b8')
964
- const paramSeed = kxRandomSeed16()
965
- const paramKeyFull = md5Hex(paramSeed) // 32 位 → AES-256 密钥(utf8);末 16 位作 iv
966
- const encIv = paramKeyFull.slice(paramKeyFull.length - 16, paramKeyFull.length)
967
- const c2 = crypto.createCipheriv('aes-256-cbc', Buffer.from(paramKeyFull, 'utf8'), Buffer.from(encIv, 'utf8'))
968
- const paramsHex = Buffer.concat([c2.update('{}', 'utf8'), c2.final()]).toString('hex')
969
- // RSA-NO-PADDING:右侧补零到「模长字节数」后直接 modexp(等价参考实现的 manual pad + rawEncrypt)
970
- const pub = PLAYLIST_RSA_PUB
971
- const json = JSON.stringify({ clienttime_ms: ms, key: paramSeed })
972
- const pubKeyObj = crypto.createPublicKey(pub)
973
- const modBits = (pubKeyObj.asymmetricKeyDetails && pubKeyObj.asymmetricKeyDetails.modulusLength) || 1024
974
- const modLen = Math.ceil(modBits / 8)
975
- if (Buffer.byteLength(json, 'utf8') > modLen) throw new Error('登录刷新:pk 载荷超长')
976
- const padded = Buffer.alloc(modLen)
977
- Buffer.from(json, 'utf8').copy(padded, 0)
978
- const pk = crypto.publicEncrypt({ key: pubKeyObj, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex')
979
-
980
- const dataMap = {
981
- dfid: s.dfid || '-',
982
- p3,
983
- plat: 1,
984
- // 设备型号与客户端构建串:贴近真实 Android 上报形态,供服务端风控辨识
985
- //(lx-music-api-server modules/refresh/kg.py 同款字段,gitversion 抄同值;
986
- // UA/t3/need_toneinfo 维持现状即可,两套变体均被服务端接受)。
987
- dev: '23127PN0CC',
988
- gitversion: 'a23c277',
989
- t1: 0,
990
- t2: 0,
991
- t3: 'MCwwLDAsMCwwLDAsMCwwLDA=',
992
- pk,
993
- params: paramsHex,
994
- userid: String(s.userid || '0'),
995
- clienttime_ms: ms,
996
- }
997
- const bodyText = JSON.stringify(dataMap)
998
- const { d } = gatewayDefaults(s)
999
- const merged = { ...d, ...dataMap }
1000
- const q = new URLSearchParams({ ...merged, signature: signAndroid(merged, bodyText) })
1001
- const res = await fetchWithTimeout(`http://login.user.kugou.com/v5/login_by_token?${q.toString()}`, {
1002
- method: 'POST',
1003
- headers: { ...GATEWAY_HEADERS(normSession(s), d.clienttime), 'Content-Type': 'application/json' },
1004
- body: bodyText,
1005
- }, 15000)
1006
- const j = await res.json().catch(() => null)
1007
- if (!j || Number(j.status) !== 1 || !(j.data && (j.data.secu_params || j.data.token))) {
1008
- throw new Error('刷新登录态失败:' + explainKgError(j, res.status))
1009
- }
1010
- if (!j.data.secu_params) {
1011
- return { token: String(j.data.token || ''), userid: String(j.data.userid || s.userid), vip_type: '', vip_token: '', t1: '' }
1012
- }
1013
- // 解开 secu_params:HEX → AES-256-CBC(同 params 派生密钥)
1014
- const decKeyFull = md5Hex(paramSeed)
1015
- const d2 = crypto.createDecipheriv('aes-256-cbc', Buffer.from(decKeyFull, 'utf8'), Buffer.from(decKeyFull.slice(16), 'utf8'))
1016
- const plain = Buffer.concat([d2.update(Buffer.from(String(j.data.secu_params), 'hex')), d2.final()]).toString('utf8')
1017
- let obj
1018
- try { obj = JSON.parse(plain) } catch { obj = { token: plain } }
1019
- return {
1020
- token: String(obj.token || ''),
1021
- userid: String(obj.userid || s.userid),
1022
- vip_type: String(obj.vip_type != null ? obj.vip_type : ''),
1023
- vip_token: String(obj.vip_token != null ? obj.vip_token : ''),
1024
- t1: String(obj.t1 != null ? obj.t1 : ''),
1025
- }
1026
- }
934
+ // 注:酷狗 token 主动续命(v5/login_by_token 兑换/探活)已于 2026-09-06 停用、2026-09-20 连同
935
+ // 实现(refreshSession / kxAes256Hex / kxRandomSeed16)一起删除——为什么不做续命见 lib/index.js
936
+ // kgWithFreshToken 上方那条决策注释;需要旧实现时从 git 历史找回。
1027
937
 
1028
938
  // =====================================================================
1029
939
  // 个人歌单(cloudlist.service,需登录 token/userid)
package/lib/lyric.js CHANGED
@@ -15,16 +15,30 @@
15
15
  import * as QQ from './qq.js'
16
16
  import * as KG from './kugou.js'
17
17
  import * as NC from './netease.js'
18
+ import { createLimiter } from './fetch-limit.js'
18
19
 
19
20
  const LRCLIB_API = 'https://lrclib.net/api'
20
21
  const LRCLIB_UA = 'dsh-music-player/1.0 (local lyric fallback; rate-limited+cached)'
21
22
 
23
+ // 出网限流(CWE-770):LRCLIB 兜底是**每次换歌自动触发**、不依赖用户交互的请求,连续切歌时
24
+ // 最容易对第三方接口形成无上限并发。给它一个进程内共享的限流器:并发 2 + 最小启动间隔
25
+ // 250ms + 队列上限 8(超出立即失败,资源有界)。
26
+ // 范围说明:只作用于本模块的 LRCLIB 请求(歌词链最后一跳)。qq/kugou/netease/radio 各自的
27
+ // fetchWithTimeout 走的是**播放关键路径**(点歌/搜索直接调用),在那里加队列会让起播变慢,
28
+ // 故未纳入;若日后要统一限流,直接复用 lib/fetch-limit.js,按模块各建一个实例即可。
29
+ const lrclibLimiter = createLimiter({ maxConcurrent: 2, minGapMs: 250, maxQueue: 8 })
30
+
22
31
  // 出网统一超时(与 qq.js 同构):Node fetch 默认无超时,端点挂起会让播放流程卡住。
32
+ // 计时器必须在**入队之前**启动:排队等待也吃这份超时预算——排队到点即出队失败、根本不发
33
+ // 上游请求。否则队列一长,超时兜底会被排队拖成 ⌊k/并发⌋×timeout,等于没兜住。
23
34
  async function fetchWithTimeout(url, opts = {}, timeoutMs = 10000) {
24
35
  const ctrl = new AbortController()
25
36
  const timer = setTimeout(() => ctrl.abort(), timeoutMs)
26
37
  try {
27
- return await fetch(url, { ...opts, signal: ctrl.signal })
38
+ return await lrclibLimiter.run(
39
+ () => fetch(url, { ...opts, signal: ctrl.signal }),
40
+ { signal: ctrl.signal },
41
+ )
28
42
  } finally {
29
43
  clearTimeout(timer)
30
44
  }
@@ -40,11 +54,12 @@ async function fetchWithTimeout(url, opts = {}, timeoutMs = 10000) {
40
54
  // =====================================================================
41
55
 
42
56
  // 搜索并归一化为候选数组(只保留带同步歌词的非纯音乐候选;纯音乐没有逐字歌词意义)。
43
- export async function lrclibSearch(keyword) {
57
+ // timeoutMs 覆盖「排队 + 出网」全过程(默认 10s,与 fetchWithTimeout 一致;测试用它跑小值)。
58
+ export async function lrclibSearch(keyword, { timeoutMs = 10000 } = {}) {
44
59
  const p = new URLSearchParams({ q: String(keyword || '') })
45
60
  const res = await fetchWithTimeout(`${LRCLIB_API}/search?${p.toString()}`, {
46
61
  headers: { Accept: 'application/json', 'User-Agent': LRCLIB_UA },
47
- })
62
+ }, timeoutMs)
48
63
  if (res.status !== 200) throw new Error('lrclib search http ' + res.status)
49
64
  const arr = await res.json().catch(() => null)
50
65
  if (!Array.isArray(arr)) throw new Error('lrclib search payload')
package/lib/news-core.js CHANGED
@@ -473,8 +473,14 @@ const normShiftScope = (scope) => {
473
473
  */
474
474
  export function sanitizeSchedulePrefs(input, prev) {
475
475
  const base = prev && typeof prev === 'object' ? prev : {}
476
+ // enabled 的「未提供」判定必须看字段本身,而不是看 input 是不是对象:Host 的 loadNews()
477
+ // 就是 sanitizeSchedulePrefs({}, 磁盘快照) —— input 恒为 truthy 的空对象,旧写法会一路
478
+ // 走 `input.enabled !== false` → 恒为 true,把用户存下的 false 悄悄改回 true(面板里表现为
479
+ // 「取消勾选后再进去又勾上了」,定时器照旧触发)。与 model/shifts 及 sanitizeRssPrefs 同款:
480
+ // 显式传了才覆盖,没传就保留 prev。
481
+ const hasEnabled = !!input && typeof input === 'object' && input.enabled !== undefined
476
482
  const out = {
477
- enabled: input && typeof input === 'object' ? input.enabled !== false : base.enabled !== false,
483
+ enabled: hasEnabled ? input.enabled !== false : base.enabled !== false,
478
484
  model: sanitizeModelSelection(input && input.model !== undefined ? input.model : base.model),
479
485
  shifts: [],
480
486
  prefVersion: Number.isInteger(base.prefVersion) ? base.prefVersion : 0,
package/lib/whatsnew.js CHANGED
@@ -41,6 +41,37 @@ export const WELCOME = {
41
41
  // 版本更新条目:新 → 旧。type 仅支持 feature / improve / fix(未知类型按
42
42
  // improve 渲染兜底)。date 为该版本发布日期(YYYY-MM-DD)。
43
43
  export const WHATS_NEW = [
44
+ {
45
+ version: '1.0.3',
46
+ date: '2026-09-20',
47
+ title: '播放与播报体验修复一批',
48
+ sections: [
49
+ {
50
+ type: 'feature',
51
+ items: [
52
+ '音量条支持鼠标滚轮调速(慢速 1% / 快速 5%)',
53
+ ],
54
+ },
55
+ {
56
+ type: 'improve',
57
+ items: [
58
+ '新闻播报 / AI 讲书的进度条:切到下一条时不再瞬间归零',
59
+ '正在播的新闻期次:面板 ▶ 不再把整期从头重播(暂停中变为「继续播放」),详情页标出正在播的类别与条目',
60
+ '网络电台的「加载更多」与在线音乐样式统一,并显示「加载中…」',
61
+ ],
62
+ },
63
+ {
64
+ type: 'fix',
65
+ items: [
66
+ '本地曲目解码或加载失败时自动跳下一首,不再把播放停死',
67
+ '「启用每日定时」可以真正关闭了(此前关掉后会被自动勾回、到点照旧触发)',
68
+ '新闻播报换 AI 声音后从当前位置继续;刷新页面后也能恢复播放进度',
69
+ '悬浮条拖到页面顶部时,音量 / 播放模式 / 章节目录弹窗不再错位',
70
+ 'QQ / 酷狗 / 网易云重新登录或收藏成功后,不再残留「请重新登录」提示',
71
+ ],
72
+ },
73
+ ],
74
+ },
44
75
  {
45
76
  version: '1.0.2',
46
77
  date: '2026-09-19',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Vibe coding时的好伴侣",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",