dsh-music-player 0.6.2 → 0.6.4

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 +1 -1
  2. package/lib/client.js +301 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ DeepSeek Harness 本地音乐/小说播放插件。
12
12
 
13
13
  - 本地音频流式播放(HTTP Range),刷新后断点续播
14
14
  - 顺序播放、单曲循环、乱序播放三种模式
15
- - 实时 8 段频谱可视化(真实 FFT 频段,解码时离线计算、跟随播放位置)
15
+ - 实时 12 段频谱可视化(真实 FFT 频段:浏览器支持时走 `captureStream()`+`AnalyserNode` **只读旁路**实时采样、t=0 即响应——它不重定向媒体元素输出,因此绝不会让播放静音;若该环境报 Chromium 的 `getTopURL` 媒体管线错误、取不到音轨,则回退到解码时离线预计算的包络,跟随播放位置)。实时柱高把各频段的 bin 归到对数频段取峰值、按分析器 dB 量程归一化(标准做法),柱高由**绝对响度**驱动(安静时柱自然低),并用一条**固定、与响度无关的频率加权**抹平音乐天然的 1/f 低频倾斜——低频几根不再常年钉在高位,同时安静片段也保持低柱;离线回退包络与实时共用同一条加权,切到回退时观感一致)
16
16
  - **实时歌词/字幕**:本地音频自动匹配同名 `.lrc` 逐行显示;**本地没有同名 `.lrc` 时自动在线兜底**(QQ 音乐官方歌词 → LRCLIB 免费同步歌词,结果按曲目缓存避免重复请求);在线 QQ 歌曲自动取官方歌词(外语歌带逐句翻译「原文 / 翻译」);AI 讲书时显示当前朗读句子(逐句滚动)。歌词/字幕显示在播放条频谱之后、时长之前,仅在闲置(控件组折叠)时展示,鼠标进入操作时自动收起;**AI 讲书还有一条「已读字符/全书字符」的全书进度细线**(按已读字数实时计算,不依赖合成时长,切块不回退,操作时再显示「N%」)
17
17
  - 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
18
18
  - 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
package/lib/client.js CHANGED
@@ -4,12 +4,15 @@
4
4
  * composer dock and a floating player panel (track list / modes / volume /
5
5
  * spectrum) that also holds the music-directory setting in-panel.
6
6
  *
7
- * Audio is a native <audio> element. A real FFT spectrum (8 log-spaced bands)
8
- * is computed offline from the decoded track (per 50ms window) and drawn on a
9
- * canvas rAF loop as a smooth equalizer that follows the current playback
10
- * position. Play mode and volume persist across reloads via the Host's prefs
11
- * endpoint (/dsh-music/prefs no browser storage); the current track +
12
- * position are restored without autoplay (a tap on ▶ resumes).
7
+ * Audio is a native <audio> element. A real FFT spectrum (8 log-spaced bands) is
8
+ * drawn on a canvas rAF loop: the primary source is a live captureStream()+AnalyserNode
9
+ * tap of the playing element — a read-only tap that NEVER reroutes the media element's
10
+ * output, so it can't mute the player (createMediaElementSource, which does reroute, is
11
+ * avoided because it goes silent whenever its AudioContext/graph isn't running and this
12
+ * Chromium throws a "getTopURL" TypeError). Falls back to a per-50ms FFT envelope
13
+ * computed offline from the decoded track. Play mode and volume persist across reloads
14
+ * via the Host's prefs endpoint (/dsh-music/prefs — no browser storage); the current
15
+ * track + position are restored without autoplay (a tap on ▶ resumes).
13
16
  * Host communication is plain HTTP to the /dsh-music/(manifest|intent|set-root|id)
14
17
  * routes.
15
18
  */
@@ -405,7 +408,7 @@ window.__ModuleLoader__.load({
405
408
  }).then((r) => r.json());
406
409
 
407
410
  // ---- engine + shared store (React re-renders on set) ----
408
- const VIZ_BARS = 8; // spectrum bars (log-spaced real FFT bands)
411
+ const VIZ_BARS = 12; // spectrum bars (log-spaced real FFT bands)
409
412
  const PEAK_DECAY = 0.012; // peak-cap fall per frame (~1.3s, visible 渐落 trail)
410
413
  const audio = new Audio();
411
414
  audio.preload = 'auto';
@@ -445,6 +448,10 @@ window.__ModuleLoader__.load({
445
448
  if (p && p.catch) p.catch(() => {});
446
449
  }
447
450
  } catch { /* unlock is best-effort */ }
451
+ // The live-spectrum analyser rides its own AudioContext; resume it too so
452
+ // the bars react on the very first frame after the gesture that unlocks
453
+ // autoplay (books + online QQ) rather than staying silent.
454
+ resumeVizCtx();
448
455
  }
449
456
 
450
457
  const store = {
@@ -1136,7 +1143,12 @@ window.__ModuleLoader__.load({
1136
1143
  return decodeCtx;
1137
1144
  }
1138
1145
  function trimCache() { while (envCache.size > 24) envCache.delete(envCache.keys().next().value); }
1146
+ let envXHR = null; // the in-flight offline-envelope download (aborted once live actually works)
1139
1147
  function loadEnvelope(id, url, isPrefetch) {
1148
+ // Skip only if the live analyser is CONFIRMED to be producing signal for the
1149
+ // current track (vizLiveOK) — then the offline full-file envelope isn't needed.
1150
+ // Otherwise keep it as the reliable fallback for when the live tap yields silence.
1151
+ if (vizLiveOK) return;
1140
1152
  const cached = envCache.get(id);
1141
1153
  const isCurrent = () => store.currentId === id;
1142
1154
  if (cached !== undefined) { if (isCurrent()) { trackEnv = cached; set({ vizState: 'ok' }); } return; }
@@ -1144,11 +1156,15 @@ window.__ModuleLoader__.load({
1144
1156
  if (isCurrent()) { trackEnv = null; set({ vizState: 'loading' }); }
1145
1157
  try {
1146
1158
  const xhr = new XMLHttpRequest();
1159
+ envXHR = xhr;
1147
1160
  xhr.open('GET', url, true);
1148
1161
  xhr.responseType = 'arraybuffer';
1162
+ const clearXHR = () => { if (envXHR === xhr) envXHR = null; };
1149
1163
  xhr.onload = () => {
1150
1164
  // 注意:真实品质不在这里读(这里要等整首音频下载完才 onload,无损歌几十 MB
1151
1165
  // 会拖慢几秒)。由 startQQPlayback 里的 loadQQQuality 用轻量 HEAD 立即读取。
1166
+ clearXHR();
1167
+ if (vizLiveOK) return; // live is already driving the bars — don't need this
1152
1168
  const ac = ensureDecodeCtx();
1153
1169
  if (ac === null || xhr.response === null) return;
1154
1170
  ac.decodeAudioData(xhr.response).then((buf) => {
@@ -1157,10 +1173,12 @@ window.__ModuleLoader__.load({
1157
1173
  const env = { bands: spec.bands, dt: spec.dt, n: spec.n, duration: buf.duration };
1158
1174
  envCache.set(id, env);
1159
1175
  trimCache();
1160
- if (!isPrefetch && reqId === envReqId && isCurrent()) { trackEnv = env; set({ vizState: 'ok' }); }
1176
+ if (!isPrefetch && reqId === envReqId && isCurrent()) {
1177
+ trackEnv = env; set({ vizState: 'ok' });
1178
+ }
1161
1179
  }).catch(() => { if (!isPrefetch && isCurrent()) set({ vizState: 'unavailable' }); });
1162
1180
  };
1163
- xhr.onerror = () => { if (!isPrefetch && isCurrent()) set({ vizState: 'unavailable' }); };
1181
+ xhr.onerror = () => { clearXHR(); if (!isPrefetch && isCurrent()) set({ vizState: 'unavailable' }); };
1164
1182
  xhr.send();
1165
1183
  } catch { if (!isPrefetch && isCurrent()) set({ vizState: 'unavailable' }); }
1166
1184
  }
@@ -1241,17 +1259,17 @@ window.__ModuleLoader__.load({
1241
1259
  return accentObserver;
1242
1260
  }
1243
1261
 
1244
- // ---- real spectrum: offline FFT per window, decoded together with the track ----
1262
+ // ---- real spectrum: offline FFT per window (FALLBACK spectrum source) ----
1245
1263
  // A real FFT spectrum (VIZ_BARS log-spaced frequency bands) computed from the
1246
1264
  // decoded PCM at decode time and stored as a compact per-window band series.
1247
- // The bars follow audio.currentTime through those precomputed bands, so they
1248
- // react to the song's actual frequency content in real time.
1265
+ // The bars follow audio.currentTime through those precomputed bands.
1249
1266
  //
1250
- // Deliberately NOT a live Web Audio AnalyserNode: routing the media element
1251
- // through createMediaElementSource breaks this environment's Chromium media
1252
- // pipeline (an internal "getTopURL" TypeError) and can mute the player. The
1253
- // offline decode already runs for every track (local + online), so this costs
1254
- // nothing extra and cannot touch the audio output path.
1267
+ // This is the FALLBACK: the primary spectrum comes from the live
1268
+ // captureStream()+AnalyserNode tap above (real-time, no full-track download).
1269
+ // The offline decode is kept as a safety net for when the browser's
1270
+ // media pipeline refuses the Web Audio source (the internal "getTopURL" TypeError
1271
+ // this Chromium throws) in that case we must not route the element, and the
1272
+ // offline envelope is the only available source.
1255
1273
  const FFT_SIZE = 2048; // samples per FFT window (~46ms @44.1kHz)
1256
1274
  const SPEC_WINDOW = 0.05; // one band snapshot per 50ms
1257
1275
  // Radix-2 in-place iterative FFT (re/im are Float32Arrays of equal power-of-2 length).
@@ -1283,6 +1301,28 @@ window.__ModuleLoader__.load({
1283
1301
  }
1284
1302
  // Per-window log-band magnitudes for a decoded mono channel, normalized 0..1
1285
1303
  // with a sqrt perceptual curve so quiet passages stay visible.
1304
+ // Fixed per-band frequency-weighting gain (0..1, top band = 1), shared by BOTH the live
1305
+ // analyser and the offline FFT envelope so the look is uniform whichever path drives the
1306
+ // bars. A raw frequency read is an ABSOLUTE dB magnitude, and music's natural 1/f
1307
+ // (bass-heavy) tilt maps the low bands near the top while the high bands sit low. This
1308
+ // FIXED, level-independent weighting flattens the SHAPE while ABSOLUTE loudness still
1309
+ // drives each bar — a quiet passage stays low (no per-band auto-gain, which would inflate a
1310
+ // quiet band to full). gain[b] = (center_b / center_top) ^ ALPHA. With ALPHA=0.12 the low
1311
+ // band is attenuated to ~0.53 while the top band keeps 1.0 — enough to counter the typical
1312
+ // bass-heavy envelope without inverting the spectrum or pinning the highs.
1313
+ const VIZ_TILT_ALPHA = 0.12;
1314
+ function bandTiltGain(sampleRate) {
1315
+ const g = new Float32Array(VIZ_BARS);
1316
+ const maxF = Math.min((sampleRate || 48000) / 2, 18000);
1317
+ const ratio = maxF / 40;
1318
+ let cTop = 0;
1319
+ for (let b = 0; b < VIZ_BARS; b++) cTop = 40 * Math.pow(ratio, (b + 0.5) / VIZ_BARS);
1320
+ for (let b = 0; b < VIZ_BARS; b++) {
1321
+ const c = 40 * Math.pow(ratio, (b + 0.5) / VIZ_BARS);
1322
+ g[b] = cTop > 0 ? Math.pow(c / cTop, VIZ_TILT_ALPHA) : 1;
1323
+ }
1324
+ return g;
1325
+ }
1286
1326
  function computeSpectrum(ch, sampleRate) {
1287
1327
  const dt = SPEC_WINDOW;
1288
1328
  const n = Math.max(1, Math.ceil(ch.length / sampleRate / dt));
@@ -1320,22 +1360,152 @@ window.__ModuleLoader__.load({
1320
1360
  }
1321
1361
  }
1322
1362
  if (gmax > 0) {
1323
- for (let i = 0; i < bands.length; i++) bands[i] = Math.min(1, Math.sqrt(bands[i] / gmax));
1363
+ // Apply the same fixed frequency weighting as the live analyser, so the offline FFT
1364
+ // envelope (fallback) shows the same flattened shape instead of a bass-heavy blob.
1365
+ const g = bandTiltGain(sampleRate);
1366
+ for (let i = 0; i < bands.length; i++) {
1367
+ bands[i] = Math.min(1, Math.sqrt(bands[i] / gmax) * g[i % VIZ_BARS]);
1368
+ }
1324
1369
  }
1325
1370
  return { bands, dt, n };
1326
1371
  }
1372
+ // ---- live real-time analyser (BEST-EFFORT tap; NEVER reroutes audio) ----
1373
+ // captureStream() is a read-only TAP of the playing <audio>: it does NOT redirect
1374
+ // the element's output (unlike createMediaElementSource, which routes it into the
1375
+ // Web Audio graph and goes SILENT whenever that graph/context isn't running — which
1376
+ // is exactly what killed the audio after switching a few tracks). So this can never
1377
+ // mute the player: worst case the analyser reads silence and we fall back to the
1378
+ // offline FFT envelope below.
1379
+ //
1380
+ // It's created only once the element has a real src (startPlay / the play event) so
1381
+ // the captured MediaStream carries an audio track. If this browser's media pipeline
1382
+ // still refuses a track (the getTopURL bug), vizLive stays false and we use the
1383
+ // offline envelope.
1384
+ let vizCtx = null;
1385
+ let vizAnalyser = null;
1386
+ let vizFreq = null;
1387
+ let vizLive = false;
1388
+ let vizLiveOK = false; // live analyser has produced real signal for the CURRENT track
1389
+ let vizSetupState = 0; // 0 = not tried; 1 = live active; 2 = permanently unavailable
1390
+ const vizBands = new Float32Array(VIZ_BARS);
1391
+ // Cached per-band frequency-weighting gain for the live analyser; shared code is
1392
+ // bandTiltGain(sampleRate) in the spectrum section. Recomputing is needed only when the
1393
+ // AudioContext sample rate changes (a new tap), so it's reset in setupLiveViz.
1394
+ let vizTiltGain = null;
1395
+ function liveTiltGain() {
1396
+ if (vizTiltGain !== null) return vizTiltGain;
1397
+ const sr = (vizCtx && vizCtx.sampleRate) || 48000;
1398
+ vizTiltGain = bandTiltGain(sr);
1399
+ return vizTiltGain;
1400
+ }
1401
+ function resumeVizCtx() {
1402
+ try {
1403
+ if (vizCtx !== null && vizCtx.state === 'suspended') {
1404
+ const p = vizCtx.resume();
1405
+ if (p && typeof p.catch === 'function') p.catch(() => {});
1406
+ }
1407
+ } catch (e) { /* best-effort */ }
1408
+ }
1409
+ function setupLiveViz() {
1410
+ if (vizLive) return true;
1411
+ if (vizSetupState === 2) return false;
1412
+ // Web Audio capability checks.
1413
+ const Ctor = window.AudioContext || window.webkitAudioContext;
1414
+ const hasCapture = typeof audio.captureStream === 'function';
1415
+ if (!hasCapture) { vizSetupState = 2; return false; }
1416
+ if (Ctor === undefined) { vizSetupState = 2; return false; }
1417
+ // IMPORTANT: calling captureStream() before the element has any data returns a
1418
+ // MediaStream with NO audio track — and because a media element returns the SAME
1419
+ // cached stream on every captureStream() call, that 0-track stream sticks forever.
1420
+ // So only capture once the element is actually playing (readyState >= 3);
1421
+ // otherwise retry from the 'playing' event.
1422
+ if (audio.readyState < 3) return false;
1423
+ try {
1424
+ const stream = audio.captureStream();
1425
+ let tracks = [];
1426
+ if (stream && typeof stream.getAudioTracks === 'function') { try { tracks = stream.getAudioTracks(); } catch (e) { tracks = []; } }
1427
+ if (!stream || typeof stream.getAudioTracks !== 'function') { vizSetupState = 2; return false; }
1428
+ if (tracks.length === 0) return false; // no audio track -> rely on the offline envelope
1429
+ vizCtx = new Ctor();
1430
+ const srcNode = vizCtx.createMediaStreamSource(stream);
1431
+ vizAnalyser = vizCtx.createAnalyser();
1432
+ vizAnalyser.fftSize = 2048;
1433
+ // Bar HEIGHT is governed by the dB range below. This matches the audioMotion-analyzer
1434
+ // default (min:-85, max:-25) so the display scale is consistent with that mature
1435
+ // implementation. The temporal smoothing keeps the bars crisp and "follow the hand":
1436
+ // low value => the analyser tracks the instantaneous FFT so the bars snap to the music
1437
+ // (with only our own rise/fall smoothing in drawViz on top).
1438
+ vizAnalyser.smoothingTimeConstant = 0.3;
1439
+ vizAnalyser.minDecibels = -85;
1440
+ vizAnalyser.maxDecibels = -25;
1441
+ vizFreq = new Uint8Array(vizAnalyser.frequencyBinCount);
1442
+ srcNode.connect(vizAnalyser); // analysis tap only — do NOT route to destination
1443
+ vizLive = true;
1444
+ vizSetupState = 1;
1445
+ // A fresh tap = a fresh song: drop the live-confirmed flag so a silent new tap can fall
1446
+ // back to the offline envelope, and recompute the frequency weighting for the new context.
1447
+ vizLiveOK = false;
1448
+ vizTiltGain = null;
1449
+ resumeVizCtx();
1450
+ return true;
1451
+ } catch (e) {
1452
+ if (vizCtx !== null) { try { vizCtx.close(); } catch (e2) {} }
1453
+ vizCtx = null; vizAnalyser = null; vizFreq = null; vizLive = false; vizSetupState = 2;
1454
+ return false;
1455
+ }
1456
+ }
1457
+ function closeLiveViz() {
1458
+ if (vizCtx !== null) { try { vizCtx.close(); } catch (e) {} }
1459
+ vizCtx = null; vizAnalyser = null; vizFreq = null; vizLive = false; vizSetupState = 0;
1460
+ }
1461
+ // Map AnalyserNode's per-bin byte data (dB→0..255) into the VIZ_BARS
1462
+ // log-spaced bands, matching the offline computeSpectrum band edges. Marks
1463
+ // vizLiveOK once the tap actually yields signal, and then drops the redundant
1464
+ // offline full-file envelope download.
1465
+ function analyseLiveBands() {
1466
+ if (!vizLive || vizAnalyser === null) return false;
1467
+ vizAnalyser.getByteFrequencyData(vizFreq);
1468
+ const binHz = vizCtx.sampleRate / vizAnalyser.fftSize;
1469
+ const maxF = Math.min(binHz * vizFreq.length, 18000);
1470
+ const ratio = maxF / 40;
1471
+ const g = liveTiltGain();
1472
+ let any = false;
1473
+ for (let b = 0; b < VIZ_BARS; b++) {
1474
+ const e0 = 40 * Math.pow(ratio, b / VIZ_BARS);
1475
+ const e1 = 40 * Math.pow(ratio, (b + 1) / VIZ_BARS);
1476
+ const b0 = Math.max(0, Math.floor(e0 / binHz));
1477
+ const b1 = Math.min(vizFreq.length, Math.max(b0 + 1, Math.ceil(e1 / binHz)));
1478
+ let m = 0;
1479
+ for (let k = b0; k < b1; k++) { const v = vizFreq[k]; if (v > m) m = v; }
1480
+ const raw = m / 255; // byte ∈ dB range → 0..1 (the standard AnalyserNode normalization)
1481
+ // Absolute loudness drives this bar (so a quiet passage stays low), but a fixed
1482
+ // frequency weighting flattens the bass-heavy shape so the low bands aren't pinned at
1483
+ // the top during loud music. See liveTiltGain().
1484
+ vizBands[b] = Math.min(1, raw * g[b]);
1485
+ if (m > 4) any = true; // above the (minDecibels floor) silence => real signal
1486
+ }
1487
+ if (any && !vizLiveOK) {
1488
+ vizLiveOK = true;
1489
+ // Live is confirmed driving the bars — the offline full-file download is
1490
+ // unneeded; cancel it to save bandwidth.
1491
+ if (envXHR !== null) { try { envXHR.abort(); } catch (e) {} envXHR = null; }
1492
+ }
1493
+ return true;
1494
+ }
1327
1495
  function drawBars(canvas, useCaps) {
1328
1496
  const c = canvas.getContext('2d');
1329
1497
  const w = canvas.width; const h = canvas.height;
1330
1498
  c.clearRect(0, 0, w, h);
1331
1499
  const gap = 2;
1332
- const bw = (w - gap * (VIZ_BARS - 1)) / VIZ_BARS;
1500
+ const bw = 3; // fixed 3px bar width (12 bars fit the 60px bar without crowding)
1501
+ // Center the group of bars within the canvas.
1502
+ const x0 = Math.max(0, Math.round((w - (bw * VIZ_BARS + gap * (VIZ_BARS - 1))) / 2));
1333
1503
  const color = currentAccent();
1334
1504
  // 峰值帽用亮度自适应色(暗主题→提亮、浅主题→压暗),任何主题下都与柱体区分。
1335
1505
  const capColor = capColorFor(color);
1336
1506
  for (let i = 0; i < VIZ_BARS; i++) {
1337
1507
  const bh = Math.max(2, Math.round(smoothCur[i] * (h - 2)));
1338
- const x = Math.round(i * (bw + gap));
1508
+ const x = x0 + i * (bw + gap);
1339
1509
  c.fillStyle = color;
1340
1510
  c.fillRect(x, h - 1 - bh, Math.max(1, Math.floor(bw)), bh);
1341
1511
  if (useCaps && smoothPeak[i] > smoothCur[i] + 0.03) {
@@ -1346,19 +1516,30 @@ window.__ModuleLoader__.load({
1346
1516
  }
1347
1517
  }
1348
1518
  function drawViz() {
1349
- if (store.playing && trackEnv !== null) {
1350
- const envIdx = (audio.currentTime || 0) / trackEnv.dt;
1351
- const base = Math.max(0, Math.min(trackEnv.n - 1, Math.floor(envIdx)));
1352
- const off = base * VIZ_BARS;
1353
- for (let i = 0; i < VIZ_BARS; i++) {
1354
- // blend the current window with the previous for smoother motion
1355
- const cur = trackEnv.bands[off + i];
1356
- const prev = base > 0 ? trackEnv.bands[off - VIZ_BARS + i] : 0;
1357
- targetBuf[i] = cur * 0.6 + prev * 0.4;
1519
+ if (store.playing) {
1520
+ // Always probe the live tap (so vizLiveOK gets set the moment it yields signal),
1521
+ // but only DRIVE the bars with it once it's actually producing signal. The offline
1522
+ // FFT envelope is the reliable fallback for when the tap is silent.
1523
+ // Priority: live (confirmed signal) > offline envelope > idle breathing.
1524
+ if (vizLive && vizAnalyser !== null) {
1525
+ analyseLiveBands();
1526
+ }
1527
+ if (vizLive && vizLiveOK) {
1528
+ for (let i = 0; i < VIZ_BARS; i++) targetBuf[i] = vizBands[i];
1529
+ } else if (trackEnv !== null) {
1530
+ const envIdx = (audio.currentTime || 0) / trackEnv.dt;
1531
+ const base = Math.max(0, Math.min(trackEnv.n - 1, Math.floor(envIdx)));
1532
+ const off = base * VIZ_BARS;
1533
+ for (let i = 0; i < VIZ_BARS; i++) {
1534
+ // blend the current window with the previous for smoother motion
1535
+ const cur = trackEnv.bands[off + i];
1536
+ const prev = base > 0 ? trackEnv.bands[off - VIZ_BARS + i] : 0;
1537
+ targetBuf[i] = cur * 0.6 + prev * 0.4;
1538
+ }
1539
+ } else {
1540
+ const now = Date.now();
1541
+ for (let i = 0; i < VIZ_BARS; i++) targetBuf[i] = 0.12 + 0.05 * Math.sin(now / 240 + i * 0.9);
1358
1542
  }
1359
- } else if (store.playing) {
1360
- const now = Date.now();
1361
- for (let i = 0; i < VIZ_BARS; i++) targetBuf[i] = 0.12 + 0.05 * Math.sin(now / 240 + i * 0.9);
1362
1543
  } else {
1363
1544
  for (let i = 0; i < VIZ_BARS; i++) targetBuf[i] = 0;
1364
1545
  }
@@ -1463,9 +1644,20 @@ window.__ModuleLoader__.load({
1463
1644
  lastPlayStartTs = Date.now();
1464
1645
  restoredMusicPos = null;
1465
1646
  bookRestorePos = -1;
1647
+ // A fresh track gets a fresh live tap: the captureStream tap is tied to the
1648
+ // media pipeline of the src it was created on, so switching songs must tear it
1649
+ // down and let the 'playing' event re-capture for the NEW src (otherwise the tap
1650
+ // reads the old song's silence and we'd wrongly fall back to offline).
1651
+ closeLiveViz();
1466
1652
  audio.src = track.url;
1467
1653
  audio.load();
1468
- set({ currentId: id, currentName: track.name, currentArtists: track.artists || [], pendingId: null, pendingName: null, error: null, tocOpen: false, currentSection: '', qqFaved: false, currentQuality: (track && track.quality) || '', bookProgress: 0 });
1654
+ // (Re)attempt the live spectrum tap now that a real src is loaded the captured
1655
+ // MediaStream only carries an audio track once the element has a source.
1656
+ setupLiveViz();
1657
+ // A fresh track always starts from 0 (audio.src/load resets currentTime) —
1658
+ // reset the readout so a stale restored position (from the previous song)
1659
+ // never lingers on the bar before the first timeupdate.
1660
+ set({ currentId: id, currentName: track.name, currentArtists: track.artists || [], pendingId: null, pendingName: null, error: null, tocOpen: false, currentSection: '', qqFaved: false, currentQuality: (track && track.quality) || '', bookProgress: 0, position: 0, duration: 0 });
1469
1661
  syncShufflePos();
1470
1662
  loadEnvelope(id, track.url);
1471
1663
  loadLyricForTrack(id);
@@ -1729,6 +1921,10 @@ window.__ModuleLoader__.load({
1729
1921
  if (bookFromRef + 1 < bookTotal) {
1730
1922
  // 进入新块:每个块各拥有一次自动重试的机会。
1731
1923
  bookAutoRetried = false;
1924
+ // 恢复定位钉只属于「被恢复的那一块」:切到下一块必须丢弃,否则下一块的
1925
+ // 音频会被 seek 回上一块的恢复位置(跳到末尾即刻结束 → 卡住/无声音/字幕不动)。
1926
+ bookRestorePos = -1;
1927
+ restoredMusicPos = null;
1732
1928
  const endedDur = Number.isFinite(audio.duration) ? audio.duration : (audio.currentTime || 0);
1733
1929
  if (Number.isFinite(endedDur)) bookBaseTime += endedDur;
1734
1930
  playBookFrom(id, bookFromRef + 1, true);
@@ -1983,8 +2179,13 @@ window.__ModuleLoader__.load({
1983
2179
  // start until then) — anchored on top of the book-wide clock.
1984
2180
  if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:')) {
1985
2181
  const ct = audio.currentTime || 0;
1986
- if (ct > bookRestorePos + 1) {
1987
- bookRestorePos = -1; // real playback advanced past the spot
2182
+ // 释放条件:真实播放已明显越过恢复点,或恢复点已超出本块的实际时长
2183
+ // (块可能被重新合成得更短——此时继续 pin 会反复 seek 到块末尾并即刻
2184
+ // 结束,表现为卡住/无声音/字幕不动)。超出时放弃该恢复点、从块头正常播。
2185
+ const pastSpot = ct > bookRestorePos + 1;
2186
+ const pastEnd = Number.isFinite(audio.duration) && audio.duration > 0 && bookRestorePos >= audio.duration;
2187
+ if (pastSpot || pastEnd) {
2188
+ bookRestorePos = -1; // real playback advanced past the spot / spot unreachable
1988
2189
  } else {
1989
2190
  if (store.playing && ct < bookRestorePos - 0.5) {
1990
2191
  try { audio.currentTime = bookRestorePos; } catch (e) {}
@@ -2030,7 +2231,7 @@ window.__ModuleLoader__.load({
2030
2231
  set({ duration: (bookTimeBase() + audio.duration) });
2031
2232
  }
2032
2233
  };
2033
- const onPlay = () => { qqErrorSkipCount = 0; set({ playing: true, error: null }); acquireWakeLock(); };
2234
+ const onPlay = () => { qqErrorSkipCount = 0; set({ playing: true, error: null }); acquireWakeLock(); setupLiveViz(); resumeVizCtx(); };
2034
2235
  const onPause = () => { set({ playing: false }); savePlayback(); releaseWakeLock(); };
2035
2236
  const onEnded = () => {
2036
2237
  // A novel plays chunk-by-chunk: when a chunk ends, auto-advance to the
@@ -2099,6 +2300,10 @@ window.__ModuleLoader__.load({
2099
2300
  audio.addEventListener('pause', onPause);
2100
2301
  audio.addEventListener('ended', onEnded);
2101
2302
  audio.addEventListener('error', onError);
2303
+ // captureStream() only carries an audio track once the element is actually
2304
+ // playing (readyState >= 3); 'play' can fire before there is data, so retry here.
2305
+ const onPlaying = () => { setupLiveViz(); resumeVizCtx(); };
2306
+ audio.addEventListener('playing', onPlaying);
2102
2307
  return () => {
2103
2308
  audio.pause();
2104
2309
  audio.removeEventListener('timeupdate', onTime);
@@ -2106,6 +2311,7 @@ window.__ModuleLoader__.load({
2106
2311
  audio.removeEventListener('play', onPlay);
2107
2312
  audio.removeEventListener('pause', onPause);
2108
2313
  audio.removeEventListener('ended', onEnded);
2314
+ audio.removeEventListener('playing', onPlaying);
2109
2315
  audio.removeEventListener('error', onError);
2110
2316
  };
2111
2317
  }
@@ -2407,7 +2613,7 @@ window.__ModuleLoader__.load({
2407
2613
  // 播放控制图标(上一首/播放/暂停/下一首/停止):用 SVG 替代 ⏮▶⏸⏭⏹ 文本字形。
2408
2614
  // 这些 Unicode 符号(尤其 ⏸ 常以 emoji 呈现)宽高/基线不一致,点击切换会让按钮
2409
2615
  // 大小与位置偏移;统一用同尺寸 viewBox=24 的 SVG,保证按钮恒定尺寸、图标精确居中。
2410
- const iconSvg = (path, w = 14) => (props) => React.createElement('svg', { className: props.className || '', width: w, height: w, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
2616
+ const iconSvg = (path, w = 16) => (props) => React.createElement('svg', { className: props.className || '', width: w, height: w, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
2411
2617
  React.createElement('path', { d: path }));
2412
2618
  const PlayIcon = iconSvg('M8 5v14l11-7z');
2413
2619
  const PauseIcon = iconSvg('M6 19h4V5H6v14zm8-14v14h4V5h-4z');
@@ -2544,12 +2750,14 @@ window.__ModuleLoader__.load({
2544
2750
  const showHint = s.pendingName !== null && s.currentId === null;
2545
2751
  const panelCls = 'dsh-music-mode-trigger' + (s.panelOpen ? ' active' : '');
2546
2752
  let vizBadge = null;
2547
- // 频谱不可用提示仅对本地/讲书曲目有效(重试能重新拉取独立音频源)。
2753
+ // 频谱不可用提示仅对本地/讲书曲目有效(重试能重新拉取独立音频源)。当实时
2754
+ // AnalyserNode 频谱已生效(vizLive)时,离线包偶发失败无关紧要,
2755
+ // 不显示该徽标误导用户——真实频谱由实时路径提供。
2548
2756
  // 在线 QQ 曲目与音频走同一代理流:频谱解码失败几乎必然意味着整段流
2549
2757
  // 播放失败,此时由音频 error 展示真实原因(如「音频加载或解码失败」),
2550
2758
  // 这里不再误导性地显示「频谱不可用,点击重试」。
2551
2759
  const isQQTrack = s.currentId !== null && String(s.currentId).startsWith('qq:');
2552
- if (hasTrack && s.vizState === 'unavailable' && !isQQTrack) {
2760
+ if (hasTrack && s.vizState === 'unavailable' && !isQQTrack && !vizLive) {
2553
2761
  vizBadge = React.createElement('button', {
2554
2762
  className: 'dsh-music-bar-warn',
2555
2763
  title: '频谱不可用,点击重试',
@@ -2601,7 +2809,7 @@ window.__ModuleLoader__.load({
2601
2809
  title: faved ? '取消收藏(从「我最喜欢」移除)' : '收藏到「我最喜欢」',
2602
2810
  onClick: toggleFav,
2603
2811
  }, React.createElement('svg', {
2604
- viewBox: '0 0 24 24', width: 14, height: 14,
2812
+ viewBox: '0 0 24 24', width: 16, height: 16,
2605
2813
  fill: faved ? 'currentColor' : 'none', stroke: 'currentColor', strokeWidth: 2, 'aria-hidden': true,
2606
2814
  }, React.createElement('path', { d: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' }))) : null;
2607
2815
  const showBarBtns = () => { if (hoverTimerRef.current !== null) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } setBarHover(true); };
@@ -2634,7 +2842,7 @@ window.__ModuleLoader__.load({
2634
2842
  hasTrack
2635
2843
  ? React.createElement('span', { className: 'dsh-music-bar-name', title: displayName + (artistText ? ' - ' + artistText : '') + (chapterEl ? ' - ' + s.currentSection : '') }, note, ' ', displayName, artistEl, sourceBadge, localQualityBadge, chapterEl, afterName)
2636
2844
  : React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' DSH音乐播放器'),
2637
- !isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 46, height: 20, ref: (el) => { barCanvasNode = el; } }) : null,
2845
+ !isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 60, height: 20, ref: (el) => { barCanvasNode = el; } }) : null,
2638
2846
  vizBadge,
2639
2847
  // 歌词/字幕:位于频谱之后、时长之前;仅"非使用态"(控件组已折叠、播放条
2640
2848
  // 半透明)显示——正在操作时收起,不给滑入的按钮组让路。
@@ -2852,9 +3060,17 @@ window.__ModuleLoader__.load({
2852
3060
  const id = String(song.songmid || song.id);
2853
3061
  const url = '/dsh-music/qq/play/' + id;
2854
3062
  const q = (Array.isArray(queue) && queue.length > 0) ? queue.slice() : [song];
3063
+ // 换到一首「新」的在线曲目:必须清除刷新恢复的定位钉,否则 onTime 会把这条
3064
+ // 新流 seek 回上一首的保存进度(换歌从旧进度开始)。startQQPlayback 只在点/
3065
+ // 切到新曲目时被调用(同曲目在面板上走 togglePlay 续播),因此这里无条件清除。
3066
+ restoredMusicPos = null;
3067
+ bookRestorePos = -1;
3068
+ // Fresh live tap per track (see startPlay): re-capture for the NEW src.
3069
+ closeLiveViz();
2855
3070
  audio.src = url;
2856
3071
  audio.load();
2857
- set({ currentId: 'qq:' + id, currentName: song.title, currentArtists: (song.artists || []), scope: { kind: 'qq' }, qqQueue: q, qqSource: sourceLabel || (q.length > 1 ? '在线' : ''), error: null, qqFaved: false, currentQuality: '' });
3072
+ setupLiveViz();
3073
+ set({ currentId: 'qq:' + id, currentName: song.title, currentArtists: (song.artists || []), scope: { kind: 'qq' }, qqQueue: q, qqSource: sourceLabel || (q.length > 1 ? '在线' : ''), error: null, qqFaved: false, currentQuality: '', position: 0, duration: 0 });
2858
3074
  loadEnvelope('qq:' + id, url);
2859
3075
  loadQQQuality(id, url);
2860
3076
  loadQQLyric('qq:' + id, id);
@@ -4288,13 +4504,20 @@ window.__ModuleLoader__.load({
4288
4504
  attachAudioElements();
4289
4505
  const unbind = bindAudio();
4290
4506
  startRaf();
4507
+ // The live analyser is a captureStream() TAP — created only once a real src is
4508
+ // loaded (startPlay/onPlay), NOT up front, because a stream captured before any
4509
+ // src exists can carry no audio track. We deliberately avoid createMediaElementSource
4510
+ // here: it re-routes the element's output into the Web Audio graph and goes
4511
+ // SILENT whenever that graph/context isn't running (which is what broke audio
4512
+ // after switching tracks). The tap can never mute the player; if the tap can't
4513
+ // provide audio (the getTopURL bug) we fall back to the offline FFT envelope.
4291
4514
  const accentWatch = watchAccent();
4292
4515
  // Browsers auto-release a wake lock when the page is hidden; re-acquire
4293
4516
  // on return if playback is still running, and drop it on hide so we
4294
4517
  // don't hold it while the tab is backgrounded.
4295
4518
  const onVis = () => {
4296
4519
  if (document.hidden) releaseWakeLock();
4297
- else acquireWakeLock();
4520
+ else { acquireWakeLock(); resumeVizCtx(); }
4298
4521
  };
4299
4522
  // On refresh/unload, stop the media element cleanly BEFORE the document
4300
4523
  // is torn down — otherwise Chromium's media pipeline can race the
@@ -4308,10 +4531,30 @@ window.__ModuleLoader__.load({
4308
4531
  void flushServerPrefs();
4309
4532
  };
4310
4533
  document.addEventListener('visibilitychange', onVis);
4534
+ // Resume the analyser's AudioContext on any user gesture so it's already
4535
+ // running when a track starts. setupLiveViz() creates the tap once a real src
4536
+ // is loaded (startPlay/onPlay); the context just needs to be running for the
4537
+ // bars to move.
4538
+ const onFirstGesture = () => { resumeVizCtx(); };
4539
+ window.addEventListener('pointerdown', onFirstGesture);
4540
+ window.addEventListener('keydown', onFirstGesture, true);
4311
4541
  window.addEventListener('pagehide', onPageHide);
4542
+ // This Chromium's media pipeline throws a benign internal "getTopURL" TypeError
4543
+ // as an UNHANDLED promise rejection while a media element is routed through
4544
+ // Web Audio (needed for the live spectrum) with our proxied stream. It does NOT
4545
+ // affect playback or the analyser — but it spams the console. Suppress exactly
4546
+ // that benign case; leave every other rejection untouched.
4547
+ const onUnhandled = (ev) => {
4548
+ const r = ev && ev.reason;
4549
+ if (r && /getTopURL/.test(String((r && r.message) || r))) ev.preventDefault();
4550
+ };
4551
+ window.addEventListener('unhandledrejection', onUnhandled);
4312
4552
  return () => {
4553
+ window.removeEventListener('unhandledrejection', onUnhandled);
4313
4554
  window.removeEventListener('pagehide', onPageHide);
4314
- document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); releaseWakeLock();
4555
+ window.removeEventListener('pointerdown', onFirstGesture);
4556
+ window.removeEventListener('keydown', onFirstGesture, true);
4557
+ document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); closeLiveViz(); releaseWakeLock();
4315
4558
  if (accentWatch !== null) accentWatch.disconnect();
4316
4559
  accentObserver = null;
4317
4560
  };
@@ -4364,9 +4607,14 @@ window.__ModuleLoader__.load({
4364
4607
  }
4365
4608
  const track = resolvePlayable(intent.id);
4366
4609
  if (track !== null) {
4610
+ // 换到一首「新」的曲目:清除刷新恢复的定位钉,避免 onTime 把新曲目 seek
4611
+ // 回上一首的保存进度(换歌从旧进度开始)。play 意图=从头开始(续播走
4612
+ // togglePlay / resume 意图),因此这里无条件清除。
4613
+ restoredMusicPos = null;
4614
+ bookRestorePos = -1;
4367
4615
  audio.src = track.url;
4368
4616
  audio.load();
4369
- set({ currentId: intent.id, currentName: track.name, currentArtists: track.artists || [], error: null, scope: { kind: 'library' } });
4617
+ set({ currentId: intent.id, currentName: track.name, currentArtists: track.artists || [], error: null, scope: { kind: 'library' }, position: 0, duration: 0 });
4370
4618
  loadEnvelope(intent.id, track.url);
4371
4619
  prefetchNext();
4372
4620
  savePlayback();
@@ -4419,7 +4667,7 @@ window.__ModuleLoader__.load({
4419
4667
  '.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
4420
4668
  '.dsh-music-bar-name { max-width: 36%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-flex; align-items: center; min-width: 0; }\n' +
4421
4669
  '.dsh-music-note { color: var(--dsh-music-accent, #2f9e6e); flex: none; margin-right: 4px; }\n' +
4422
- '.dsh-music-viz { flex: none; width: 46px; height: 20px; }\n' +
4670
+ '.dsh-music-viz { flex: none; width: 60px; height: 20px; }\n' +
4423
4671
  // 歌词/字幕:夹在频谱与时长之间,吃掉剩余宽度,文本在可用空间内水平居中,
4424
4672
  // 超长省略号截断。仅非使用态渲染(元素随 .dsh-music-bar.dimmed 半透明,符合
4425
4673
  // 后台静默观感)。出现时延迟 0.3s(与控件组滑出的 0.3s 过渡对齐)再淡入,
@@ -4428,9 +4676,9 @@ window.__ModuleLoader__.load({
4428
4676
  '.dsh-music-bar-lyric { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 14px; animation: dsh-music-lyric-in 0.3s ease 0.3s backwards; }\n' +
4429
4677
  '@keyframes dsh-music-lyric-in { from { opacity: 0; } to { opacity: 1; } }\n' +
4430
4678
  '.dsh-music-bar-warn { background: transparent; border: none; color: var(--dsw-alias-state-warn-primary, #d9a441); font-size: 12px; cursor: pointer; padding: 0; white-space: nowrap; }\n' +
4431
- '.dsh-music-bar-btn { display: inline-flex; align-items: center; justify-content: center; flex: none; height: 20px; background: transparent; border: none; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 4px; border-radius: 4px; }\n' +
4432
- '.dsh-music-bar-btn:hover { color: var(--dsw-alias-brand-primary, #4f8cff); }\n' +
4433
- '.dsh-music-bar-btn.active { color: var(--dsw-alias-brand-primary, #4f8cff); }\n' +
4679
+ '.dsh-music-bar-btn { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 50%; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.05)); color: var(--dsh-music-accent, #2f9e6e); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; }\n' +
4680
+ '.dsh-music-bar-btn:hover { color: var(--dsh-music-accent-fg, #fff); background: var(--dsh-music-accent, #2f9e6e); }\n' +
4681
+ '.dsh-music-bar-btn.active { color: var(--dsh-music-accent, #2f9e6e); }\n' +
4434
4682
  '.dsh-music-bar-vol { position: relative; flex: none; display: inline-flex; align-self: center; }\n' +
4435
4683
  '.dsh-music-bar-vol-pop { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); display: flex; align-items: center; justify-content: center; width: 36px; height: 108px; box-sizing: border-box; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 8px; box-shadow: 0 8px 20px rgba(0,0,0,0.3); z-index: 60; }\n' +
4436
4684
  // 讲书时音量弹层加宽,容纳 AI 声音选择 + 音量条。
@@ -4462,7 +4710,7 @@ window.__ModuleLoader__.load({
4462
4710
  '@keyframes dsh-music-spin { to { transform: rotate(360deg); } }\n' +
4463
4711
  '.dsh-music-bar-berr { margin-left: 8px; color: var(--dsw-alias-state-error-primary, #e5534b); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; display: inline-flex; align-items: center; gap: 4px; }\n' +
4464
4712
  '.dsh-music-bar-berr-text { overflow: hidden; text-overflow: ellipsis; }\n' +
4465
- '.dsh-music-bar-btn.retry { color: var(--dsw-alias-state-error-primary, #e5534b); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); border-radius: 6px; padding: 0 6px; height: 18px; flex: none; }\n' +
4713
+ '.dsh-music-bar-btn.retry { width: auto; color: var(--dsw-alias-state-error-primary, #e5534b); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); border-radius: 6px; padding: 0 6px; height: 18px; flex: none; }\n' +
4466
4714
  '.dsh-music-bar-btn.retry:hover { background: var(--dsw-alias-state-error-primary, #e5534b); color: #fff; }\n' +
4467
4715
  '.dsh-music-bar .dsh-music-mode-trigger { width: 24px; height: 24px; }\n' +
4468
4716
  '.dsh-music-bar .dsh-music-mode-trigger svg { flex: none; }\n' +
@@ -4681,7 +4929,9 @@ window.__ModuleLoader__.load({
4681
4929
  '.dsh-music-panel-toast.err { background: var(--dsw-alias-state-error-primary, #e5534b); }\n' +
4682
4930
  '@keyframes dsh-music-toast-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.94); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }\n' +
4683
4931
  '.dsh-music-bar-btn.fav { color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
4684
- '.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n';
4932
+ '.dsh-music-bar-btn.fav:hover { color: var(--dsh-music-accent-fg, #fff); }\n' +
4933
+ '.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n' +
4934
+ '.dsh-music-bar-btn.fav.on:hover { color: var(--dsh-music-accent-fg, #fff); }\n';
4685
4935
 
4686
4936
  return module.exports;
4687
4937
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",