dsh-music-player 0.6.4 → 0.6.6

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/lib/client.js CHANGED
@@ -169,6 +169,10 @@ window.__ModuleLoader__.load({
169
169
  const PREF_QQ_FAV = 'dsh-music-qq-fav'; // QQ「我喜欢」收藏 songid/songmid(Host 兜底)
170
170
  const PREF_QQ_HISTORY = 'dsh-music-qq-history'; // QQ 搜索历史(最近在前,最多 10 条)
171
171
  const PREF_QQ_UI = 'dsh-music-qq-ui'; // QQ 面板所在层/歌单 UI 状态
172
+ const PREF_SHOW_LYRIC = 'dsh-music-show-lyric'; // 播放条歌词显示开关(默认开)
173
+ const PREF_SHOW_VIZ = 'dsh-music-show-viz'; // 播放条频谱显示开关(默认开)
174
+ const PREF_SHOW_PROGRESS = 'dsh-music-show-progress'; // 播放条进度条显示开关(默认开)
175
+ const PREF_IMMERSE = 'dsh-music-immerse'; // 沉浸感:播放条闲置态透明度 0..1(默认 0.5)
172
176
  // Legacy single-book progress key (pre-0.2.1); migrated into the per-book
173
177
  // map on upgrade so very old browser copies are not silently dropped.
174
178
  const PREF_LEGACY_BOOK = 'dsh-music-book-playback';
@@ -177,6 +181,7 @@ window.__ModuleLoader__.load({
177
181
  const PREF_KEYS = new Set([
178
182
  PREF_MODE, PREF_VOL, PREF_VOICE, PREF_SCOPE, PREF_PANEL_POS,
179
183
  PREF_PLAYBACK, PREF_BOOKS_PLAYBACK, PREF_QQ_FAV, PREF_QQ_HISTORY, PREF_QQ_UI,
184
+ PREF_SHOW_LYRIC, PREF_SHOW_VIZ, PREF_SHOW_PROGRESS, PREF_IMMERSE,
180
185
  ]);
181
186
  let serverPrefs = null; // null = Host snapshot not fetched yet
182
187
  let serverPrefsFetched = false; // distinguishes "not fetched" from an early savePref
@@ -333,6 +338,16 @@ window.__ModuleLoader__.load({
333
338
  if (Number.isFinite(v)) { store.volume = Math.min(1, Math.max(0, v)); audio.volume = store.volume; }
334
339
  const voice = loadPref(PREF_VOICE);
335
340
  if (typeof voice === 'string' && voice !== '') store.voice = voice;
341
+ // 系统配置开关:默认开启(缺省即 true)。
342
+ const showLyric = loadPref(PREF_SHOW_LYRIC);
343
+ if (showLyric === '0') store.showLyric = false;
344
+ const showViz = loadPref(PREF_SHOW_VIZ);
345
+ if (showViz === '0') store.showViz = false;
346
+ const showProgress = loadPref(PREF_SHOW_PROGRESS);
347
+ if (showProgress === '0') store.showProgress = false;
348
+ // 沉浸感:0..1,缺省 0.5。钳制到合法区间防止脏数据。
349
+ const immerse = parseFloat(loadPref(PREF_IMMERSE));
350
+ if (Number.isFinite(immerse)) store.immerse = Math.min(1, Math.max(0, immerse));
336
351
  } catch (e) {}
337
352
  }
338
353
  // ---- per-book novel progress (independent from music) ----
@@ -377,7 +392,7 @@ window.__ModuleLoader__.load({
377
392
  }
378
393
  // Playback-panel geometry: default CSS width (must match .dsh-music-panel),
379
394
  // resize bounds, and the viewport-height fraction cap when user-resized.
380
- const PANEL_W = 460;
395
+ const PANEL_W = 600;
381
396
  const PANEL_MIN_W = 320;
382
397
  const PANEL_MAX_W = 720;
383
398
  const PANEL_MIN_H = 200;
@@ -475,6 +490,10 @@ window.__ModuleLoader__.load({
475
490
  // 歌词/字幕:当前行文本(音乐 = 当前歌词行,讲书 = 当前句子)。空串 = 无歌词
476
491
  // 不渲染。播放条"频谱后、时长前"位置、仅非使用态显示。
477
492
  lyricText: '',
493
+ // 系统配置:播放条歌词 / 频谱 / 进度条显示开关(默认开启,存 Host prefs)。
494
+ showLyric: true, showViz: true, showProgress: true,
495
+ // 沉浸感:播放条闲置态透明度(0..1,默认 0.5),存 Host prefs。
496
+ immerse: 0.5,
478
497
  // 播放模式弹层是否打开(portal 到 body 时让播放条按钮保持展开、不因移出而收起)。
479
498
  modeMenuOpen: false,
480
499
  // AI 讲书 TTS voice: available voices come from /manifest, the selection
@@ -505,6 +524,10 @@ window.__ModuleLoader__.load({
505
524
  if ('volume' in patch) savePref(PREF_VOL, String(patch.volume));
506
525
  if ('voice' in patch) savePref(PREF_VOICE, patch.voice);
507
526
  if ('scope' in patch) savePref(PREF_SCOPE, JSON.stringify(patch.scope));
527
+ if ('showLyric' in patch) savePref(PREF_SHOW_LYRIC, patch.showLyric ? '1' : '0');
528
+ if ('showViz' in patch) savePref(PREF_SHOW_VIZ, patch.showViz ? '1' : '0');
529
+ if ('showProgress' in patch) savePref(PREF_SHOW_PROGRESS, patch.showProgress ? '1' : '0');
530
+ if ('immerse' in patch) savePref(PREF_IMMERSE, String(Math.min(1, Math.max(0, patch.immerse))));
508
531
  for (const fn of [...listeners]) fn();
509
532
  }
510
533
  function useStore() {
@@ -1862,6 +1885,11 @@ window.__ModuleLoader__.load({
1862
1885
  const url = bookUrl(id, from);
1863
1886
  if (url === null) { clearStuck(); failBook('书籍信息缺失'); return; }
1864
1887
  bookFromRef = from;
1888
+ // 与音乐切歌(startPlay)一致:先拆除旧的实时频谱监听(captureStream 探针),
1889
+ // 再切换到讲书块。音乐播放时建立的探针挂在同一个 <audio> 上,若不拆除,切换到
1890
+ // 需要冷合成的讲书块时媒体流水线会冲突(响一下→没声音、字幕冻住/输出失败)。
1891
+ // 切块后 onPlaying 会为当前块重新建立探针,频谱不受影响。
1892
+ closeLiveViz();
1865
1893
  audio.src = url;
1866
1894
  audio.load();
1867
1895
  // 讲书实时字幕:拉取当前块文本并按句切分,随播放推进逐句显示。
@@ -1974,6 +2002,8 @@ window.__ModuleLoader__.load({
1974
2002
  const id = currentBookId();
1975
2003
  const chunkUrl = bookUrl(id, bookFromRef);
1976
2004
  if (chunkUrl !== null && audio.currentSrc !== new URL(chunkUrl, window.location.href).href) {
2005
+ // 同样先拆除旧实时频谱监听再切换 src,避免音乐探针残留导致讲书输出冲突。
2006
+ closeLiveViz();
1977
2007
  audio.src = chunkUrl;
1978
2008
  audio.load();
1979
2009
  }
@@ -1984,9 +2014,33 @@ window.__ModuleLoader__.load({
1984
2014
  // 恢复续播的这块之前没有拉取过字幕(restore 不动 audio、playBookFrom 不经过
1985
2015
  // 这里),必须补一次 loadBookSubtitle,否则续播后整块无字幕、直到切块才出现。
1986
2016
  loadBookSubtitle(id, bookFromRef);
2017
+ // 重启续播:Host TTS 合成缓存已清空,恢复的这块若尚未预热完成,点 ▶ 后仍要
2018
+ // 重新合成(数秒~数十秒)。若不置缓冲提示/合成超时,播放条会停在恢复位置、
2019
+ // 无声音、字幕不动——表现为「卡住」。这里沿用 playBookFrom 的「AI 合成中… Ns」
2020
+ // 提示 + 60s 超时兜底,让等待透明、且合成失败/挂起时有明确错误而非无限卡死。
2021
+ if (bookStuckTimer !== null) clearTimeout(bookStuckTimer);
2022
+ set({ bookBuffering: true, bookBufferingSilent: false, bookBufferingSince: Date.now() });
2023
+ bookStuckTimer = setTimeout(() => {
2024
+ if (store.bookBuffering) set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0, bookError: 'AI 合成超时,请点击「重试」' });
2025
+ }, 60000);
1987
2026
  }
1988
2027
  const promise = audio.play();
1989
- if (promise !== undefined && typeof promise.catch === 'function') promise.catch((err) => { if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' }); });
2028
+ if (promise !== undefined && typeof promise.then === 'function') {
2029
+ promise.then(() => {
2030
+ // 恢复续播路径已置缓冲提示/超时:播放真正开始时清除(与 playBookFrom 的
2031
+ // doneBuffering 一致)。其它模式下 bookBuffering/超时本就不存在,跳过 set。
2032
+ if (bookStuckTimer !== null || store.bookBuffering) {
2033
+ if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2034
+ set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0 });
2035
+ }
2036
+ }).catch((err) => {
2037
+ // 播放被拒绝(如 autoplay 拦截)时同样清掉缓冲/超时:否则「AI 合成中…」
2038
+ // 会一直挂着,而错误提示又不显示(这里只对 autoplay 拦截报错)。
2039
+ if (bookStuckTimer !== null) { clearTimeout(bookStuckTimer); bookStuckTimer = null; }
2040
+ set({ bookBuffering: false, bookBufferingSilent: false, bookBufferingSince: 0 });
2041
+ if (isAutoplayBlocked(err)) set({ error: '浏览器拦截了自动播放,请点击播放按钮' });
2042
+ });
2043
+ }
1990
2044
  // Envelope decoding is deferred to play (see loadTracks) — decode the
1991
2045
  // restored track lazily so its spectrum is ready once it resumes.
1992
2046
  if (!String(store.currentId).startsWith('book:')) {
@@ -2179,14 +2233,25 @@ window.__ModuleLoader__.load({
2179
2233
  // start until then) — anchored on top of the book-wide clock.
2180
2234
  if (bookRestorePos >= 0 && String(store.currentId).startsWith('book:')) {
2181
2235
  const ct = audio.currentTime || 0;
2182
- // 释放条件:真实播放已明显越过恢复点,或恢复点已超出本块的实际时长
2183
- // (块可能被重新合成得更短——此时继续 pin 会反复 seek 到块末尾并即刻
2184
- // 结束,表现为卡住/无声音/字幕不动)。超出时放弃该恢复点、从块头正常播。
2236
+ // 释放/处理条件:真实播放已明显越过恢复点(正常放过去),或恢复点已超出本块
2237
+ // 的实际时长。块可能被重新合成得更短(尤其重启后冷合成)——此时若仍把
2238
+ // currentTime seek 到保存位置,会被浏览器钳到块末尾,且未必派发 ended,于是
2239
+ // 「响一下→没声音、字幕不动」。若用户其实已越过本块(保存位置 ≥ 块长),则
2240
+ // 直接跳到下一块;否则释放定位钉、让它从当前处自然播到块尾再切块。
2241
+ const dur = (Number.isFinite(audio.duration) && audio.duration > 0) ? audio.duration : 0;
2185
2242
  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
2189
- } else {
2243
+ const pastEnd = dur > 0 && bookRestorePos >= dur;
2244
+ if (pastSpot) {
2245
+ bookRestorePos = -1; // real playback advanced past the spot live time
2246
+ } else if (pastEnd) {
2247
+ // 保存位置已越过本块实际末尾(重启后重合成块变短)→ 用户其实已读完这一块,
2248
+ // 直接跳到下一块继续,而不是重听本块(否则会整块重播,或响一下→卡住)。
2249
+ // maybeAdvanceBook 内部会清 bookRestorePos 并推进 bookBaseTime;若已到最后一
2250
+ // 页(无下一块)则释放定位钉、让本块自然播完后再结束。
2251
+ if (!maybeAdvanceBook()) bookRestorePos = -1;
2252
+ } else if (dur > 0) {
2253
+ // 时长已就绪且恢复点在本块内:才把音频 seek 到恢复点(以免提前 seek 到一个
2254
+ // 随后才发现越界、被钳到块末尾的位置而卡死)。
2190
2255
  if (store.playing && ct < bookRestorePos - 0.5) {
2191
2256
  try { audio.currentTime = bookRestorePos; } catch (e) {}
2192
2257
  }
@@ -2196,6 +2261,8 @@ window.__ModuleLoader__.load({
2196
2261
  updateLyric();
2197
2262
  return;
2198
2263
  }
2264
+ // dur 尚未知(元数据未加载):先不 seek,走下方正常读值;等时长就绪后上面的
2265
+ // pin 会再次评估并正确 seek/释放,绝不提前把一个可能越界的位置塞进 currentTime。
2199
2266
  }
2200
2267
  set({ position: bookTimeBase() + (audio.currentTime || 0) });
2201
2268
  // Persist the playback spot periodically (≈every 5s) for BOTH music and
@@ -2447,6 +2514,12 @@ window.__ModuleLoader__.load({
2447
2514
  void ensureBookTotal(book.id).then((total) => {
2448
2515
  if (!Number.isFinite(total) || total < 0) { bookTotal = savedTotal; return; }
2449
2516
  bookTotal = total;
2517
+ // 预热「要续播的那一块」:重启后 Host 的 TTS 合成缓存与浏览器 HTTP 缓存都已
2518
+ // 清空/失效,续播时该块会重新合成(数秒~数十秒)。此时若不加处理,用户一点
2519
+ // ▶ 播放条就停在恢复位置、无声音、字幕不动——正是「重启后续播卡住」的根因。
2520
+ // 这里在恢复阶段就后台合成并缓存这一块,等用户点 ▶ 时即可秒起(或至少已
2521
+ // 在合成中,点 ▶ 只是等收尾,无需从零开始)。
2522
+ if (from >= 0 && from < total) fetch(bookUrl(book.id, from)).catch(() => {});
2450
2523
  if (from + 1 < total) preloadBook(book.id, from + 1);
2451
2524
  });
2452
2525
  }
@@ -2830,6 +2903,9 @@ window.__ModuleLoader__.load({
2830
2903
  // 无内容(点击停止 / 插件刚安装)时恒定工作态:不透明度 100%、控件组展开,无任何特效。
2831
2904
  const active = barHover || anyPopOpen || !hasTrack;
2832
2905
  const barDimmed = !active;
2906
+ // 沉浸感由系统配置驱动:数值越大越「沉浸」(播放条越透明融入背景)。
2907
+ // 传给播放条的 opacity 是 1-immersee:沉浸 0% → 不透明(1),沉浸 100% → 全透明(0)。
2908
+ const barStyle = { '--dsh-music-immerse': String(1 - s.immerse) };
2833
2909
  // 播放进度细线:音乐按 position/duration(单曲时长),讲书按「已读字符/全书字符」
2834
2910
  // 的 bookProgress——全书总时长在合成本书前不可知,用时长占比会切块回退,字符占比
2835
2911
  // 才稳定且只增不减。
@@ -2838,15 +2914,15 @@ window.__ModuleLoader__.load({
2838
2914
  : ((hasTrack && s.duration > 0) ? Math.min(100, Math.max(0, (s.position / s.duration) * 100)) : 0);
2839
2915
  return React.createElement('div', { className: 'dsh-music-bar-wrap' },
2840
2916
  React.createElement('div',
2841
- { className: 'dsh-music-bar' + (isBook ? ' book' : '') + (barDimmed ? ' dimmed' : ''), onMouseEnter: showBarBtns, onMouseLeave: onBarLeave },
2917
+ { className: 'dsh-music-bar' + (isBook ? ' book' : '') + (barDimmed ? ' dimmed' : ''), style: barStyle, onMouseEnter: showBarBtns, onMouseLeave: onBarLeave },
2842
2918
  hasTrack
2843
2919
  ? React.createElement('span', { className: 'dsh-music-bar-name', title: displayName + (artistText ? ' - ' + artistText : '') + (chapterEl ? ' - ' + s.currentSection : '') }, note, ' ', displayName, artistEl, sourceBadge, localQualityBadge, chapterEl, afterName)
2844
2920
  : React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' DSH音乐播放器'),
2845
- !isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 60, height: 20, ref: (el) => { barCanvasNode = el; } }) : null,
2921
+ !isBook && hasTrack && s.playing && s.showViz ? React.createElement('canvas', { className: 'dsh-music-viz', width: 60, height: 20, ref: (el) => { barCanvasNode = el; } }) : null,
2846
2922
  vizBadge,
2847
2923
  // 歌词/字幕:位于频谱之后、时长之前;仅"非使用态"(控件组已折叠、播放条
2848
2924
  // 半透明)显示——正在操作时收起,不给滑入的按钮组让路。
2849
- s.lyricText && barDimmed && hasTrack
2925
+ s.lyricText && barDimmed && hasTrack && s.showLyric
2850
2926
  ? React.createElement('span', { className: 'dsh-music-bar-lyric', title: s.lyricText }, s.lyricText)
2851
2927
  : null,
2852
2928
  // 时长 + 右侧控制按钮是一个组合:右对齐(margin-left:auto)。鼠标进入播放条
@@ -2915,7 +2991,7 @@ window.__ModuleLoader__.load({
2915
2991
  )) : null,
2916
2992
  // 播放进度细线:绝对定位在播放条底部,与播放条等宽、高约 1px,随播放实时填充。
2917
2993
  // 音乐按单曲时长,讲书按「已读字符/全书字符」的 bookProgress(见 progressPct)。
2918
- hasTrack && (isBook || s.duration > 0)
2994
+ hasTrack && (isBook || s.duration > 0) && s.showProgress
2919
2995
  ? React.createElement('div', { className: 'dsh-music-bar-progress' },
2920
2996
  React.createElement('div', { className: 'dsh-music-bar-progress-fill', style: { width: progressPct + '%' } }))
2921
2997
  : null,
@@ -3503,14 +3579,42 @@ window.__ModuleLoader__.load({
3503
3579
  if (!d || !d.ok) { setLoginStatus((d && d.error) || '查询失败,正在重试…'); schedulePoll(); return; }
3504
3580
  if (d.status === 'success') { clearPoll(); setLoggedIn(true); setUin(d.uin || ''); setNickname(d.nickname || ''); setLayer('main'); setActivePl(null); saveUi('main', '', ''); setLoginStatus('登录成功'); setTimeout(closeLogin, 800); refreshQQFavIds(); }
3505
3581
  else if (d.status === 'scanned') { setLoginStatus('已扫码,请在手机上确认'); schedulePoll(); }
3506
- else if (d.status === 'expired') { clearPoll(); setLoginStatus('二维码已过期,请重新扫码'); }
3582
+ else if (d.status === 'expired') { clearPoll(); setLoginStatus('二维码已过期'); }
3507
3583
  else if (d.status === 'failed') { clearPoll(); setLoginStatus(d.message || '登录失败'); }
3508
- else if (d.status === 'waiting') { setLoginStatus('等待扫码…'); schedulePoll(); }
3584
+ else if (d.status === 'waiting') { schedulePoll(); }
3509
3585
  else { schedulePoll(); }
3510
3586
  } catch (e) { setLoginStatus('获取登录状态超时,正在自动重试…'); schedulePoll(); }
3511
3587
  }
3512
3588
  function closeLogin() { clearPoll(); loginModeRef.current = null; qrKeyRef.current = ''; setLoginMode(null); setLoginStatus(''); setQrImage(''); }
3513
- async function logout() { try { await jsonPost('/dsh-music/qq/login/logout', {}); } catch {} setLoggedIn(false); setUin(''); setNickname(''); }
3589
+ async function logout() {
3590
+ try { await jsonPost('/dsh-music/qq/login/logout', {}); } catch {}
3591
+ setLoggedIn(false); setUin(''); setNickname('');
3592
+ // 彻底清空所有 QQ 音乐浏览数据,避免换账号登录后看到上一个账号的歌单/搜索/浏览数据。
3593
+ setLayer('main'); setActivePl(null); setPlLoading(false); setBrowseTab('mine');
3594
+ setQ(''); setSearched(false); setSearching(false); setResults([]); setPlResults([]);
3595
+ setQError(''); setResultTab('songs'); setSearchPage(1); setSearchLastLen(0);
3596
+ setSearchingMore(false); setPlSearchPage(1); setPlSearchLastLen(0); setPlSearchingMore(false);
3597
+ setHist([]); setHistOpen(false);
3598
+ setMinePlays([]); setMineLoaded(false); setRecommended([]);
3599
+ setCategories([]); setCatPlays([]); setCurCategory(null); setBrowseErr(''); setBrowseLoading(false);
3600
+ setRecPage(1); setRecLoadingMore(false); setRecHasMore(true);
3601
+ setCatPage(1); setCatLoadingMore(false); setCatHasMore(true); setCatExpanded(false);
3602
+ setTopGroups([]); setTopLoaded(false); setTopDetail(null); setTopLoading(false);
3603
+ setTopTotal(0); setTopHasMore(false); setTopLoadingMore(false);
3604
+ setNewSongs([]); setNewLoaded(false);
3605
+ saveUi('main', '', '');
3606
+ // 清空在线播放队列与当前曲目(否则退出后播放条仍可「下一首」见上一账号的歌)。
3607
+ const isQQPlaying = String(store.currentId || '').startsWith('qq:') || store.scope?.kind === 'qq';
3608
+ if (isQQPlaying) {
3609
+ // 当前播的是 QQ 在线曲目 → 真正停止播放(pause + 清 src)并清空在线队列。
3610
+ // 用 stop() 而不是只 set state,否则音频仍在播,播放条仍显示/残留上一账号的曲目。
3611
+ stop();
3612
+ set({ qqQueue: [], qqSource: '', qqFaved: false });
3613
+ } else {
3614
+ // 当前播的是本地/讲书 → 只清空在线队列,保留当前播放。
3615
+ set({ qqQueue: [], qqSource: '', qqFaved: false });
3616
+ }
3617
+ }
3514
3618
 
3515
3619
  // ---- 渲染辅助 ----
3516
3620
  const fmtCount = (n) => { const v = Number(n) || 0; if (v >= 1e8) return (v / 1e8).toFixed(1).replace(/\.0$/, '') + '亿'; if (v >= 1e4) return (v / 1e4).toFixed(1).replace(/\.0$/, '') + '万'; return String(v); };
@@ -3710,13 +3814,15 @@ window.__ModuleLoader__.load({
3710
3814
  loginStatus !== '登录成功' ? React.createElement('div', { className: 'dsh-music-qq-login-actions' },
3711
3815
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => startLogin(loginMode) }, '刷新二维码'),
3712
3816
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: closeLogin }, '取消')) : null,
3713
- React.createElement('p', { className: 'dsh-music-hint' }, '用官方 App 扫码并确认。扫码登录走第三方接口,存在账号风控/合规风险,仅供个人试听。'),
3714
3817
  )))) : null;
3715
3818
 
3716
3819
  // ---- 未登录:只显示居中两个登录按钮(QQ 登录 / 微信登录,分两行)+ 风险提示 ----
3717
3820
  if (!loggedIn) {
3718
3821
  return React.createElement('div', { className: 'dsh-music-qq dsh-music-qq-login' },
3719
3822
  React.createElement('div', { className: 'dsh-music-qq-login-center' },
3823
+ React.createElement('div', { className: 'dsh-music-qq-login-verified' },
3824
+ React.createElement('span', { className: 'dsh-music-qq-login-verified-icon' }, '✓'),
3825
+ React.createElement('span', null, 'QQ/微信扫码登录验证已 OK')),
3720
3826
  React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('qq') }, 'QQ 登录'),
3721
3827
  React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('wx') }, '微信登录'),
3722
3828
  React.createElement('div', { className: 'dsh-music-qq-login-warn' },
@@ -4154,24 +4260,32 @@ window.__ModuleLoader__.load({
4154
4260
  const listBody = React.createElement('div', { className: 'dsh-music-list-body' },
4155
4261
  React.createElement('div', { style: paneStyle('music') }, musicBody),
4156
4262
  React.createElement('div', { className: 'dsh-music-qq-pane', style: paneStyle('qq') }, React.createElement(QQOnlinePanel, { panelRef })),
4157
- React.createElement('div', { style: paneStyle('book') }, bookEmptyBody));
4263
+ React.createElement('div', { style: paneStyle('book') }, bookEmptyBody),
4264
+ React.createElement('div', { style: paneStyle('config') }, React.createElement(SystemSetting, null)));
4158
4265
  return React.createElement('div', { className: 'dsh-music-panel', ref: panelRef, style: rootStyle },
4159
4266
  React.createElement('div', {
4160
4267
  className: 'dsh-music-panel-head dsh-music-panel-drag',
4161
4268
  onPointerDown: onHeadDown, onPointerMove: onHeadMove, onPointerUp: onHeadUp,
4162
4269
  },
4163
4270
  React.createElement('span', { className: 'dsh-music-panel-grip', 'aria-hidden': true }, '⠿'),
4164
- React.createElement('span', { className: 'dsh-music-panel-title' }, '播放列表'),
4271
+ React.createElement('span', { className: 'dsh-music-panel-title' }, 'DeepSeek Harness 音乐播放器'),
4165
4272
  React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: () => set({ panelOpen: false }) }, '✕')),
4166
- React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '本地音乐'), tabBtn('qq', 'QQ音乐'), tabBtn('book', 'AI讲书')),
4167
- s.tab === 'qq' ? null : React.createElement(DirectorySetting, { panelRef }),
4168
- s.tab === 'music' ? musicSubTabs : null,
4169
- // While a novel is playing, keep music-only errors/scanning out of the
4170
- // panel (novel status shows on the playback bar instead).
4171
- // 音乐/小说统一在主列表区上方显示 error(设置块不再重复/分模式显示)。
4172
- s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
4173
- !isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '扫描中…') : null,
4174
- React.createElement('div', { className: 'dsh-music-list', style: pos === null ? null : { maxHeight: 'none' }, ref: (el) => { listRef.current = el; } }, listBody),
4273
+ React.createElement('div', { className: 'dsh-music-panel-body' },
4274
+ // Tab 标签竖排在窗口左侧(侧边栏),内容区在其右侧。
4275
+ React.createElement('div', { className: 'dsh-music-tabs' },
4276
+ tabBtn('music', '本地音乐'), tabBtn('qq', 'QQ音乐'), tabBtn('book', 'AI讲书'), tabBtn('config', '系统配置')),
4277
+ React.createElement('div', { className: 'dsh-music-panel-content' },
4278
+ s.tab === 'qq' || s.tab === 'config' ? null : React.createElement(DirectorySetting, { panelRef }),
4279
+ s.tab === 'music' ? musicSubTabs : null,
4280
+ // While a novel is playing, keep music-only errors/scanning out of the
4281
+ // panel (novel status shows on the playback bar instead).
4282
+ // 音乐/小说统一在主列表区上方显示 error(设置块不再重复/分模式显示)。
4283
+ // 系统配置页不显示曲库扫描相关的错误/加载提示。
4284
+ s.error && s.tab !== 'config' ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
4285
+ !isBook && s.tab !== 'config' && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '扫描中…') : null,
4286
+ React.createElement('div', { className: 'dsh-music-list', style: pos === null ? null : { maxHeight: 'none' }, ref: (el) => { listRef.current = el; } }, listBody),
4287
+ ),
4288
+ ),
4175
4289
  React.createElement('div', { className: 'dsh-music-resize', title: '拖动调整面板大小', onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp }),
4176
4290
  addMenu ? React.createElement(AddToPlaylistMenu, {
4177
4291
  track: addMenu.track, anchor: { x: addMenu.x, y: addMenu.y },
@@ -4336,6 +4450,40 @@ window.__ModuleLoader__.load({
4336
4450
  saveRoot(p, isBook ? 'book' : 'music');
4337
4451
  }
4338
4452
  }
4453
+ // 系统配置面板(「系统配置」tab):播放条歌词 / 频谱显示开关,持久化到 Host prefs。
4454
+ function SystemSetting() {
4455
+ const s = useStore();
4456
+ // 通用开关行:右侧一个开关按钮,点击切换并保存。
4457
+ const toggleRow = (label, desc, value, onChange) => React.createElement('div', { className: 'dsh-music-config-row' },
4458
+ React.createElement('div', { className: 'dsh-music-config-info' },
4459
+ React.createElement('span', { className: 'dsh-music-config-label' }, label),
4460
+ desc ? React.createElement('span', { className: 'dsh-music-config-desc' }, desc) : null),
4461
+ React.createElement('button', {
4462
+ className: 'dsh-music-toggle' + (value ? ' on' : ''),
4463
+ role: 'switch',
4464
+ 'aria-checked': value,
4465
+ onClick: () => onChange(!value),
4466
+ }, React.createElement('span', { className: 'dsh-music-toggle-knob' })));
4467
+ // 沉浸感:鼠标移出后播放条变半透明、与背景融合。拖动滑块调节透明度。
4468
+ const immersePct = Math.round(s.immerse * 100);
4469
+ const immerseRow = React.createElement('div', { className: 'dsh-music-config-row' },
4470
+ React.createElement('div', { className: 'dsh-music-config-info' },
4471
+ React.createElement('span', { className: 'dsh-music-config-label' }, '沉浸感'),
4472
+ React.createElement('span', { className: 'dsh-music-config-desc' }, '鼠标移出后播放条透明度、与背景融合程度')),
4473
+ React.createElement('div', { className: 'dsh-music-config-slider' },
4474
+ React.createElement('input', {
4475
+ className: 'dsh-music-config-range', type: 'range', min: 0, max: 100, step: 5,
4476
+ value: immersePct,
4477
+ onChange: (e) => set({ immerse: Number(e.target.value) / 100 }),
4478
+ }),
4479
+ React.createElement('span', { className: 'dsh-music-config-val' }, immersePct + '%')));
4480
+ return React.createElement('div', { className: 'dsh-music-config' },
4481
+ toggleRow('歌词显示', '播放条上显示当前歌词 / 讲书字幕', s.showLyric, (v) => set({ showLyric: v })),
4482
+ toggleRow('频谱显示', '播放条上显示实时音频频谱', s.showViz, (v) => set({ showViz: v })),
4483
+ toggleRow('进度条显示', '播放条底部显示播放进度条(音乐 / 讲书)', s.showProgress, (v) => set({ showProgress: v })),
4484
+ immerseRow,
4485
+ );
4486
+ }
4339
4487
  // 「加入歌单」弹层:曲库每行「+」点击后出现,列出所有歌单(含我最喜欢)并可新建。
4340
4488
  // 用 fixed 定位(锚点为按钮视口坐标),避免被面板滚动列表裁剪。
4341
4489
  function AddToPlaylistMenu({ track, anchor, onClose }) {
@@ -4657,7 +4805,7 @@ window.__ModuleLoader__.load({
4657
4805
  'body { --dsh-music-accent: var(--dsw-alias-brand-primary, #2f9e6e); --dsh-music-accent-fg: var(--dsw-alias-label-primary-foreground, #fff); }\n' +
4658
4806
  '.dsh-music-bar-wrap { box-sizing: border-box; width: 100%; padding: 0 var(--dsh-composer-side-clearance, 16px); }\n' +
4659
4807
  '.dsh-music-bar { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; max-width: var(--dsh-composer-card-max-width, 780px); margin: 0 auto; padding: 4px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04)); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); border-radius: 8px; cursor: default; user-select: none; position: relative; overflow: hidden; transition: opacity 0.3s ease; }\n' +
4660
- '.dsh-music-bar.dimmed { opacity: 0.5; }\n' +
4808
+ '.dsh-music-bar.dimmed { opacity: var(--dsh-music-immerse, 0.5); }\n' +
4661
4809
  // 播放进度细线:绝对定位在播放条底部(占满其宽度),高 1px、视觉上是一条细线;
4662
4810
  // 轨道用低透明度衬底色,填充部分用主题色,随 position/duration 实时前进
4663
4811
  // (宽度 0.12s 平滑过渡)。播放条容器已 overflow:hidden,细线两端会被裁剪到
@@ -4715,15 +4863,24 @@ window.__ModuleLoader__.load({
4715
4863
  '.dsh-music-bar .dsh-music-mode-trigger { width: 24px; height: 24px; }\n' +
4716
4864
  '.dsh-music-bar .dsh-music-mode-trigger svg { flex: none; }\n' +
4717
4865
  '.dsh-music-bar .dsh-music-mode-menu { align-self: center; }\n' +
4718
- '.dsh-music-panel { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 460px; max-height: 72vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; pointer-events: auto; overflow: hidden; }\n' +
4866
+ '.dsh-music-panel { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 600px; max-height: 72vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; pointer-events: auto; overflow: hidden; }\n' +
4719
4867
  '.dsh-music-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; touch-action: none; z-index: 5; }\n' +
4720
4868
  '.dsh-music-resize::after { content: ""; position: absolute; right: 4px; bottom: 4px; width: 5px; height: 5px; border-right: 2px solid var(--dsw-alias-label-secondary, #8a8f98); border-bottom: 2px solid var(--dsw-alias-label-secondary, #8a8f98); opacity: 0.7; }\n' +
4721
4869
  '.dsh-music-resize:hover::after { opacity: 1; }\n' +
4722
4870
  '.dsh-music-panel-head { display: flex; align-items: center; gap: 6px; }\n' +
4723
- '.dsh-music-tabs { display: flex; gap: 4px; }\n' +
4724
- '.dsh-music-tab { flex: 1; padding: 5px 0; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; }\n' +
4725
- '.dsh-music-tab:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
4726
- '.dsh-music-tab.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
4871
+ // 面板主体:左右布局——左侧 Tab 侧边栏,右侧内容区。两侧紧贴(gap:0),
4872
+ // 选中 tab 就能与内容区无缝连成整体。
4873
+ '.dsh-music-panel-body { display: flex; flex-direction: row; gap: 0; flex: 1; min-height: 0; }\n' +
4874
+ // 内容区:不设背景,透出面板自然底色;与左侧深色侧边栏靠明暗对比区分。
4875
+ '.dsh-music-panel-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; min-height: 0; padding-left: 12px; }\n' +
4876
+ // Tab 标签竖排在窗口左侧(侧边栏):背景比右侧略深。左右无内边距保证
4877
+ // 选中项撑满整列并与内容区无缝连接;上下内边距加大,让标签组与上方标题、
4878
+ // 下方边缘留出呼吸空间。
4879
+ '.dsh-music-tabs { display: flex; flex-direction: column; gap: 4px; flex: none; width: 88px; padding: 48px 0; background: rgba(0,0,0,0.28); }\n' +
4880
+ '.dsh-music-tab { flex: none; width: 100%; padding: 16px 8px; border: none; background: transparent; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; }\n' +
4881
+ // 选中的 tab:用面板底色填充,与右侧透明内容区同色、右缘直通(无缝隙)连成整体;
4882
+ // 左缘一条强调色竖条 + 加粗,指示当前所在项。
4883
+ '.dsh-music-tab.active { background: var(--dsw-alias-bg-overlay, #1e1f22); color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 600; box-shadow: inset 3px 0 0 var(--dsh-music-accent, #2f9e6e); }\n' +
4727
4884
  '.dsh-music-panel-drag { cursor: move; touch-action: none; user-select: none; }\n' +
4728
4885
  '.dsh-music-panel-grip { color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 12px; letter-spacing: -1px; opacity: 0.7; }\n' +
4729
4886
  '.dsh-music-panel-title { font-weight: 600; margin-right: auto; }\n' +
@@ -4776,6 +4933,23 @@ window.__ModuleLoader__.load({
4776
4933
  '.dsh-music-empty { padding: 12px; text-align: center; color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 12px; }\n' +
4777
4934
  '.dsh-music-error { color: var(--dsw-alias-state-error-primary, #e5534b); font-size: 12px; }\n' +
4778
4935
  '.dsh-music-loading { color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 12px; }\n' +
4936
+ // 系统配置面板:开关行(标签 + 描述 + 右侧开关)。
4937
+ '.dsh-music-config { display: flex; flex-direction: column; gap: 12px; }\n' +
4938
+ '.dsh-music-config-row { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04)); }\n' +
4939
+ '.dsh-music-config-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }\n' +
4940
+ '.dsh-music-config-label { color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; font-weight: 600; }\n' +
4941
+ '.dsh-music-config-desc { color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 11px; }\n' +
4942
+ // 开关:右侧胶囊。用固定强调色而非随主题反转的 brand-primary,
4943
+ // 让深/浅主题下都读作「绿色=开、灰=关」;旋钮恒白保证两种主题下对比清晰。
4944
+ '.dsh-music-toggle { position: relative; flex: none; width: 40px; height: 22px; padding: 0; border: none; border-radius: 22px; background: var(--dsw-alias-border-l1, rgba(128,128,128,0.35)); cursor: pointer; transition: background 0.15s; }\n' +
4945
+ '.dsh-music-toggle .dsh-music-toggle-knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.35); transition: left 0.15s; }\n' +
4946
+ // 开启:固定绿色强调色(不随主题反转),旋钮右移。
4947
+ '.dsh-music-toggle.on { background: #2f9e6e; }\n' +
4948
+ '.dsh-music-toggle.on .dsh-music-toggle-knob { left: 20px; }\n' +
4949
+ // 沉浸感滑块行:右侧 range + 百分比数值。
4950
+ '.dsh-music-config-slider { flex: none; display: flex; align-items: center; gap: 8px; }\n' +
4951
+ '.dsh-music-config-range { width: 140px; accent-color: #2f9e6e; cursor: pointer; }\n' +
4952
+ '.dsh-music-config-val { min-width: 34px; text-align: right; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); font-variant-numeric: tabular-nums; }\n' +
4779
4953
  '.dsh-music-settings { display: flex; flex-direction: column; gap: 10px; }\n' +
4780
4954
  '.dsh-music-settings-row { display: flex; gap: 8px; align-items: center; }\n' +
4781
4955
  '.dsh-music-settings-cur { flex: 1; min-width: 0; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04)); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
@@ -4825,7 +4999,7 @@ window.__ModuleLoader__.load({
4825
4999
  '.dsh-music-qq-login-status { font-size: 14px; color: var(--dsw-alias-label-primary, #e6e6e6); text-align: center; }\n' +
4826
5000
  '.dsh-music-qq-login-actions { display: flex; gap: 8px; }\n' +
4827
5001
  '.dsh-music-qq-viewtabs { display: flex; gap: 6px; }\n' +
4828
- '.dsh-music-qq-viewtab { padding: 5px 12px; border-radius: 8px; border: none; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 13px; }\n' +
5002
+ '.dsh-music-qq-viewtab { flex: none; white-space: nowrap; padding: 5px 12px; border-radius: 8px; border: none; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 13px; }\n' +
4829
5003
  '.dsh-music-qq-viewtab.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
4830
5004
  '.dsh-music-qq-cats { display: flex; flex-wrap: wrap; gap: 6px; }\n' +
4831
5005
  '.dsh-music-qq-cat { padding: 4px 10px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); background: transparent; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 12px; }\n' +
@@ -4853,9 +5027,12 @@ window.__ModuleLoader__.load({
4853
5027
  '.dsh-music-qq-now-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
4854
5028
  '.dsh-music-qq-now-artist { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary, #8a8f98); margin-left: 4px; }\n' +
4855
5029
  '.dsh-music-qq-now-src { flex: 0 0 auto; color: var(--dsw-alias-label-secondary, #8a8f98); margin-left: auto; }\n' +
4856
- '.dsh-music-qq-toolbar { display: flex; justify-content: space-between; gap: 8px; align-items: center; }\n' +
5030
+ '.dsh-music-qq-toolbar { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 12px; }\n' +
4857
5031
  '.dsh-music-qq-login { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; }\n' +
4858
5032
  '.dsh-music-qq-login-center { display: flex; flex-direction: column; gap: 12px; align-items: center; max-width: 320px; }\n' +
5033
+ // 醒目提示:QQ 扫码登录已验证 OK。
5034
+ '.dsh-music-qq-login-verified { display: flex; align-items: center; gap: 8px; width: 100%; max-width: 300px; padding: 10px 14px; box-sizing: border-box; font-size: 14px; font-weight: 700; color: #fff; background: linear-gradient(135deg, #16a34a, #22c55e); border-radius: 8px; text-align: center; justify-content: center; box-shadow: 0 2px 8px rgba(22, 163, 74, 0.35); }\n' +
5035
+ '.dsh-music-qq-login-verified-icon { font-size: 18px; line-height: 1; }\n' +
4859
5036
  '.dsh-music-qq-login-btn { width: 200px; padding: 10px 16px; font-size: 15px; }\n' +
4860
5037
  // 免责声明:居中块内的左对齐编号列表,阅读更清晰。
4861
5038
  '.dsh-music-qq-login-warn { display: flex; flex-direction: column; gap: 4px; width: 100%; max-width: 300px; margin-top: 4px; font-size: 12px; color: var(--dsw-alias-state-warn-primary, #d9a441); line-height: 1.5; text-align: left; box-sizing: border-box; max-height: 30vh; overflow-y: auto; }\n' +
package/lib/index.js CHANGED
@@ -1176,6 +1176,8 @@ export function apply(ctx) {
1176
1176
  'dsh-music-mode', 'dsh-music-volume', 'dsh-music-voice', 'dsh-music-scope',
1177
1177
  'dsh-music-panel-pos', 'dsh-music-playback', 'dsh-music-books-playback',
1178
1178
  'dsh-music-qq-fav', 'dsh-music-qq-history', 'dsh-music-qq-ui',
1179
+ 'dsh-music-show-lyric', 'dsh-music-show-viz', 'dsh-music-show-progress',
1180
+ 'dsh-music-immerse',
1179
1181
  ])
1180
1182
  const PREF_VALUE_MAX = 256 * 1024 // 单键上限(books 进度 map / QQ 队列可能较大)
1181
1183
  const sanitizePrefs = (input) => {
@@ -1191,6 +1193,12 @@ export function apply(ctx) {
1191
1193
  out[k] = String(Math.min(1, Math.max(0, n)))
1192
1194
  continue
1193
1195
  }
1196
+ if (k === 'dsh-music-immerse') {
1197
+ const n = Number(v)
1198
+ if (!Number.isFinite(n)) continue
1199
+ out[k] = String(Math.min(1, Math.max(0, n)))
1200
+ continue
1201
+ }
1194
1202
  if (k === 'dsh-music-mode') {
1195
1203
  if (v !== 'single' && v !== 'order' && v !== 'shuffle') continue
1196
1204
  out[k] = v
package/lib/qq.js CHANGED
@@ -31,16 +31,25 @@ async function fetchWithTimeout(url, opts = {}, timeoutMs = 12000) {
31
31
  }
32
32
  }
33
33
 
34
- export function hash33(s) { let h = 0; for (const c of s) h = ((h << 5) + c.charCodeAt(0)) >>> 0; return h & 0x7fffffff }
34
+ // hash33: QQ ptlogin Go 参考实现一致 —— Go 源码为 `h += (h<<5) + c`,等价于 `h = h*33 + c`。
35
+ // 注意:不是 `h*32 + c`(即缺少 +h 会算错 ptqrtoken,导致 ptqrlogin 返回 403)。
36
+ export function hash33(s) { let h = 0; for (const c of s) h = ((h * 33) + c.charCodeAt(0)) >>> 0; return h & 0x7fffffff }
35
37
 
36
38
  function responseCookies(res) {
37
39
  const out = {}
38
40
  const list = res.headers.getSetCookie ? res.headers.getSetCookie() : []
39
- for (const c of list) { const p = c.split(';')[0]; const i = p.indexOf('='); if (i < 0) continue; out[p.slice(0, i).trim()] = p.slice(i + 1).trim() }
41
+ for (const c of list) {
42
+ const p = c.split(';')[0]; const i = p.indexOf('='); if (i < 0) continue
43
+ const k = p.slice(0, i).trim(); const v = p.slice(i + 1).trim()
44
+ // Set-Cookie 里可能同时给「真实值」和「删除标记」(空值 + Expires=1970 / Max-Age=0)。
45
+ // 空值删除标记不应覆盖已有的真实值(否则会把 p_skey / p_uin 等清空)。
46
+ if (v === '' && out[k] != null && out[k] !== '') continue
47
+ out[k] = v
48
+ }
40
49
  return out
41
50
  }
42
51
  export function joinCookieMap(cookies) {
43
- return Object.keys(cookies).filter(k => k.trim() && cookies[k].trim()).sort().map(k => `${k}=${cookies[k]}`).join('; ')
52
+ return Object.keys(cookies).filter(k => k.trim() && (cookies[k] || '').trim()).sort().map(k => `${k}=${cookies[k]}`).join('; ')
44
53
  }
45
54
 
46
55
  // =====================================================================
@@ -88,11 +97,64 @@ export async function checkQRLogin(keyStr) {
88
97
  if (result.status !== 'success') return result
89
98
  let cookies = responseCookies(res)
90
99
  if (redirectURL) { try { cookies = await fetchQQRedirectCookies(redirectURL, cookies) } catch (e) { result.extra.redirect_error = e.message } }
100
+ // 关键:QQ 扫码成功拿到的是 QQ 网页 cookie(uin/superkey…),本身不含 QQ 音乐的 musickey。
101
+ // 参照 GitHub-ZC/wp_MusicApi util/login_qq_scan.js:需再走「graph.qq.com OAuth 换取 code →
102
+ // musicu.fcg QQConnectLogin.LoginServer/QQLogin 换 musickey」两步,才能拿到 qm_keyst/musickey。
103
+ // 否则 getMyPlaylists 等接口因缺 authst 报 code 80030(未登录)。
104
+ try { cookies = await exchangeQQMusicAuthst(cookies, redirectURL, result) } catch (e) { result.extra.authst_error = e.message }
91
105
  result.cookies = normalizeQQMusicCookies(cookies)
92
106
  result.cookie = joinCookieMap(result.cookies)
93
107
  return result
94
108
  }
95
109
 
110
+ // 参考 GitHub-ZC/wp_MusicApi util/login_qq_scan.js:QQ 扫码登录后补齐 QQ 音乐登录态(musickey)。
111
+ // 步骤:① 跟随 pt_auth_key 跳转收集 graph.qq.com cookie;② OAuth authorize 拿 code;
112
+ // ③ musicu.fcg QQConnectLogin.LoginServer/QQLogin 用 code 换 musickey。
113
+ async function exchangeQQMusicAuthst(cookies, redirectURL, result) {
114
+ // 直接从 cookies 对象读取(不经过 joinCookieMap,避免它过滤掉空值/特殊字符的 key)。
115
+ const getC = (k) => { const v = cookies && typeof cookies === 'object' ? cookies[k] : undefined; return v != null ? String(v).trim() : '' }
116
+ const graphCookie = joinCookieMap(cookies)
117
+ const gtkSrc = getC('p_skey') || getC('skey') || getC('qqmusic_key') || getC('p_lskey') || getC('lskey') || getC('qm_keyst') || getC('p_skey_forbid')
118
+ const g_tk = qqGtk(gtkSrc)
119
+ const OAuth = 'https://graph.qq.com/oauth2.0/authorize'
120
+ const OAuthRedirect = 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/'
121
+ const ui = 'DFEC5395-9E69-4D3E-96A6-300BB770874D'
122
+ const oauthParams = new URLSearchParams()
123
+ oauthParams.set('response_type', 'code'); oauthParams.set('client_id', '100497308')
124
+ oauthParams.set('redirect_uri', OAuthRedirect); oauthParams.set('scope', 'all'); oauthParams.set('state', 'state')
125
+ oauthParams.set('switch', ''); oauthParams.set('from_ptlogin', '1'); oauthParams.set('src', '1')
126
+ oauthParams.set('update_auth', '1'); oauthParams.set('openapi', '80901010_1030'); oauthParams.set('g_tk', String(g_tk))
127
+ oauthParams.set('auth_time', String(Date.now())); oauthParams.set('ui', ui)
128
+ const oauthRes = await fetchWithTimeout(OAuth, { method: 'POST', headers: { 'User-Agent': UA, 'Referer': 'https://xui.ptlogin2.qq.com/', 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': graphCookie }, body: oauthParams.toString(), redirect: 'manual' })
129
+ const location = oauthRes.headers.get('location') || ''
130
+ for (const [k, v] of Object.entries(responseCookies(oauthRes))) cookies[k] = v
131
+ let code = (location.match(/[?&]code=([^&]+)/) || [])[1] || (location.match(/[?&]code%3D([^&]+)/) || [])[1] || ''
132
+ if (!code && getC('qm_keyst') && getC('uin')) return cookies
133
+ if (!code) { result.extra.oauth_no_code = location.slice(0, 120); return cookies }
134
+ // ③ musicu.fcg QQLogin 换 musickey
135
+ const payload = JSON.stringify({ comm: { g_tk: 5381, platform: 'yqq', ct: 24, cv: 0 }, req: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param: { code } } })
136
+ const QQLoginCookie = joinCookieMap(cookies)
137
+ const endpoints = ['https://u.y.qq.com/cgi-bin/musicu.fcg', 'https://szu.y.qq.com/cgi-bin/musicu.fcg', 'https://shu.y.qq.com/cgi-bin/musicu.fcg']
138
+ let lastErr
139
+ for (const api of endpoints) {
140
+ const r = await fetchWithTimeout(api, { method: 'POST', headers: { 'User-Agent': UA, 'Referer': 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/', 'Origin': 'https://y.qq.com', 'Accept': '*/*', 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': QQLoginCookie }, body: payload })
141
+ const body = await r.text()
142
+ for (const [k, v] of Object.entries(responseCookies(r))) if (!cookies[k]) cookies[k] = v
143
+ if (r.status !== 200) { lastErr = new Error(`qq login http ${r.status}`); continue }
144
+ let parsed; try { parsed = parseJsonPreserveBigInt(body) } catch { lastErr = new Error('qq login json'); continue }
145
+ if (parsed.code !== 0 || parsed.req?.code !== 0) { lastErr = new Error(`qq login api error: ${parsed.req?.message || parsed.req?.msg || parsed.message || parsed.msg}`); continue }
146
+ // 从响应 data 提取 musickey(与微信类似)
147
+ const data = parsed.req?.data || {}
148
+ const dataCookies = wxLoginDataCookies(data)
149
+ for (const [k, v] of Object.entries(dataCookies)) if (!cookies[k]) cookies[k] = v
150
+ const mu = cookies.musickey || cookies.qqmusic_key || cookies.qm_keyst
151
+ if (mu) { cookies.qqmusic_key = mu; cookies.qm_keyst = mu; if (!cookies.uin) cookies.uin = cookies.musicid || cookies.uin }
152
+ return cookies
153
+ }
154
+ if (lastErr) result.extra.authst_api_error = lastErr.message
155
+ return cookies
156
+ }
157
+
96
158
  function parseQQQRCheck(raw) {
97
159
  const matches = raw.match(/'([^']*)'/g) || []
98
160
  const codes = matches.map(m => m.slice(1, -1))
@@ -304,10 +366,12 @@ function wxLoginDataCookies(data) {
304
366
 
305
367
  export function normalizeQQMusicCookies(cookies) {
306
368
  const r = { ...cookies }
307
- const first = (...xs) => xs.find(x => x && x.trim())
369
+ const first = (...xs) => { for (const x of xs) { if (x && String(x).trim()) return String(x).trim() } return '' }
308
370
  if (!r.uin) r.uin = first(r.ptui_loginuin, r.luin, r.pt2gguin, r.superuin, r.p_uin, r.musicid, r.userid, r.wxuin)
309
371
  if (!r.qqmusic_key) r.qqmusic_key = first(r.p_skey, r.skey, r.musickey)
310
372
  if (!r.qm_keyst) r.qm_keyst = r.qqmusic_key
373
+ // 避免给 cookie map 塞 undefined/null 值(joinCookieMap / 后续字符串处理会崩溃)
374
+ for (const k of Object.keys(r)) if (r[k] == null) delete r[k]
311
375
  return r
312
376
  }
313
377
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",