dsh-music-player 0.7.1 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/lib/client.js +178 -77
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -30,9 +30,15 @@ DeepSeek Harness 音乐/小说播放插件。
30
30
 
31
31
  ![播放QQ音乐](assets/screenshot-qq.png)
32
32
 
33
+ ![播放酷狗音乐](assets/screenshot-kg.png)
34
+
33
35
  ![播放AI讲书](assets/screenshot-spectrum.png)
34
36
 
35
- ![播放面板](assets/screenshot-panel.png)
37
+ ![播放面板1](assets/screenshot-panel-qq.png)
38
+
39
+ ![播放面板2](assets/screenshot-panel-kg.png)
40
+
41
+ ![播放面板3](assets/screenshot-panel.png)
36
42
 
37
43
  ## 安装
38
44
 
package/lib/client.js CHANGED
@@ -1723,7 +1723,23 @@ window.__ModuleLoader__.load({
1723
1723
  let vizScratchB = null;
1724
1724
  let vizBandPeak = null; // 每频段慢峰值(自适应增益的响度参考)
1725
1725
  let vizBandGain = null; // 每频段自适应增益(默认 VIZ_WAVE_GAIN)
1726
+ let vizBandMean = null; // 每频段波形均值(分析路径算好,绘制直接复用免每帧重扫)
1726
1727
  let vizFilterTime = null; // 上次消费音频缓冲的 vizCtx.currentTime(用于算新增样本数)
1728
+ // 降采样包络缓冲:每像素列的最小/最大偏差(复用缓冲,避免每帧分配)。
1729
+ let vizEnvMin = null;
1730
+ let vizEnvMax = null;
1731
+ let vizEnvW = 0; // 包络缓冲对应的画布列数(变化时才重建)
1732
+ // 2d context 缓存:canvas 元素在播放条重挂载时才换新实例,按实例比对复用,
1733
+ // 省掉每帧 2-3 次 getContext('2d') 调用。
1734
+ let vizCanvas2d = null;
1735
+ let vizCanvas2dFor = null;
1736
+ function vizGet2D(canvas) {
1737
+ if (vizCanvas2dFor !== canvas || vizCanvas2d === null) {
1738
+ vizCanvas2d = canvas.getContext('2d');
1739
+ vizCanvas2dFor = canvas;
1740
+ }
1741
+ return vizCanvas2d;
1742
+ }
1727
1743
  // Cached per-band frequency-weighting gain for the live analyser; shared code is
1728
1744
  // bandTiltGain(sampleRate) in the spectrum section. Recomputing is needed only when the
1729
1745
  // AudioContext sample rate changes (a new tap), so it's reset in setupLiveViz.
@@ -1790,13 +1806,14 @@ window.__ModuleLoader__.load({
1790
1806
  return true;
1791
1807
  } catch (e) {
1792
1808
  if (vizCtx !== null) { try { vizCtx.close(); } catch (e2) {} }
1793
- vizCtx = null; vizAnalyser = null; vizFreq = null; vizWave = null; vizWaveSmooth = null; vizBandSmooth = null; vizBandFilter = null; vizScratchA = null; vizScratchB = null; vizBandPeak = null; vizBandGain = null; vizFilterTime = null; vizLive = false; vizSetupState = 2;
1809
+ vizCtx = null; vizAnalyser = null; vizFreq = null; vizWave = null; vizWaveSmooth = null; vizBandSmooth = null; vizBandFilter = null; vizScratchA = null; vizScratchB = null; vizBandPeak = null; vizBandGain = null; vizBandMean = null; vizEnvMin = null; vizEnvMax = null; vizEnvW = 0; vizFilterTime = null; vizLive = false; vizSetupState = 2;
1794
1810
  return false;
1795
1811
  }
1796
1812
  }
1797
1813
  function closeLiveViz() {
1798
1814
  if (vizCtx !== null) { try { vizCtx.close(); } catch (e) {} }
1799
- vizCtx = null; vizAnalyser = null; vizFreq = null; vizWave = null; vizWaveSmooth = null; vizBandSmooth = null; vizBandFilter = null; vizScratchA = null; vizScratchB = null; vizBandPeak = null; vizBandGain = null; vizFilterTime = null; vizLive = false; vizSetupState = 0;
1815
+ vizCtx = null; vizAnalyser = null; vizFreq = null; vizWave = null; vizWaveSmooth = null; vizBandSmooth = null; vizBandFilter = null; vizScratchA = null; vizScratchB = null; vizBandPeak = null; vizBandGain = null; vizBandMean = null; vizEnvMin = null; vizEnvMax = null; vizEnvW = 0; vizFilterTime = null; vizLive = false; vizSetupState = 0;
1816
+ vizCanvas2d = null; vizCanvas2dFor = null;
1800
1817
  }
1801
1818
  // Map AnalyserNode's per-bin byte data (dB→0..255) into the VIZ_BARS
1802
1819
  // log-spaced bands. Marks vizLiveOK once the tap actually yields signal.
@@ -1871,9 +1888,20 @@ window.__ModuleLoader__.load({
1871
1888
  vizScratchB = new Float32Array(n);
1872
1889
  vizBandPeak = new Float32Array(VIZ_WAVE_BANDS.length);
1873
1890
  vizBandGain = new Float32Array(VIZ_WAVE_BANDS.length);
1891
+ vizBandMean = new Float32Array(VIZ_WAVE_BANDS.length);
1874
1892
  for (let i = 0; i < vizBandSmooth.length; i++) vizBandSmooth[i] = 0.5;
1893
+ for (let i = 0; i < vizBandMean.length; i++) vizBandMean[i] = 0.5;
1875
1894
  vizFilterTime = null;
1876
1895
  }
1896
+ // 时域路径的 tap 信号确认(与 analyseLiveBands 的 m>4 等价):死 tap 只会给
1897
+ // 恒定 128(静音),任一样本偏离中心 >4 即视为真实信号。wave 模式不再调
1898
+ // analyseLiveBands,vizLiveOK 改由这里维护。
1899
+ if (!vizLiveOK) {
1900
+ for (let i = 0; i < n; i++) {
1901
+ const d = vizWave[i] >= 128 ? vizWave[i] - 128 : 128 - vizWave[i];
1902
+ if (d > 4) { vizLiveOK = true; break; }
1903
+ }
1904
+ }
1877
1905
  // 算出「自上次处理后新增的样本数」hop:只把缓冲末尾的新样本喂给滤波器。
1878
1906
  // 关键:若整窗都喂且滤波器状态跨帧保留,重叠区会被重复滤波、状态跑得比
1879
1907
  // 真实时间快 → 强低音会错误地在高频段激起(实测 100Hz → 中频 0.46)。
@@ -1918,6 +1946,7 @@ window.__ModuleLoader__.load({
1918
1946
  let bm = 0;
1919
1947
  for (let i = 0; i < n; i++) bm += vizBandSmooth[base + i];
1920
1948
  bm /= n;
1949
+ vizBandMean[bi] = bm; // 绘制侧直接复用(drawOneWave 免每帧重扫一遍求均值)
1921
1950
  let bp = 0;
1922
1951
  for (let i = 0; i < n; i++) { const d = Math.abs(vizBandSmooth[base + i] - bm); if (d > bp) bp = d; }
1923
1952
  const sp = Math.max(bp, vizBandPeak[bi] * VIZ_WAVE_PEAK_DECAY);
@@ -1925,18 +1954,22 @@ window.__ModuleLoader__.load({
1925
1954
  vizBandGain[bi] = Math.min(VIZ_WAVE_GAIN, Math.max(VIZ_WAVE_GAIN_MIN, VIZ_WAVE_PEAK_TARGET / Math.max(sp, 1e-3)));
1926
1955
  }
1927
1956
  // 单条回落波形(vizWaveSmooth)同样以 0.5 为中线、对称平滑;仅作降级回退用。
1928
- for (let i = 0; i < vizWave.length; i++) {
1929
- // 256 作归一化基准:getByteTimeDomainData 的静音中心是 128,128/256 = 0.5
1930
- // 正好是画布垂直中线,波形严格围绕中线上下对称(若用 /255 则中心落在 ~0.502,
1931
- // 整条线会系统性偏上约 0.002*(h-1))。
1932
- const t = vizWave[i] / 256;
1933
- const s = vizWaveSmooth[i];
1934
- vizWaveSmooth[i] = s + (t - s) * 0.4;
1957
+ // 分段缓冲已就绪时回退路径不可达(drawWaveform 走分频段多线),跳过缓动省
1958
+ // 每帧 2048 次无效运算。
1959
+ if (vizBandSmooth === null) {
1960
+ for (let i = 0; i < vizWave.length; i++) {
1961
+ // 256 作归一化基准:getByteTimeDomainData 的静音中心是 128,128/256 = 0.5
1962
+ // 正好是画布垂直中线,波形严格围绕中线上下对称(若用 /255 则中心落在 ~0.502,
1963
+ // 整条线会系统性偏上约 0.002*(h-1))。
1964
+ const t = vizWave[i] / 256;
1965
+ const s = vizWaveSmooth[i];
1966
+ vizWaveSmooth[i] = s + (t - s) * 0.4;
1967
+ }
1935
1968
  }
1936
1969
  return true;
1937
1970
  }
1938
1971
  function drawBars(canvas, useCaps) {
1939
- const c = canvas.getContext('2d');
1972
+ const c = vizGet2D(canvas);
1940
1973
  const w = canvas.width; const h = canvas.height;
1941
1974
  c.clearRect(0, 0, w, h);
1942
1975
  const gap = 2;
@@ -1963,8 +1996,8 @@ window.__ModuleLoader__.load({
1963
1996
  // reads as the main contour while mids/highs layer on top, vertically centered on the
1964
1997
  // canvas midline (0.5) to line up with the left-aligned track name.
1965
1998
  function drawWaveform(canvas) {
1966
- if (vizWaveSmooth === null) { canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); return; }
1967
- const c = canvas.getContext('2d');
1999
+ if (vizWaveSmooth === null) { vizGet2D(canvas).clearRect(0, 0, canvas.width, canvas.height); return; }
2000
+ const c = vizGet2D(canvas);
1968
2001
  const w = canvas.width; const h = canvas.height;
1969
2002
  c.clearRect(0, 0, w, h);
1970
2003
  c.lineCap = 'round';
@@ -1983,14 +2016,20 @@ window.__ModuleLoader__.load({
1983
2016
  // 用该频段的自适应增益(安静感知)替换固定放大:大声归一全幅、安静提亮,
1984
2017
  // 见 analyseLiveWave 的 vizBandGain。
1985
2018
  const gain = (vizBandGain !== null && vizBandGain.length === bands.length) ? vizBandGain[b] : VIZ_WAVE_GAIN;
1986
- drawOneWave(c, w, h, n, src, bands[b].alpha, bands[b].width, color, gain);
2019
+ const mean = (vizBandMean !== null && vizBandMean.length === bands.length) ? vizBandMean[b] : undefined;
2020
+ drawOneWave(c, w, h, n, src, bands[b].alpha, bands[b].width, color, gain, mean);
1987
2021
  }
1988
2022
  }
1989
2023
  // Draw a single waveform curve from a normalized(0..1) sample buffer. The curve is
1990
2024
  // centered on its OWN mean so its vertical centroid always lands on the canvas midline
1991
2025
  // (0.5), regardless of any DC bias or content asymmetry — otherwise a non-symmetric
1992
2026
  // waveform (real vocals/rock) would sit visibly toward the top or bottom.
1993
- function drawOneWave(c, w, h, n, samples, alpha, width, color, gain) {
2027
+ //
2028
+ // 绘制前按像素列做 min/max 包络降采样:画布只有 ~60px 宽,逐点画 n=2048 的折线
2029
+ // 点间距仅 ~0.03px(~30 倍超采样),抗锯齿描边是波形模式的主要绘制开销。每列
2030
+ // 只取最大/最小偏差各 1 点(峰值保留 → 与全点绘制视觉无差),描边顶点数从
2031
+ // 3×2048 降到 3×2×w(~94%↓)。分析路径已算出均值时直接复用(mean 参数)。
2032
+ function drawOneWave(c, w, h, n, samples, alpha, width, color, gain, mean) {
1994
2033
  const col = color || currentAccent();
1995
2034
  // gain:该频段的自适应增益(默认固定放大 VIZ_WAVE_GAIN)。
1996
2035
  const g = (typeof gain === 'number' && Number.isFinite(gain) && gain > 0) ? gain : VIZ_WAVE_GAIN;
@@ -1999,18 +2038,41 @@ window.__ModuleLoader__.load({
1999
2038
  c.strokeStyle = col;
2000
2039
  c.lineWidth = width;
2001
2040
  // 求本条波形自身的均值,作为竖直居中轴(强制质心落在画布中线 0.5)。
2002
- let mean = 0;
2003
- for (let i = 0; i < n; i++) mean += samples[i];
2004
- mean /= n;
2005
- c.beginPath();
2041
+ let mu = mean;
2042
+ if (typeof mu !== 'number' || !Number.isFinite(mu)) {
2043
+ mu = 0;
2044
+ for (let i = 0; i < n; i++) mu += samples[i];
2045
+ mu /= n;
2046
+ }
2047
+ // 逐列聚合 min/max 偏差(相对均值)。缓冲按列数复用,宽度变化才重建。
2048
+ const cols = Math.max(1, Math.min(w, n));
2049
+ if (vizEnvW !== cols || vizEnvMin === null || vizEnvMax === null) {
2050
+ vizEnvMin = new Float32Array(cols);
2051
+ vizEnvMax = new Float32Array(cols);
2052
+ vizEnvW = cols;
2053
+ }
2054
+ vizEnvMin.fill(Infinity);
2055
+ vizEnvMax.fill(-Infinity);
2006
2056
  for (let i = 0; i < n; i++) {
2007
- const x = (i / (n - 1)) * w;
2008
- // 以均值 mean 为对称轴放大偏差,再映射到画布高度。y +0.5 把中心精确对齐到
2009
- // 画布视觉正中 h/2(20px 画布的 y=10):直接用 (1-norm)*(h-1) 会把中心落在
2010
- // 9.5,整整偏上 0.5px,直线/波形都会看起来没垂直居中。
2011
- const norm = 0.5 + (samples[i] - mean) * g;
2012
- const y = (1 - norm) * (h - 1) + 0.5; // norm=0.5 → y = h/2 → 竖直居中
2013
- if (i === 0) c.moveTo(x, y); else c.lineTo(x, y);
2057
+ const ci = Math.min(cols - 1, Math.floor((i * cols) / n));
2058
+ const d = samples[i] - mu;
2059
+ if (d < vizEnvMin[ci]) vizEnvMin[ci] = d;
2060
+ if (d > vizEnvMax[ci]) vizEnvMax[ci] = d;
2061
+ }
2062
+ c.beginPath();
2063
+ // min/max 之字形:每列先 max min(列内竖直连线),相邻列首尾相接,
2064
+ // 形成与全点折线等观的连续包络。y 用 +0.5 把中心精确对齐到画布视觉正中
2065
+ // h/2(20px 画布的 y=10):直接用 (1-norm)*(h-1) 会把中心落在 9.5,
2066
+ // 整整偏上 0.5px,直线/波形都会看起来没垂直居中。
2067
+ let started = false;
2068
+ for (let ci = 0; ci < cols; ci++) {
2069
+ const lo = vizEnvMin[ci]; const hi = vizEnvMax[ci];
2070
+ if (!(lo <= hi)) continue; // 空列(理论上不发生,防御 NaN/Inf)
2071
+ const x = ((ci + 0.5) / cols) * w;
2072
+ const yHi = (1 - (0.5 + hi * g)) * (h - 1) + 0.5;
2073
+ const yLo = (1 - (0.5 + lo * g)) * (h - 1) + 0.5;
2074
+ if (!started) { c.moveTo(x, yHi); started = true; } else { c.lineTo(x, yHi); }
2075
+ c.lineTo(x, yLo);
2014
2076
  }
2015
2077
  c.stroke();
2016
2078
  c.globalAlpha = 1;
@@ -2019,16 +2081,22 @@ window.__ModuleLoader__.load({
2019
2081
  if (barCanvasNode === null) return;
2020
2082
  const canvas = barCanvasNode;
2021
2083
  const w = canvas.width; const h = canvas.height;
2022
- // Always probe the live tap (so vizLiveOK gets set the moment it yields signal).
2023
- if (vizLive && vizAnalyser !== null) {
2024
- analyseLiveBands();
2025
- analyseLiveWave();
2084
+ // 暂停/无 tap 时只清画布,不做任何 DSP——对静音做 FFT/时域读取 + 3 频段
2085
+ // biquad 滤波(vizCtx.currentTime 暂停时照走,hop 每帧 ~800 样本)都是无效功。
2086
+ // vizLiveOK 的确认因此发生在播放首帧,不影响显示(未确认本来就不画)。
2087
+ if (!vizLive || vizAnalyser === null || !store.playing) {
2088
+ vizGet2D(canvas).clearRect(0, 0, w, h);
2089
+ return;
2026
2090
  }
2091
+ // 模式互斥读取:wave 只走时域(biquad 分频段),bars 只走频域(对数分段)——
2092
+ // 另一条路径的读取+映射对当前显示是纯开销。vizLiveOK 由各自路径确认
2093
+ // (bars:analyseLiveBands 内频段能量 m>4;wave:analyseLiveWave 内 |x-128|>4)。
2094
+ const waveMode = store.vizMode === 'wave';
2095
+ const analysed = waveMode ? analyseLiveWave() : analyseLiveBands();
2027
2096
  // 失败/未确认信号 -> 直接不显示(无离线回退)。仅当实时路径确认在出信号
2028
2097
  // 且正在播放时才画,否则清空画布。
2029
- const confirmed = vizLive && vizLiveOK && store.playing;
2030
- if (!confirmed) { canvas.getContext('2d').clearRect(0, 0, w, h); return; }
2031
- if (store.vizMode === 'wave') {
2098
+ if (!analysed || !vizLiveOK) { vizGet2D(canvas).clearRect(0, 0, w, h); return; }
2099
+ if (waveMode) {
2032
2100
  drawWaveform(canvas);
2033
2101
  return;
2034
2102
  }
@@ -2214,6 +2282,9 @@ window.__ModuleLoader__.load({
2214
2282
  const BOOK_MAX_CHUNK_SEC = 180;
2215
2283
  let restoredMusicPos = null; // restored music position to display until the audio truly reaches it
2216
2284
  let bookRestorePos = -1; // restored book's in-chunk position, seeked on play
2285
+ // 刚发生过 ended→切块:其后到达的重复 ended 属于旧块陈旧事件(吞掉);
2286
+ // 下一块真正开始播放(play promise 兑现)或用户主动重播/停止时清除。
2287
+ let bookJustAdvanced = false;
2217
2288
  // 当前块是否已自动重试过一次(瞬时 LLM 合成失败时先静音重试一次,
2218
2289
  // 只有重试仍失败才弹错误 + 手动重试)。成功播放后复位。
2219
2290
  let bookAutoRetried = false;
@@ -2370,7 +2441,11 @@ window.__ModuleLoader__.load({
2370
2441
  loadBookSubtitle(id, from);
2371
2442
  const promise = audio.play();
2372
2443
  if (promise !== undefined && typeof promise.then === 'function') {
2373
- promise.then(() => { clearStuck(); doneBuffering(); }).catch((e) => {
2444
+ promise.then(() => {
2445
+ clearStuck();
2446
+ doneBuffering();
2447
+ bookJustAdvanced = false; // 新块真正开始播放:其后同块的 ended 是真实结束
2448
+ }).catch((e) => {
2374
2449
  clearStuck();
2375
2450
  // Distinguish a real autoplay block (NotAllowedError) from a media
2376
2451
  // load/decode failure. Load failures are already surfaced by the
@@ -2395,7 +2470,7 @@ window.__ModuleLoader__.load({
2395
2470
  }
2396
2471
  }
2397
2472
  // `from` lets the toc jump straight to a chapter's chunk index.
2398
- function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookAutoRetried = false; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
2473
+ function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookAutoRetried = false; bookJustAdvanced = false; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
2399
2474
  // Play a novel from its saved progress when available (e.g. switching to
2400
2475
  // music and back), otherwise start fresh from the beginning. Explicit
2401
2476
  // chapter jumps keep using playBook(id, fromChunk) and are unaffected.
@@ -2429,7 +2504,13 @@ window.__ModuleLoader__.load({
2429
2504
  restoredMusicPos = null;
2430
2505
  const endedDur = Number.isFinite(audio.duration) ? audio.duration : (audio.currentTime || 0);
2431
2506
  if (Number.isFinite(endedDur)) bookBaseTime += endedDur;
2432
- playBookFrom(id, bookFromRef + 1, true);
2507
+ // 只有下一块确已预热(preAudio 缓冲就绪、切换瞬时完成)才静默切块;
2508
+ // 冷启动续播跳块时下一块尚未合成(需数秒~数十秒),必须显示「AI 合成中」
2509
+ // 并带上 60s 超时兜底,否则用户看到的是无声无息的「卡住」(既没声音、
2510
+ // 字幕也不动,又没有任何反馈)。
2511
+ const warmed = bookBufferedFrom === bookFromRef + 1;
2512
+ bookJustAdvanced = true; // 后续紧到的重复 ended 属旧块陈旧事件(onEnded 据此吞掉)
2513
+ playBookFrom(id, bookFromRef + 1, warmed);
2433
2514
  // Persist the new chunk/base immediately so a refresh after the switch
2434
2515
  // resumes from here (with the continuous clock) rather than the old chunk.
2435
2516
  lastPosSaveAt = Date.now();
@@ -2438,10 +2519,45 @@ window.__ModuleLoader__.load({
2438
2519
  }
2439
2520
  return false;
2440
2521
  }
2522
+ // 恢复续播的定位钉评估(onTime 与 onDur 共用):
2523
+ // 真实播放明显越过恢复点 → 释放定位钉,交给实时时间;
2524
+ // 恢复点已超出本块实际时长(重启后冷合成可能更短)→ 用户其实已读完这一块,
2525
+ // 直接跳到下一块继续,而不是重听本块(否则整块重播,或钳到块尾卡住);
2526
+ // 时长已知且恢复点在块内 → 才把音频 seek 到恢复点;
2527
+ // 时长未知 → 什么都不做(绝不提前把可能越界的位置塞进 currentTime)。
2528
+ // 关键:onDur(durationchange)也调用——只挂在 timeupdate 上时,若元素被钳在
2529
+ // 块尾(不再播放、不再派发 timeupdate),pastEnd 补救永远不会被评估,续播就会
2530
+ // 永久卡在「没声音、字幕不动」。
2531
+ function evaluateBookRestorePin() {
2532
+ if (!(bookRestorePos >= 0) || !String(store.currentId || '').startsWith('book:')) return false;
2533
+ const ct = audio.currentTime || 0;
2534
+ const dur = (Number.isFinite(audio.duration) && audio.duration > 0) ? audio.duration : 0;
2535
+ if (ct > bookRestorePos + 1) {
2536
+ bookRestorePos = -1; // real playback advanced past the spot — live time
2537
+ return false;
2538
+ }
2539
+ if (dur > 0 && bookRestorePos >= dur) {
2540
+ // maybeAdvanceBook 内部会清 bookRestorePos 并推进 bookBaseTime;若已到最后一
2541
+ // 块(无下一块)则释放定位钉、让本块自然播完后再结束。
2542
+ if (!maybeAdvanceBook()) bookRestorePos = -1;
2543
+ return true;
2544
+ }
2545
+ if (dur > 0) {
2546
+ if (store.playing && ct < bookRestorePos - 0.5) {
2547
+ try { audio.currentTime = bookRestorePos; } catch (e) {}
2548
+ }
2549
+ set({ position: bookBaseTime + bookRestorePos });
2550
+ // 补齐:pin 阶段也推进字幕——续播时该块的实时字幕可能还没就绪,这里在
2551
+ // 每次 timeupdate 都尝试定位当前句,避免「先空白、切块后才出字幕」。
2552
+ updateLyric();
2553
+ return true;
2554
+ }
2555
+ return false; // dur 尚未知(元数据未加载):等 onDur/onTime 再评估
2556
+ }
2441
2557
  function stopBookHelper() {
2442
2558
  preAudio.removeAttribute('src'); preAudio.load();
2443
2559
  if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2444
- bookTotal = -1; bookFromRef = 0; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1;
2560
+ bookTotal = -1; bookFromRef = 0; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookJustAdvanced = false;
2445
2561
  }
2446
2562
  function togglePlay() {
2447
2563
  if (store.pendingId !== null && store.currentId === null) { startPlay(store.pendingId); return; }
@@ -2491,9 +2607,12 @@ window.__ModuleLoader__.load({
2491
2607
  audio.src = chunkUrl;
2492
2608
  audio.load();
2493
2609
  }
2494
- if ((audio.currentTime || 0) < bookRestorePos - 0.5) {
2495
- try { audio.currentTime = bookRestorePos; } catch (e) {}
2496
- }
2610
+ // 注意:这里绝不能提前 seek(旧实现 load() 后立刻 currentTime=bookRestorePos)。
2611
+ // 冷启动续播时本块是重新合成的(TTS 非确定性,时长会变),此时 readyState=0、
2612
+ // 时长未知;若保存位置超出新块时长,越界 seek 会被浏览器钳到块末尾,元素停在
2613
+ // 块尾且不再派发 timeupdate/ended → onTime 的 pastEnd 守卫永远不评估 →
2614
+ // 「没声音、字幕不动」。seek 统一交给定位钉:onDur(时长就绪)/onTime(播放中)
2615
+ // 确认恢复点在块内后才 seek(见 evaluateBookRestorePin)。
2497
2616
  if (bookTotal >= 0 && bookFromRef + 1 < bookTotal) preloadBook(id, bookFromRef + 1);
2498
2617
  // 恢复续播的这块之前没有拉取过字幕(restore 不动 audio、playBookFrom 不经过
2499
2618
  // 这里),必须补一次 loadBookSubtitle,否则续播后整块无字幕、直到切块才出现。
@@ -2517,6 +2636,7 @@ window.__ModuleLoader__.load({
2517
2636
  if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2518
2637
  set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0 });
2519
2638
  }
2639
+ bookJustAdvanced = false; // 本块真正开始播放:其后同块的 ended 是真实结束
2520
2640
  }).catch((err) => {
2521
2641
  // 播放被拒绝(如 autoplay 拦截)时同样清掉缓冲/超时:否则「AI 合成中…」
2522
2642
  // 会一直挂着,而错误提示又不显示(这里只对 autoplay 拦截报错)。
@@ -2714,41 +2834,10 @@ window.__ModuleLoader__.load({
2714
2834
  }
2715
2835
  }
2716
2836
  // A restored book applies the same pin to its in-chunk position (its
2717
- // deferred seek is applied on play, so currentTime is still at the chunk
2718
- // start until then) — anchored on top of the book-wide clock.
2719
- if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:')) {
2720
- const ct = audio.currentTime || 0;
2721
- // 释放/处理条件:真实播放已明显越过恢复点(正常放过去),或恢复点已超出本块
2722
- // 的实际时长。块可能被重新合成得更短(尤其重启后冷合成)——此时若仍把
2723
- // currentTime seek 到保存位置,会被浏览器钳到块末尾,且未必派发 ended,于是
2724
- // 「响一下→没声音、字幕不动」。若用户其实已越过本块(保存位置 ≥ 块长),则
2725
- // 直接跳到下一块;否则释放定位钉、让它从当前处自然播到块尾再切块。
2726
- const dur = (Number.isFinite(audio.duration) && audio.duration > 0) ? audio.duration : 0;
2727
- const pastSpot = ct > bookRestorePos + 1;
2728
- const pastEnd = dur > 0 && bookRestorePos >= dur;
2729
- if (pastSpot) {
2730
- bookRestorePos = -1; // real playback advanced past the spot — live time
2731
- } else if (pastEnd) {
2732
- // 保存位置已越过本块实际末尾(重启后重合成块变短)→ 用户其实已读完这一块,
2733
- // 直接跳到下一块继续,而不是重听本块(否则会整块重播,或响一下→卡住)。
2734
- // maybeAdvanceBook 内部会清 bookRestorePos 并推进 bookBaseTime;若已到最后一
2735
- // 页(无下一块)则释放定位钉、让本块自然播完后再结束。
2736
- if (!maybeAdvanceBook()) bookRestorePos = -1;
2737
- } else if (dur > 0) {
2738
- // 时长已就绪且恢复点在本块内:才把音频 seek 到恢复点(以免提前 seek 到一个
2739
- // 随后才发现越界、被钳到块末尾的位置而卡死)。
2740
- if (store.playing && ct < bookRestorePos - 0.5) {
2741
- try { audio.currentTime = bookRestorePos; } catch (e) {}
2742
- }
2743
- set({ position: bookBaseTime + bookRestorePos });
2744
- // 补齐:pin 阶段也推进字幕——续播时该块的实时字幕可能还没就绪,这里在
2745
- // 每次 timeupdate 都尝试定位当前句,避免「先空白、切块后才出字幕」。
2746
- updateLyric();
2747
- return;
2748
- }
2749
- // dur 尚未知(元数据未加载):先不 seek,走下方正常读值;等时长就绪后上面的
2750
- // pin 会再次评估并正确 seek/释放,绝不提前把一个可能越界的位置塞进 currentTime。
2751
- }
2837
+ // deferred seek is applied once duration is known and we're playing)
2838
+ // anchored on top of the book-wide clock. Shared with onDur so the
2839
+ // pastEnd rescue runs even when no timeupdate ever fires.
2840
+ if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:') && evaluateBookRestorePin()) return;
2752
2841
  set({ position: bookTimeBase() + (audio.currentTime || 0) });
2753
2842
  // Persist the playback spot periodically (≈every 5s) for BOTH music and
2754
2843
  // novels, so a refresh at any moment resumes here instead of jumping
@@ -2780,6 +2869,11 @@ window.__ModuleLoader__.load({
2780
2869
  return; // retry re-loads; don't clobber duration yet
2781
2870
  }
2782
2871
  }
2872
+ // 恢复定位钉在「时长就绪」的瞬间先评估一次:这是 pastEnd(保存位置 ≥ 新块
2873
+ // 实际时长,冷合成块变短)能被最早发现的时机。只靠 onTime 评估会在元素被
2874
+ // 钳到块尾、不再播放、不再派发 timeupdate 时永远轮不到补救(冷启动续播
2875
+ // 「响一下→没声音、字幕不动」的残留卡死点)。
2876
+ if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:')) evaluateBookRestorePin();
2783
2877
  set({ duration: (bookTimeBase() + audio.duration) });
2784
2878
  }
2785
2879
  };
@@ -2790,9 +2884,16 @@ window.__ModuleLoader__.load({
2790
2884
  // next block until the whole book is done, then stop (never step into
2791
2885
  // the music list). Book ids are 'book:'-prefixed.
2792
2886
  if (store.currentId !== null && String(store.currentId).startsWith('book:')) {
2793
- // 切块进行中(bookBuffering 为真,新块还没开始播放)忽略旧块的
2794
- // ended:防止陈旧事件重复触发导致跳块/时间空走。
2795
- if (store.bookBuffering) return;
2887
+ // 切块进行中(bookBuffering 为真)时要区分两种 ended:
2888
+ // 刚切块过(bookJustAdvanced):这是旧块的陈旧重复事件,吞掉——否则
2889
+ // 会连跳两块;标记在下一块真正开始播放(play promise 兑现)时清除。
2890
+ // ② 尚未切块过:这是「本块真结束」带着缓冲态到达(恢复续播被钳到块尾
2891
+ // 等场景),若一并吞掉就会不切块、不报错地永久卡住——清缓冲并正常切块。
2892
+ if (store.bookBuffering && bookJustAdvanced) return;
2893
+ if (store.bookBuffering) {
2894
+ if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2895
+ set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0 });
2896
+ }
2796
2897
  if (!maybeAdvanceBook()) stop();
2797
2898
  return;
2798
2899
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "DeepSeek Harness 音乐 + AI 讲书插件:Vibe coding人工作时的伴侣",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,7 +37,7 @@
37
37
  "scripts": {
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
- "ci": "vitest run"
40
+ "ci": "vitest run --retry=2 --testTimeout=20000"
41
41
  },
42
42
  "dsh": {
43
43
  "bundle": {