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.
- package/README.md +1 -1
- package/lib/client.js +301 -51
- 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);
|
|
@@ -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
|
-
|
|
1987
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
|
4432
|
-
'.dsh-music-bar-btn:hover { color: var(--
|
|
4433
|
-
'.dsh-music-bar-btn.active { color: var(--
|
|
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
|
|
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.
|
|
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",
|