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.
- package/README.md +7 -1
- package/lib/client.js +178 -77
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,9 +30,15 @@ DeepSeek Harness 音乐/小说播放插件。
|
|
|
30
30
|
|
|
31
31
|

|
|
32
32
|
|
|
33
|
+

|
|
34
|
+
|
|
33
35
|

|
|
34
36
|
|
|
35
|
-

|
|
37
|
+

|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+

|
|
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
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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
|
|
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
|
|
1967
|
-
const c = canvas
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
-
//
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
|
|
2030
|
-
if (
|
|
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(() => {
|
|
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
|
-
|
|
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
|
-
|
|
2495
|
-
|
|
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
|
|
2718
|
-
//
|
|
2719
|
-
|
|
2720
|
-
|
|
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
|
-
//
|
|
2795
|
-
|
|
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.
|
|
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": {
|