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.
- package/README.md +1 -1
- package/lib/client.js +281 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ DeepSeek Harness 本地音乐/小说播放插件。
|
|
|
12
12
|
|
|
13
13
|
- 本地音频流式播放(HTTP Range),刷新后断点续播
|
|
14
14
|
- 顺序播放、单曲循环、乱序播放三种模式
|
|
15
|
-
- 实时
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 =
|
|
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()) {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|