dsh-music-player 0.6.2 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +281 -42
  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);
@@ -2030,7 +2222,7 @@ window.__ModuleLoader__.load({
2030
2222
  set({ duration: (bookTimeBase() + audio.duration) });
2031
2223
  }
2032
2224
  };
2033
- const onPlay = () => { qqErrorSkipCount = 0; set({ playing: true, error: null }); acquireWakeLock(); };
2225
+ const onPlay = () => { qqErrorSkipCount = 0; set({ playing: true, error: null }); acquireWakeLock(); setupLiveViz(); resumeVizCtx(); };
2034
2226
  const onPause = () => { set({ playing: false }); savePlayback(); releaseWakeLock(); };
2035
2227
  const onEnded = () => {
2036
2228
  // A novel plays chunk-by-chunk: when a chunk ends, auto-advance to the
@@ -2099,6 +2291,10 @@ window.__ModuleLoader__.load({
2099
2291
  audio.addEventListener('pause', onPause);
2100
2292
  audio.addEventListener('ended', onEnded);
2101
2293
  audio.addEventListener('error', onError);
2294
+ // captureStream() only carries an audio track once the element is actually
2295
+ // playing (readyState >= 3); 'play' can fire before there is data, so retry here.
2296
+ const onPlaying = () => { setupLiveViz(); resumeVizCtx(); };
2297
+ audio.addEventListener('playing', onPlaying);
2102
2298
  return () => {
2103
2299
  audio.pause();
2104
2300
  audio.removeEventListener('timeupdate', onTime);
@@ -2106,6 +2302,7 @@ window.__ModuleLoader__.load({
2106
2302
  audio.removeEventListener('play', onPlay);
2107
2303
  audio.removeEventListener('pause', onPause);
2108
2304
  audio.removeEventListener('ended', onEnded);
2305
+ audio.removeEventListener('playing', onPlaying);
2109
2306
  audio.removeEventListener('error', onError);
2110
2307
  };
2111
2308
  }
@@ -2544,12 +2741,14 @@ window.__ModuleLoader__.load({
2544
2741
  const showHint = s.pendingName !== null && s.currentId === null;
2545
2742
  const panelCls = 'dsh-music-mode-trigger' + (s.panelOpen ? ' active' : '');
2546
2743
  let vizBadge = null;
2547
- // 频谱不可用提示仅对本地/讲书曲目有效(重试能重新拉取独立音频源)。
2744
+ // 频谱不可用提示仅对本地/讲书曲目有效(重试能重新拉取独立音频源)。当实时
2745
+ // AnalyserNode 频谱已生效(vizLive)时,离线包偶发失败无关紧要,
2746
+ // 不显示该徽标误导用户——真实频谱由实时路径提供。
2548
2747
  // 在线 QQ 曲目与音频走同一代理流:频谱解码失败几乎必然意味着整段流
2549
2748
  // 播放失败,此时由音频 error 展示真实原因(如「音频加载或解码失败」),
2550
2749
  // 这里不再误导性地显示「频谱不可用,点击重试」。
2551
2750
  const isQQTrack = s.currentId !== null && String(s.currentId).startsWith('qq:');
2552
- if (hasTrack && s.vizState === 'unavailable' && !isQQTrack) {
2751
+ if (hasTrack && s.vizState === 'unavailable' && !isQQTrack && !vizLive) {
2553
2752
  vizBadge = React.createElement('button', {
2554
2753
  className: 'dsh-music-bar-warn',
2555
2754
  title: '频谱不可用,点击重试',
@@ -2634,7 +2833,7 @@ window.__ModuleLoader__.load({
2634
2833
  hasTrack
2635
2834
  ? React.createElement('span', { className: 'dsh-music-bar-name', title: displayName + (artistText ? ' - ' + artistText : '') + (chapterEl ? ' - ' + s.currentSection : '') }, note, ' ', displayName, artistEl, sourceBadge, localQualityBadge, chapterEl, afterName)
2636
2835
  : 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,
2836
+ !isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 60, height: 20, ref: (el) => { barCanvasNode = el; } }) : null,
2638
2837
  vizBadge,
2639
2838
  // 歌词/字幕:位于频谱之后、时长之前;仅"非使用态"(控件组已折叠、播放条
2640
2839
  // 半透明)显示——正在操作时收起,不给滑入的按钮组让路。
@@ -2852,9 +3051,17 @@ window.__ModuleLoader__.load({
2852
3051
  const id = String(song.songmid || song.id);
2853
3052
  const url = '/dsh-music/qq/play/' + id;
2854
3053
  const q = (Array.isArray(queue) && queue.length > 0) ? queue.slice() : [song];
3054
+ // 换到一首「新」的在线曲目:必须清除刷新恢复的定位钉,否则 onTime 会把这条
3055
+ // 新流 seek 回上一首的保存进度(换歌从旧进度开始)。startQQPlayback 只在点/
3056
+ // 切到新曲目时被调用(同曲目在面板上走 togglePlay 续播),因此这里无条件清除。
3057
+ restoredMusicPos = null;
3058
+ bookRestorePos = -1;
3059
+ // Fresh live tap per track (see startPlay): re-capture for the NEW src.
3060
+ closeLiveViz();
2855
3061
  audio.src = url;
2856
3062
  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: '' });
3063
+ setupLiveViz();
3064
+ 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
3065
  loadEnvelope('qq:' + id, url);
2859
3066
  loadQQQuality(id, url);
2860
3067
  loadQQLyric('qq:' + id, id);
@@ -4288,13 +4495,20 @@ window.__ModuleLoader__.load({
4288
4495
  attachAudioElements();
4289
4496
  const unbind = bindAudio();
4290
4497
  startRaf();
4498
+ // The live analyser is a captureStream() TAP — created only once a real src is
4499
+ // loaded (startPlay/onPlay), NOT up front, because a stream captured before any
4500
+ // src exists can carry no audio track. We deliberately avoid createMediaElementSource
4501
+ // here: it re-routes the element's output into the Web Audio graph and goes
4502
+ // SILENT whenever that graph/context isn't running (which is what broke audio
4503
+ // after switching tracks). The tap can never mute the player; if the tap can't
4504
+ // provide audio (the getTopURL bug) we fall back to the offline FFT envelope.
4291
4505
  const accentWatch = watchAccent();
4292
4506
  // Browsers auto-release a wake lock when the page is hidden; re-acquire
4293
4507
  // on return if playback is still running, and drop it on hide so we
4294
4508
  // don't hold it while the tab is backgrounded.
4295
4509
  const onVis = () => {
4296
4510
  if (document.hidden) releaseWakeLock();
4297
- else acquireWakeLock();
4511
+ else { acquireWakeLock(); resumeVizCtx(); }
4298
4512
  };
4299
4513
  // On refresh/unload, stop the media element cleanly BEFORE the document
4300
4514
  // is torn down — otherwise Chromium's media pipeline can race the
@@ -4308,10 +4522,30 @@ window.__ModuleLoader__.load({
4308
4522
  void flushServerPrefs();
4309
4523
  };
4310
4524
  document.addEventListener('visibilitychange', onVis);
4525
+ // Resume the analyser's AudioContext on any user gesture so it's already
4526
+ // running when a track starts. setupLiveViz() creates the tap once a real src
4527
+ // is loaded (startPlay/onPlay); the context just needs to be running for the
4528
+ // bars to move.
4529
+ const onFirstGesture = () => { resumeVizCtx(); };
4530
+ window.addEventListener('pointerdown', onFirstGesture);
4531
+ window.addEventListener('keydown', onFirstGesture, true);
4311
4532
  window.addEventListener('pagehide', onPageHide);
4533
+ // This Chromium's media pipeline throws a benign internal "getTopURL" TypeError
4534
+ // as an UNHANDLED promise rejection while a media element is routed through
4535
+ // Web Audio (needed for the live spectrum) with our proxied stream. It does NOT
4536
+ // affect playback or the analyser — but it spams the console. Suppress exactly
4537
+ // that benign case; leave every other rejection untouched.
4538
+ const onUnhandled = (ev) => {
4539
+ const r = ev && ev.reason;
4540
+ if (r && /getTopURL/.test(String((r && r.message) || r))) ev.preventDefault();
4541
+ };
4542
+ window.addEventListener('unhandledrejection', onUnhandled);
4312
4543
  return () => {
4544
+ window.removeEventListener('unhandledrejection', onUnhandled);
4313
4545
  window.removeEventListener('pagehide', onPageHide);
4314
- document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); releaseWakeLock();
4546
+ window.removeEventListener('pointerdown', onFirstGesture);
4547
+ window.removeEventListener('keydown', onFirstGesture, true);
4548
+ document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); closeLiveViz(); releaseWakeLock();
4315
4549
  if (accentWatch !== null) accentWatch.disconnect();
4316
4550
  accentObserver = null;
4317
4551
  };
@@ -4364,9 +4598,14 @@ window.__ModuleLoader__.load({
4364
4598
  }
4365
4599
  const track = resolvePlayable(intent.id);
4366
4600
  if (track !== null) {
4601
+ // 换到一首「新」的曲目:清除刷新恢复的定位钉,避免 onTime 把新曲目 seek
4602
+ // 回上一首的保存进度(换歌从旧进度开始)。play 意图=从头开始(续播走
4603
+ // togglePlay / resume 意图),因此这里无条件清除。
4604
+ restoredMusicPos = null;
4605
+ bookRestorePos = -1;
4367
4606
  audio.src = track.url;
4368
4607
  audio.load();
4369
- set({ currentId: intent.id, currentName: track.name, currentArtists: track.artists || [], error: null, scope: { kind: 'library' } });
4608
+ set({ currentId: intent.id, currentName: track.name, currentArtists: track.artists || [], error: null, scope: { kind: 'library' }, position: 0, duration: 0 });
4370
4609
  loadEnvelope(intent.id, track.url);
4371
4610
  prefetchNext();
4372
4611
  savePlayback();
@@ -4419,7 +4658,7 @@ window.__ModuleLoader__.load({
4419
4658
  '.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
4420
4659
  '.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
4660
  '.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' +
4661
+ '.dsh-music-viz { flex: none; width: 60px; height: 20px; }\n' +
4423
4662
  // 歌词/字幕:夹在频谱与时长之间,吃掉剩余宽度,文本在可用空间内水平居中,
4424
4663
  // 超长省略号截断。仅非使用态渲染(元素随 .dsh-music-bar.dimmed 半透明,符合
4425
4664
  // 后台静默观感)。出现时延迟 0.3s(与控件组滑出的 0.3s 过渡对齐)再淡入,
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.3",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",