dsh-music-player 0.5.1 → 0.5.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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  DeepSeek Harness 本地音乐/小说播放插件。
6
6
 
7
- 写代码写累了、想摸鱼又不想切窗口?这个插件就是你的**摸鱼神器**——直接在 DeepSeek Harness 的网页里塞进一个本地音乐播放器:扫一下你电脑上的音乐目录(默认 `~/Music`)就能在浏览器里听歌,带播放条和可拖拽的播放面板,还能自己建歌单。
7
+ 写代码写累了、想摸鱼又不想切窗口?这个插件就是你的**摸鱼神器**——直接在 DeepSeek Harness 的网页里塞进一个 **DSH音乐播放器**:扫一下你电脑上的音乐目录(默认 `~/Music`)就能在浏览器里听歌,带播放条和可拖拽的播放面板,还能自己建歌单。
8
8
 
9
9
  光听歌还不够,它还能**听书**:把本地 `.txt` 小说丢给 AI 朗读,想听哪章点哪章、声音随便挑。最绝的是它还注册了 `music_play` 模型工具——你连鼠标都不用动,在对话框里跟 agent 说句「播放周杰伦的歌」,音乐分分钟响起来,摸鱼摸出新境界。
10
10
 
@@ -52,7 +52,7 @@ dsh plugin --profile <profile> add github:kendu76/dsh-music-player
52
52
  > 项目是手写的纯 JS(`lib/` 直接是发布产物),**没有**需要从源码构建的步骤,因此从 GitHub/npm 直装即可使用,无需像 TypeScript 包那样为构建脚本授权。
53
53
 
54
54
  安装后重启 DSH,打开 Web GUI:
55
- - 聊天输入区上方会出现「本地音乐播放器」播放条
55
+ - 聊天输入区上方会出现「DSH音乐播放器」播放条
56
56
  - 点击右侧「列表」按钮打开播放面板
57
57
  - 在面板顶部点击「选择音乐目录」并选定音乐目录(默认 `~/Music`),自动递归扫描
58
58
  - 之后可直接在对话框里让 agent 播放,例如「播放周杰伦的歌」
package/lib/client.js CHANGED
@@ -33,6 +33,30 @@ window.__ModuleLoader__.load({
33
33
  : (node) => node; // defensive fallback (react-dom is always provided by DSH)
34
34
  const portalToBody = (node) => createPortal(node, document.body);
35
35
 
36
+ // 以播放面板中心为基准的固定定位样式(面板可拖拽,弹窗随其居中)。
37
+ // halfW 为目标弹窗的近似半宽;maxH 为弹窗最大高度(px):垂直 clamp 用
38
+ // maxH 的一半,保证 translate 居中后弹窗完整落在视口内;内容超过 maxH 时
39
+ // 由内部 .dsh-music-picker-list 滚动承载(底部按钮保持固定可见)。
40
+ // 面板不可见/无尺寸(如关闭态)时回退到视口中心。on 控制是否真正计算。
41
+ const panelCenterStyle = (panelRef, on, halfW, maxH) => {
42
+ if (!on) return null;
43
+ const pr = (panelRef && panelRef.current) ? panelRef.current.getBoundingClientRect() : null;
44
+ const vw = window.innerWidth, vh = window.innerHeight;
45
+ const cx = (pr && pr.width > 0) ? pr.left + pr.width / 2 : vw / 2;
46
+ const cy = (pr && pr.height > 0) ? pr.top + pr.height / 2 : vh / 2;
47
+ const clampC = (v, lo, hi) => (lo <= hi ? Math.max(lo, Math.min(v, hi)) : v);
48
+ const halfWm = Math.min(halfW, vw / 2);
49
+ const halfHm = Math.min(maxH / 2, vh / 2);
50
+ return {
51
+ position: 'fixed',
52
+ left: clampC(cx, halfWm, vw - halfWm),
53
+ top: clampC(cy, halfHm, vh - halfHm),
54
+ transform: 'translate(-50%, -50%)',
55
+ maxHeight: maxH + 'px',
56
+ margin: 0,
57
+ };
58
+ };
59
+
36
60
  // This host/environment throws a harmless, unhandled rejection from
37
61
  // Chromium's media pipeline — "Cannot read properties of undefined (reading
38
62
  // 'getTopURL')" — whenever an <audio> element loads or plays. Playback and
@@ -202,6 +226,12 @@ window.__ModuleLoader__.load({
202
226
  // 已收藏到「我喜欢」的歌曲 songid / songmid 集合(用于判断当前曲目是否已收藏)。
203
227
  qqFavIds: [],
204
228
  qqFavMids: [],
229
+ // 「我喜欢」收藏成功/取消成功后的递增计数,供 QQ 面板刷新该歌单数目。
230
+ qqFavRev: 0,
231
+ // 自定义输入弹窗(替代浏览器 prompt):{ id, title, initial, onOk } | null。
232
+ prompt: null,
233
+ // 自定义确认弹窗(替代浏览器 confirm):{ title, message, onOk, okText, danger } | null。
234
+ confirm: null,
205
235
  };
206
236
  const listeners = new Set();
207
237
  function set(patch) {
@@ -222,6 +252,24 @@ window.__ModuleLoader__.load({
222
252
  }, []);
223
253
  return snap;
224
254
  }
255
+ // 自定义输入弹窗(替代浏览器 prompt):openPrompt 打开、closePrompt 关闭,
256
+ // onOk(value) 在用户点「确定」时收到去空格后的值;点「取消」/关闭不回调。
257
+ let promptSeq = 0;
258
+ function openPrompt(title, initial, onOk) {
259
+ set({ prompt: { id: ++promptSeq, title, initial: (initial || ''), onOk } });
260
+ }
261
+ function closePrompt() {
262
+ set({ prompt: null });
263
+ }
264
+ // 自定义确认弹窗(替代浏览器 confirm):点「确定」回调 onOk();
265
+ // 点「取消」/关闭/Esc 不回调。danger=true 时确定按钮用危险色提示。
266
+ function openConfirm(title, message, onOk, okText, danger) {
267
+ set({ confirm: { title, message: (message || ''), onOk, okText: (okText || '确定'), danger: !!danger } });
268
+ }
269
+ function closeConfirm() {
270
+ set({ confirm: null });
271
+ }
272
+
225
273
  const trackById = (id) => (store.tracks || []).find((t) => t.id === id) || null;
226
274
 
227
275
  // ---- 自建歌单:范围 / 解析 / 收藏 ----
@@ -340,6 +388,8 @@ window.__ModuleLoader__.load({
340
388
  set({ qqFavIds: idsA, qqFavMids: midsA });
341
389
  persistQQFav(idsA, midsA);
342
390
  }
391
+ // 通知 QQ 面板刷新「我喜欢」歌单的数目。
392
+ set({ qqFavRev: (store.qqFavRev || 0) + 1 });
343
393
  }
344
394
  else if (d && d.error) set({ error: d.error });
345
395
  else set({ error: '收藏失败(HTTP ' + status + '),请重试' });
@@ -409,59 +459,59 @@ window.__ModuleLoader__.load({
409
459
  }
410
460
  // 歌单管理:新建 / 重命名 / 删除 / 移动歌曲。
411
461
  function onCreatePlaylist() {
412
- const name = window.prompt('新建歌单名称', '');
413
- if (name === null) return;
414
- const trimmed = name.trim();
415
- if (trimmed === '') return;
416
- fetch('/dsh-music/playlist', {
417
- method: 'POST', cache: 'no-store',
418
- headers: { 'content-type': 'application/json' },
419
- body: JSON.stringify({ name: trimmed }),
420
- }).then((r) => r.json()).then((r) => {
421
- if (r && r.playlist) {
422
- set({ playlists: [...(store.playlists || []), r.playlist], subTab: r.playlist.id });
423
- }
424
- }).catch(() => {});
462
+ openPrompt('新建歌单名称', '', (trimmed) => {
463
+ if (!trimmed) return;
464
+ fetch('/dsh-music/playlist', {
465
+ method: 'POST', cache: 'no-store',
466
+ headers: { 'content-type': 'application/json' },
467
+ body: JSON.stringify({ name: trimmed }),
468
+ }).then((r) => r.json()).then((r) => {
469
+ if (r && r.playlist) {
470
+ set({ playlists: [...(store.playlists || []), r.playlist], subTab: r.playlist.id });
471
+ }
472
+ }).catch(() => {});
473
+ });
425
474
  }
426
475
  function onRenamePlaylist(pl) {
427
- const name = window.prompt('重命名歌单「' + pl.name + '」', pl.name);
428
- if (name === null) return;
429
- const trimmed = name.trim();
430
- if (trimmed === '' || trimmed === pl.name) return;
431
- fetch('/dsh-music/playlist/rename', {
432
- method: 'POST', cache: 'no-store',
433
- headers: { 'content-type': 'application/json' },
434
- body: JSON.stringify({ id: pl.id, name: trimmed }),
435
- }).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); }).catch(() => {});
476
+ openPrompt('重命名歌单「' + pl.name + '」', pl.name, (trimmed) => {
477
+ if (trimmed === pl.name) return;
478
+ fetch('/dsh-music/playlist/rename', {
479
+ method: 'POST', cache: 'no-store',
480
+ headers: { 'content-type': 'application/json' },
481
+ body: JSON.stringify({ id: pl.id, name: trimmed }),
482
+ }).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); }).catch(() => {});
483
+ });
436
484
  }
437
485
  function onDeletePlaylist(pl) {
438
- if (!window.confirm('删除歌单「' + pl.name + '」?歌曲文件不会被删除。')) return;
439
- fetch('/dsh-music/playlist/delete', {
440
- method: 'POST', cache: 'no-store',
441
- headers: { 'content-type': 'application/json' },
442
- body: JSON.stringify({ id: pl.id }),
443
- }).then((r) => r.json()).then((r) => {
444
- if (r && r.ok) {
445
- const next = (store.playlists || []).filter((p) => p.id !== pl.id);
446
- set({ playlists: next, subTab: 'library' });
447
- if (store.scope && store.scope.kind === 'playlist' && store.scope.id === pl.id) {
448
- set({ scope: { kind: 'library' } });
486
+ openConfirm('删除歌单', '删除歌单「' + pl.name + '」?歌曲文件不会被删除。', () => {
487
+ fetch('/dsh-music/playlist/delete', {
488
+ method: 'POST', cache: 'no-store',
489
+ headers: { 'content-type': 'application/json' },
490
+ body: JSON.stringify({ id: pl.id }),
491
+ }).then((r) => r.json()).then((r) => {
492
+ if (r && r.ok) {
493
+ const next = (store.playlists || []).filter((p) => p.id !== pl.id);
494
+ set({ playlists: next, subTab: 'library' });
495
+ if (store.scope && store.scope.kind === 'playlist' && store.scope.id === pl.id) {
496
+ set({ scope: { kind: 'library' } });
497
+ }
449
498
  }
450
- }
451
- }).catch(() => {});
499
+ }).catch(() => {});
500
+ }, '删除', true);
452
501
  }
453
502
  // 一键清空歌单(任何歌单都可用,含系统「我最喜欢」;仅从歌单移除,不删文件)。
454
503
  function onClearPlaylist(pl) {
455
504
  const n = (pl.tracks || []).length;
456
505
  if (n === 0 && !pl.missing) return;
457
- if (!window.confirm('清空歌单「' + pl.name + '」?将移除全部 ' + n + ' 首歌曲' + (pl.missing > 0 ? '(另有 ' + pl.missing + ' 首已失效一并清除)' : '') + ',歌曲文件不会被删除。')) return;
458
- fetch('/dsh-music/playlist/clear', {
459
- method: 'POST', cache: 'no-store',
460
- headers: { 'content-type': 'application/json' },
461
- body: JSON.stringify({ id: pl.id }),
462
- }).then((r) => r.json()).then((r) => {
463
- if (r && r.playlist) updatePlaylistInStore(r.playlist);
464
- }).catch(() => {});
506
+ openConfirm('清空歌单', '清空歌单「' + pl.name + '」?将移除全部 ' + n + ' 首歌曲' + (pl.missing > 0 ? '(另有 ' + pl.missing + ' 首已失效一并清除)' : '') + ',歌曲文件不会被删除。', () => {
507
+ fetch('/dsh-music/playlist/clear', {
508
+ method: 'POST', cache: 'no-store',
509
+ headers: { 'content-type': 'application/json' },
510
+ body: JSON.stringify({ id: pl.id }),
511
+ }).then((r) => r.json()).then((r) => {
512
+ if (r && r.playlist) updatePlaylistInStore(r.playlist);
513
+ }).catch(() => {});
514
+ }, '确定', true);
465
515
  }
466
516
  function movePlaylistTrack(pl, path, dir) {
467
517
  const paths = (pl.tracks || []).map((t) => t.path);
@@ -710,6 +760,10 @@ window.__ModuleLoader__.load({
710
760
  let shuffleQueue = [];
711
761
  let shufflePos = -1;
712
762
  let shuffleScopeKey = null;
763
+ // 在线 QQ 队列连续失败的跳过次数:某首歌因版权下架/拿不到地址而触发
764
+ // <audio> error 时自动跳到下一首;连续跳过次数达到队列长度(整列都试过)
765
+ // 即停止报错——且停止后不再 step,杜绝无限循环跳歌。成功播放(onPlay)清零。
766
+ let qqErrorSkipCount = 0;
713
767
  function buildShuffleQueue(anchorId) {
714
768
  const ids = activeIds();
715
769
  // Fisher-Yates
@@ -1310,7 +1364,7 @@ window.__ModuleLoader__.load({
1310
1364
  set({ duration: (bookTimeBase() + audio.duration) });
1311
1365
  }
1312
1366
  };
1313
- const onPlay = () => { set({ playing: true, error: null }); acquireWakeLock(); };
1367
+ const onPlay = () => { qqErrorSkipCount = 0; set({ playing: true, error: null }); acquireWakeLock(); };
1314
1368
  const onPause = () => { set({ playing: false }); savePlayback(); releaseWakeLock(); };
1315
1369
  const onEnded = () => {
1316
1370
  // A novel plays chunk-by-chunk: when a chunk ends, auto-advance to the
@@ -1359,6 +1413,17 @@ window.__ModuleLoader__.load({
1359
1413
  }).catch(() => {});
1360
1414
  }
1361
1415
  } else {
1416
+ // 在线 QQ 队列:某首歌因版权下架/拿不到播放地址而加载失败时,自动
1417
+ // 跳到下一首继续播放(不因单曲失败中断整个队列);只有队列里就这一
1418
+ // 首、或连续跳过次数已达队列长度(整列都试过)才停下报错——且停止后
1419
+ // 不再 step,杜绝无限循环跳歌。
1420
+ const isQQTrack = store.currentId !== null && String(store.currentId).startsWith('qq:');
1421
+ const qLen = (store.qqQueue || []).length;
1422
+ if (isQQTrack && qLen > 1 && qqErrorSkipCount < qLen) {
1423
+ qqErrorSkipCount++;
1424
+ step(1);
1425
+ return;
1426
+ }
1362
1427
  set({ error: '音频加载或解码失败', playing: false });
1363
1428
  }
1364
1429
  };
@@ -1798,7 +1863,7 @@ window.__ModuleLoader__.load({
1798
1863
  React.createElement('div', { className: 'dsh-music-bar' + (isBook ? ' book' : '') },
1799
1864
  hasTrack
1800
1865
  ? React.createElement('span', { className: 'dsh-music-bar-name', title: name + (artistText ? ' - ' + artistText : '') }, note, ' ', name, artistEl, sourceBadge, afterName)
1801
- : React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' 本地音乐播放器'),
1866
+ : React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' DSH音乐播放器'),
1802
1867
  // 章节名独立占一整行、完整显示(不再被省略号截断)。
1803
1868
  sectionBadge,
1804
1869
  !isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 64, height: 14, ref: (el) => { barCanvasNode = el; } }) : null,
@@ -1980,7 +2045,7 @@ window.__ModuleLoader__.load({
1980
2045
  // 在线 QQ 面板的「上次所在层」只在本次会话首次挂载时恢复一次,避免切 tab
1981
2046
  // (QQ 音乐 ↔ 本地音乐/AI讲书)重新挂载时又被 localStorage 里的旧层拉回播放列表页。
1982
2047
  let qqUiRestored = false;
1983
- function QQOnlinePanel() {
2048
+ function QQOnlinePanel({ panelRef }) {
1984
2049
  const s = useStore();
1985
2050
  const [loggedIn, setLoggedIn] = useState(s.qqLoggedIn || false);
1986
2051
  const [uin, setUin] = useState(s.qqUin || '');
@@ -1998,6 +2063,15 @@ window.__ModuleLoader__.load({
1998
2063
  const [plResults, setPlResults] = useState([]);
1999
2064
  const [qError, setQError] = useState('');
2000
2065
  const [resultTab, setResultTab] = useState('songs'); // 搜索结果内:songs | playlists
2066
+ // 搜索分页:page 从 1 起、每页 20。「是否还有下一页」由最近一页是否返回满页判断,
2067
+ // 不依赖 QQ 接口必返的 totalnum(歌单搜索的 totalnum 可能缺失/不可靠)。
2068
+ const QQLIST_PAGE = 20;
2069
+ const [searchPage, setSearchPage] = useState(1);
2070
+ const [searchLastLen, setSearchLastLen] = useState(0);
2071
+ const [searchingMore, setSearchingMore] = useState(false);
2072
+ const [plSearchPage, setPlSearchPage] = useState(1);
2073
+ const [plSearchLastLen, setPlSearchLastLen] = useState(0);
2074
+ const [plSearchingMore, setPlSearchingMore] = useState(false);
2001
2075
  // 搜索历史(localStorage 持久化,最近在前)。
2002
2076
  const [hist, setHist] = useState([]);
2003
2077
  const [histOpen, setHistOpen] = useState(false);
@@ -2011,6 +2085,17 @@ window.__ModuleLoader__.load({
2011
2085
  const [curCategory, setCurCategory] = useState(null);
2012
2086
  const [browseErr, setBrowseErr] = useState('');
2013
2087
  const [browseLoading, setBrowseLoading] = useState(false);
2088
+ // 「加入歌单」弹窗:{ song, x, y }(锚点=「+」按钮的视口坐标),点击弹出我的歌单列表。
2089
+ const [qqJoin, setQqJoin] = useState(null);
2090
+ const [qqJoinMsg, setQqJoinMsg] = useState('');
2091
+ const qqJoinRef = useRef(null);
2092
+ // 点击弹窗外关闭「加入歌单」弹窗。
2093
+ useEffect(() => {
2094
+ if (qqJoin === null) return;
2095
+ const onDown = (e) => { if (qqJoinRef.current !== null && !qqJoinRef.current.contains(e.target)) setQqJoin(null); };
2096
+ document.addEventListener('mousedown', onDown);
2097
+ return () => document.removeEventListener('mousedown', onDown);
2098
+ }, [qqJoin]);
2014
2099
  // 推荐歌单加载更多:热门推荐 12 条后用「全部分类」分页续载。
2015
2100
  const [recPage, setRecPage] = useState(1);
2016
2101
  const [recLoadingMore, setRecLoadingMore] = useState(false);
@@ -2064,20 +2149,34 @@ window.__ModuleLoader__.load({
2064
2149
  setActivePl({ name: s.qqSource || '在线播放列表', songs: s.qqQueue || [], source: s.qqSource || '在线' });
2065
2150
  }
2066
2151
  };
2152
+ // ① 挂载时只做本地初始化:读搜索历史(localStorage)+ 查登录态(status 由
2153
+ // host 读本地 cookie 文件,不发任何外部网络请求)。外部数据(分类/推荐/我的
2154
+ // 歌单)一律延后到登录后(见下方 [loggedIn] effect),未登录零外部请求——
2155
+ // 本插件 QQ 在线功能以「登录」为门槛。
2067
2156
  useEffect(() => {
2068
2157
  setHist(loadHist());
2069
2158
  jsonGet('/dsh-music/qq/status').then((d) => { if (d) { setLoggedIn(!!d.loggedIn); setUin(d.uin || ''); setNickname(d.nickname || ''); } }).catch(() => {});
2070
- json('/dsh-music/qq/playlist-categories').then((d) => { if (d && d.ok) setCategories(d.categories || []); });
2071
- // 默认落在「我的歌单」tab:登录态下加载我的歌单;未登录时主 UI 不显示。
2072
- loadMine();
2073
- loadRecommended();
2074
- // 仅本次会话首次挂载时恢复上次所在层;之后切 tab 重挂不再拉回旧层。
2075
- if (!qqUiRestored) { qqUiRestored = true; restoreUi(loadUi()); }
2076
2159
  // 点击搜索框外时关闭历史下拉。
2077
2160
  const onDocClick = (e) => { if (histRef.current !== null && !histRef.current.contains(e.target)) setHistOpen(false); };
2078
2161
  document.addEventListener('mousedown', onDocClick);
2079
2162
  return () => { clearPoll(); document.removeEventListener('mousedown', onDocClick); };
2080
2163
  }, []);
2164
+ // ② 登录态变化时才加载在线数据:未登录(loggedIn=false)一个外部请求都不发;
2165
+ // 登录成功(setLoggedIn(true))或已登录刷新页面(status 返回 true)时自动加载
2166
+ // 分类 / 我的歌单 / 推荐,并恢复上次所在层。登出后不再加载。
2167
+ useEffect(() => {
2168
+ if (!loggedIn) return;
2169
+ json('/dsh-music/qq/playlist-categories').then((d) => { if (d && d.ok) setCategories(d.categories || []); });
2170
+ // 默认落在「我的歌单」tab:登录态下加载我的歌单;未登录时主 UI 不显示。
2171
+ loadMine();
2172
+ loadRecommended();
2173
+ // 仅本次会话首次登录时恢复上次所在层;之后切 tab 重挂不再拉回旧层。
2174
+ if (!qqUiRestored) { qqUiRestored = true; restoreUi(loadUi()); }
2175
+ }, [loggedIn]);
2176
+ // ③ 收藏/取消收藏「我喜欢」后刷新「我的歌单」,让「我喜欢」歌单数目随之更新。
2177
+ useEffect(() => {
2178
+ if (s.qqFavRev > 0 && loggedIn) loadMine();
2179
+ }, [s.qqFavRev]);
2081
2180
 
2082
2181
  async function loadRecommended() {
2083
2182
  setBrowseLoading(true); setBrowseErr('');
@@ -2175,12 +2274,14 @@ window.__ModuleLoader__.load({
2175
2274
  setHistOpen(false);
2176
2275
  saveHist(kw);
2177
2276
  setSearching(true); setQError(''); setResults([]); setPlResults([]); setSearched(true); setCurCategory(null);
2277
+ setSearchPage(1); setSearchLastLen(0); setPlSearchPage(1); setPlSearchLastLen(0);
2178
2278
  const [songs, pls] = await Promise.all([
2179
- json('/dsh-music/qq/search?w=' + encodeURIComponent(kw)),
2180
- json('/dsh-music/qq/playlist-search?w=' + encodeURIComponent(kw)),
2279
+ json('/dsh-music/qq/search?w=' + encodeURIComponent(kw) + '&page=1'),
2280
+ json('/dsh-music/qq/playlist-search?w=' + encodeURIComponent(kw) + '&page=1'),
2181
2281
  ]);
2182
- if (songs && songs.ok) setResults(songs.results || []); else setQError((songs && songs.error) || '歌曲搜索失败');
2183
- if (pls && pls.ok) setPlResults(pls.playlists || []);
2282
+ if (songs && songs.ok) { setResults(songs.results || []); setSearchLastLen((songs.results || []).length); setSearchPage(songs.page || 1); }
2283
+ else setQError((songs && songs.error) || '歌曲搜索失败');
2284
+ if (pls && pls.ok) { setPlResults(pls.playlists || []); setPlSearchLastLen((pls.playlists || []).length); setPlSearchPage(pls.page || 1); }
2184
2285
  // 默认打开「有结果」的那个 tab(歌曲优先)。
2185
2286
  const sLen = (songs && songs.ok && (songs.results || []).length) || 0;
2186
2287
  const pLen = (pls && pls.ok && (pls.playlists || []).length) || 0;
@@ -2188,6 +2289,37 @@ window.__ModuleLoader__.load({
2188
2289
  else if (pLen > 0) setResultTab('playlists');
2189
2290
  setSearching(false);
2190
2291
  }
2292
+ // 搜索结果「加载更多」:追加下一页(共用当前关键词 q)。
2293
+ async function loadMoreSongs() {
2294
+ const kw = q.trim();
2295
+ if (kw === '' || searchingMore) return;
2296
+ const next = searchPage + 1;
2297
+ setSearchingMore(true);
2298
+ try {
2299
+ const d = await json('/dsh-music/qq/search?w=' + encodeURIComponent(kw) + '&page=' + next);
2300
+ if (d && d.ok) {
2301
+ setResults((cur) => cur.concat(d.results || []));
2302
+ setSearchLastLen((d.results || []).length);
2303
+ setSearchPage(d.page || next);
2304
+ }
2305
+ } catch {}
2306
+ setSearchingMore(false);
2307
+ }
2308
+ async function loadMorePls() {
2309
+ const kw = q.trim();
2310
+ if (kw === '' || plSearchingMore) return;
2311
+ const next = plSearchPage + 1;
2312
+ setPlSearchingMore(true);
2313
+ try {
2314
+ const d = await json('/dsh-music/qq/playlist-search?w=' + encodeURIComponent(kw) + '&page=' + next);
2315
+ if (d && d.ok) {
2316
+ setPlResults((cur) => cur.concat(d.playlists || []));
2317
+ setPlSearchLastLen((d.playlists || []).length);
2318
+ setPlSearchPage(d.page || next);
2319
+ }
2320
+ } catch {}
2321
+ setPlSearchingMore(false);
2322
+ }
2191
2323
  function playSong(song, queue, sourceLabel) {
2192
2324
  // 未登录时点击 VIP 歌曲:不要启动注定失败的播放(否则播放条会误报
2193
2325
  // 「频谱不可用/音频加载失败」,重试也无济于事),改为提示并弹出登录。
@@ -2246,15 +2378,79 @@ window.__ModuleLoader__.load({
2246
2378
  const playing = active && s.playing;
2247
2379
  const vip = song.payplay === 1;
2248
2380
  const artists = (song.artists || []).join('/');
2249
- return React.createElement('button', {
2250
- key: id, className: 'dsh-music-track' + (active ? ' active' : ''), title: song.title + ' - ' + artists,
2251
- onClick: () => { if (active) togglePlay(); else playSong(song, queue, sourceLabel); },
2252
- },
2253
- React.createElement('span', { className: 'dsh-music-track-name qq' },
2254
- React.createElement('span', { className: 'dsh-music-track-title' }, (playing ? '▶ ' : '') + song.title),
2255
- vip ? React.createElement('span', { className: 'dsh-music-online-tag vip' }, 'VIP') : null),
2256
- React.createElement('span', { className: 'dsh-music-online-tag' }, artists || 'QQ'));
2381
+ return React.createElement('div', { key: id, className: 'dsh-music-track-row' + (active ? ' active' : '') },
2382
+ React.createElement('button', {
2383
+ className: 'dsh-music-track' + (active ? ' active' : ''), title: song.title + ' - ' + artists,
2384
+ onClick: () => { if (active) togglePlay(); else playSong(song, queue, sourceLabel); },
2385
+ },
2386
+ React.createElement('span', { className: 'dsh-music-track-name qq' },
2387
+ React.createElement('span', { className: 'dsh-music-track-title' }, (playing ? '' : '') + song.title),
2388
+ vip ? React.createElement('span', { className: 'dsh-music-online-tag vip' }, 'VIP') : null),
2389
+ React.createElement('span', { className: 'dsh-music-online-tag' }, artists || 'QQ')),
2390
+ React.createElement('button', {
2391
+ className: 'dsh-music-playlist-mini add',
2392
+ title: '加入我的歌单',
2393
+ onClick: (e) => { e.stopPropagation(); openQqJoin(song, e); },
2394
+ }, '+'));
2257
2395
  };
2396
+ // 「加入歌单」:打开弹窗(列出我的歌单),并把歌曲加入所选歌单 / 新建歌单。
2397
+ function openQqJoin(song, e) {
2398
+ const r = e.currentTarget.getBoundingClientRect();
2399
+ if (minePlays.length === 0 && !mineLoaded) loadMine();
2400
+ setQqJoinMsg('');
2401
+ setQqJoin({ song, x: r.right, y: r.top });
2402
+ }
2403
+ async function qqJoinAddTo(pl) {
2404
+ const song = qqJoin && qqJoin.song;
2405
+ if (!song) return;
2406
+ try {
2407
+ // dirId 用歌单的权威 dirId(dirid),tid 固定 0(「我喜欢」dirId=201,tid=0 的可用模式)。
2408
+ const d = await jsonPost('/dsh-music/qq/playlist-add', { song, dirId: (pl && (pl.dirId || pl.tid || pl.id)) || 0, tid: 0 });
2409
+ if (!d || !d.ok) throw new Error((d && d.error) || '加入歌单失败');
2410
+ // 本地乐观 +1,并异步刷新「我的歌单」,让数目与实际一致。
2411
+ if (pl) {
2412
+ const pid = String(pl.id);
2413
+ setMinePlays((cur) => cur.map((p) => (String(p.id) === pid ? { ...p, trackCount: (Number(p.trackCount) || 0) + 1 } : p)));
2414
+ loadMine();
2415
+ }
2416
+ setQqJoin(null);
2417
+ } catch (err) { setQqJoinMsg(String((err && err.message) || err)); }
2418
+ }
2419
+ async function qqJoinCreate() {
2420
+ const song = qqJoin && qqJoin.song;
2421
+ openPrompt('新建歌单名称', '', async (trimmed) => {
2422
+ if (!trimmed) return;
2423
+ try {
2424
+ const d = await jsonPost('/dsh-music/qq/playlist-create', { name: trimmed });
2425
+ if (!d || !d.ok || !d.playlist) throw new Error((d && d.error) || '创建歌单失败');
2426
+ const created = d.playlist;
2427
+ // AddPlaylist 返回的 id 即新歌单 dirid;AddSonglist 用该 dirid + tid=0 加歌。
2428
+ if (song) {
2429
+ const add = await jsonPost('/dsh-music/qq/playlist-add', { song, dirId: Number(created.id) || 0, tid: 0 });
2430
+ if (!add || !add.ok) throw new Error((add && add.error) || '加入新歌单失败');
2431
+ }
2432
+ loadMine();
2433
+ setQqJoin(null);
2434
+ } catch (err) { setQqJoinMsg(String((err && err.message) || err)); }
2435
+ });
2436
+ }
2437
+ const qqJoinMenu = qqJoin ? portalToBody((() => {
2438
+ const openUp = (qqJoin.y || 0) > ((window.innerHeight || 0) - 240);
2439
+ const style = {
2440
+ left: Math.max(8, (qqJoin.x || 0) - 150),
2441
+ top: openUp ? (qqJoin.y || 0) - 6 : (qqJoin.y || 0) + 8,
2442
+ transform: openUp ? 'translateY(-100%)' : 'none',
2443
+ };
2444
+ return React.createElement('div', { className: 'dsh-music-add-pop', ref: qqJoinRef, style },
2445
+ qqJoinMsg ? React.createElement('div', { className: 'dsh-music-hint', style: { padding: '2px 8px', color: 'var(--dsw-alias-state-error-primary, #e5534b)' } }, qqJoinMsg) : null,
2446
+ minePlays.length > 0 ? minePlays.map((p) => React.createElement('button', {
2447
+ key: p.id, className: 'dsh-music-add-pop-item',
2448
+ title: '加入「' + p.name + '」',
2449
+ onClick: () => qqJoinAddTo(p),
2450
+ }, p.name + (p.trackCount ? '(' + p.trackCount + ')' : ''))) : React.createElement('div', { className: 'dsh-music-hint', style: { padding: '2px 8px' } }, '暂无我的歌单,请先创建。'),
2451
+ React.createElement('button', { className: 'dsh-music-add-pop-item new', onClick: qqJoinCreate }, '+ 新建歌单'),
2452
+ );
2453
+ })()) : null;
2258
2454
  const playRow = (pl) => {
2259
2455
  const meta = (pl.trackCount > 0 ? pl.trackCount + ' 首' : '')
2260
2456
  + (pl.playCount ? ' · 播放 ' + fmtCount(pl.playCount) : '');
@@ -2313,8 +2509,10 @@ window.__ModuleLoader__.load({
2313
2509
  }, kw)))
2314
2510
  : null);
2315
2511
 
2512
+ // 扫码框以面板中心为基准居中(面板可拖拽),贴边时 clamp 不被裁掉。
2513
+ const qqLoginStyle = panelCenterStyle(panelRef, loginMode !== null, 170, 460);
2316
2514
  const loginModal = loginMode !== null ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
2317
- React.createElement('div', { className: 'dsh-music-picker qq-login' },
2515
+ React.createElement('div', { className: 'dsh-music-picker qq-login', style: qqLoginStyle },
2318
2516
  React.createElement('div', { className: 'dsh-music-picker-head' },
2319
2517
  React.createElement('span', { className: 'dsh-music-picker-title' }, 'QQ 音乐登录'),
2320
2518
  React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: closeLogin }, '✕')),
@@ -2334,9 +2532,23 @@ window.__ModuleLoader__.load({
2334
2532
  React.createElement('div', { className: 'dsh-music-qq-login-center' },
2335
2533
  React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('qq') }, 'QQ 登录'),
2336
2534
  React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('wx') }, '微信登录'),
2337
- React.createElement('p', { className: 'dsh-music-qq-login-warn' },
2338
- '免责声明:扫码登录走非官方接口,仅供个人试听。登录及播放存在账号风控、封禁与合规风险,请知悉并自行承担后果;如有顾虑请勿登录。')),
2339
- loginModal);
2535
+ React.createElement('div', { className: 'dsh-music-qq-login-warn' },
2536
+ React.createElement('div', { className: 'dsh-music-qq-login-warn-title' }, '使用声明(重要)'),
2537
+ React.createElement('div', { className: 'dsh-music-qq-login-warn-p' },
2538
+ '在线 QQ 音乐功能通过非官方接口访问 QQ 音乐资源,所播放/收藏的内容版权归版权方及 QQ 音乐平台所有。本功能仅供个人学习、技术研究、日常试听使用,严禁用于任何商业用途、公开传播、二次分发或盈利行为。使用本功能即表示您已知悉并同意:'),
2539
+ React.createElement('div', { className: 'dsh-music-qq-login-warn-item' },
2540
+ React.createElement('span', { className: 'dsh-music-qq-login-warn-num' }, '1'),
2541
+ React.createElement('span', null, '您应对自己的使用行为及其后果负责。')),
2542
+ React.createElement('div', { className: 'dsh-music-qq-login-warn-item' },
2543
+ React.createElement('span', { className: 'dsh-music-qq-login-warn-num' }, '2'),
2544
+ React.createElement('span', null, '因使用非官方接口登录/播放导致的账号风控、封禁、限流,以及可能引发的法律、版权纠纷,均由使用者自行承担。')),
2545
+ React.createElement('div', { className: 'dsh-music-qq-login-warn-item' },
2546
+ React.createElement('span', { className: 'dsh-music-qq-login-warn-num' }, '3'),
2547
+ React.createElement('span', null, '本项目作者不承担任何因此产生的直接或间接责任。')),
2548
+ React.createElement('div', { className: 'dsh-music-qq-login-warn-p' },
2549
+ '如您不同意以上条款,请勿使用本功能。'))),
2550
+ loginModal,
2551
+ qqJoinMenu);
2340
2552
  }
2341
2553
 
2342
2554
  // ---- 播放列表UI(第 2 层):返回 + 可滚动歌曲列表 ----
@@ -2355,7 +2567,8 @@ window.__ModuleLoader__.load({
2355
2567
  : (pl.songs && pl.songs.length
2356
2568
  ? React.createElement('div', null, pl.songs.map((song) => songRow(song, pl.songs, pl.name)))
2357
2569
  : React.createElement('div', { className: 'dsh-music-hint' }, '暂无歌曲。'))),
2358
- loginModal);
2570
+ loginModal,
2571
+ qqJoinMenu);
2359
2572
  }
2360
2573
 
2361
2574
  // ---- 主UI(第 1 层):顶部工具栏 + 4 个子tab,只滚动子tab内容区 ----
@@ -2364,6 +2577,17 @@ window.__ModuleLoader__.load({
2364
2577
  const hasSongs = results.length > 0;
2365
2578
  const hasPls = plResults.length > 0;
2366
2579
  let resultContent = null;
2580
+ // 搜索分页「加载更多」按钮:最近一页返回满页(==20)就认为还有下一页。
2581
+ const songMoreBtn = (searchLastLen >= QQLIST_PAGE)
2582
+ ? React.createElement('div', { className: 'dsh-music-qq-loadmore' },
2583
+ React.createElement('button', { className: 'dsh-music-qq-loadmore-btn', onClick: loadMoreSongs },
2584
+ searchingMore ? '加载中…' : '加载更多'))
2585
+ : null;
2586
+ const plMoreBtn = (plSearchLastLen >= QQLIST_PAGE)
2587
+ ? React.createElement('div', { className: 'dsh-music-qq-loadmore' },
2588
+ React.createElement('button', { className: 'dsh-music-qq-loadmore-btn', onClick: loadMorePls },
2589
+ plSearchingMore ? '加载中…' : '加载更多'))
2590
+ : null;
2367
2591
  if (searching) {
2368
2592
  resultContent = React.createElement('div', { className: 'dsh-music-hint' }, '搜索中…');
2369
2593
  } else if (qError || !(hasSongs || hasPls)) {
@@ -2375,12 +2599,12 @@ window.__ModuleLoader__.load({
2375
2599
  resultTabBtn('songs', '歌曲'),
2376
2600
  resultTabBtn('playlists', '相关歌单')),
2377
2601
  resultTab === 'playlists'
2378
- ? plResults.map(playRow)
2379
- : results.map((song) => songRow(song, results, '搜索结果')));
2602
+ ? React.createElement('div', null, plResults.map(playRow), plMoreBtn)
2603
+ : React.createElement('div', null, results.map((song) => songRow(song, results, '搜索结果')), songMoreBtn));
2380
2604
  } else if (hasSongs) {
2381
- resultContent = results.map((song) => songRow(song, results, '搜索结果'));
2605
+ resultContent = React.createElement('div', null, results.map((song) => songRow(song, results, '搜索结果')), songMoreBtn);
2382
2606
  } else {
2383
- resultContent = plResults.map(playRow);
2607
+ resultContent = React.createElement('div', null, plResults.map(playRow), plMoreBtn);
2384
2608
  }
2385
2609
  body = React.createElement('div', null, searchBox,
2386
2610
  searched ? React.createElement('div', null, resultContent) : null);
@@ -2500,6 +2724,7 @@ window.__ModuleLoader__.load({
2500
2724
  head,
2501
2725
  React.createElement('div', { className: 'dsh-music-qq-body' }, body),
2502
2726
  loginModal,
2727
+ qqJoinMenu,
2503
2728
  );
2504
2729
  }
2505
2730
 
@@ -2526,7 +2751,10 @@ window.__ModuleLoader__.load({
2526
2751
  // with only top+left and the CSS max-height:72vh still applying, a fixed
2527
2752
  // element whose CSS also sets the translate would collapse/clamp and shift
2528
2753
  // by half its own size while dragging.
2529
- const style = pos === null ? null : { left: pos.x, top: pos.y, width: pos.w, height: pos.h, maxHeight: 'none', transform: 'none' };
2754
+ // 面板常驻不卸载:关闭时仅用 display:none 隐藏(子树、QQ 面板状态全保留),
2755
+ // 重新打开时按播放类别重设 tab(见 togglePanel)并恢复显示。因此组件不会
2756
+ // 在关闭时 unmount,切 tab / 关面板重开都不会丢内部 useState 状态。
2757
+ const rootStyle = { ...(pos === null ? null : { left: pos.x, top: pos.y, width: pos.w, height: pos.h, maxHeight: 'none', transform: 'none' }), display: s.panelOpen ? '' : 'none' };
2530
2758
 
2531
2759
  const onHeadDown = (e) => {
2532
2760
  if (e.button !== undefined && e.button !== 0) return;
@@ -2615,9 +2843,10 @@ window.__ModuleLoader__.load({
2615
2843
  // The directory/file pickers are portaled to <body>, so a click inside
2616
2844
  // them is technically outside the panel's DOM — treat those as "inside"
2617
2845
  // so interacting with the picker never closes the panel underneath.
2846
+ // The「加入歌单」弹窗(含本地+QQ)同样 portal 到 body,点击其内菜单不应关面板。
2618
2847
  const onDown = (e) => {
2619
2848
  if (panelRef.current !== null && !panelRef.current.contains(e.target)
2620
- && !(e.target.closest && e.target.closest('.dsh-music-picker-overlay'))) {
2849
+ && !(e.target.closest && (e.target.closest('.dsh-music-picker-overlay') || e.target.closest('.dsh-music-add-pop')))) {
2621
2850
  set({ panelOpen: false });
2622
2851
  }
2623
2852
  };
@@ -2633,7 +2862,7 @@ window.__ModuleLoader__.load({
2633
2862
  }, [s.panelOpen, s.currentId]);
2634
2863
  // 面板关闭时清掉「加入歌单」弹层状态,避免重开面板时残留。
2635
2864
  useEffect(() => { if (!s.panelOpen) setAddMenu(null); }, [s.panelOpen]);
2636
- if (!s.panelOpen) return null;
2865
+ // 面板常驻不卸载:关闭时用根 div 的 display:none 隐藏,而非 return null
2637
2866
  const rows = s.tracks.map((t) => {
2638
2867
  const active = t.id === s.currentId;
2639
2868
  const playing = active && s.playing;
@@ -2693,20 +2922,29 @@ window.__ModuleLoader__.load({
2693
2922
  const isPlaylistView = s.subTab !== 'library';
2694
2923
  const plView = isPlaylistView ? playlistById(s.subTab) : null;
2695
2924
  const musicBody = plView
2696
- ? React.createElement(PlaylistDetail, { pl: plView })
2925
+ ? React.createElement(PlaylistDetail, { pl: plView, panelRef })
2697
2926
  : (rows.length > 0
2698
2927
  ? rows
2699
2928
  : React.createElement('div', { className: 'dsh-music-empty' }, '暂无音乐。点击上方“选择音乐目录”并选择目录后自动扫描。'));
2700
- const listBody = s.tab === 'music'
2701
- ? musicBody
2702
- : s.tab === 'qq'
2703
- ? React.createElement(QQOnlinePanel, null)
2704
- : (s.books.length > 0
2705
- ? bookRows
2706
- : (s.ttsConfigured
2707
- ? React.createElement('div', { className: 'dsh-music-empty' }, '未发现 .txt 小说文件。')
2708
- : React.createElement('div', { className: 'dsh-music-error' }, s.ttsReason || '未配置xiaomi提供方。')));
2709
- return React.createElement('div', { className: 'dsh-music-panel', ref: panelRef, style },
2929
+ // 三个 tab 的内容常驻渲染、非活动 tab display:none 隐藏:这样切 tab 时
2930
+ // 不会卸载任何面板(本地音乐 / QQ 面板 / AI 讲书),各自内部 useState 状态
2931
+ // 全部保留(例如 QQ 面板当前在「我的歌单/搜索/歌单详情」的哪个 UI)。
2932
+ const paneStyle = (key) => ({ display: s.tab === key ? '' : 'none' });
2933
+ const bookEmptyBody = s.books.length > 0
2934
+ ? bookRows
2935
+ : (s.ttsConfigured
2936
+ ? React.createElement('div', { className: 'dsh-music-empty' }, '未发现 .txt 小说文件。')
2937
+ : React.createElement('div', { className: 'dsh-music-error' }, s.ttsReason || '未配置xiaomi提供方。'));
2938
+ // 三个 pane 直接在 .dsh-music-list(flex column)里;仅 QQ pane
2939
+ // flex:1 + min-height:0 + overflow:hidden(见 .dsh-music-qq-pane),让
2940
+ // .dsh-music-qq 撑满 pane 高度、.dsh-music-qq-body 独立滚动:播放列表 UI
2941
+ // 只滚歌曲列表,head(返回按钮/歌单名)固定不滚。本地音乐/讲书 pane 保持
2942
+ // 普通块级,超高时仍由 .dsh-music-list 滚动。
2943
+ const listBody = React.createElement('div', { className: 'dsh-music-list-body' },
2944
+ React.createElement('div', { style: paneStyle('music') }, musicBody),
2945
+ React.createElement('div', { className: 'dsh-music-qq-pane', style: paneStyle('qq') }, React.createElement(QQOnlinePanel, { panelRef })),
2946
+ React.createElement('div', { style: paneStyle('book') }, bookEmptyBody));
2947
+ return React.createElement('div', { className: 'dsh-music-panel', ref: panelRef, style: rootStyle },
2710
2948
  React.createElement('div', {
2711
2949
  className: 'dsh-music-panel-head dsh-music-panel-drag',
2712
2950
  onPointerDown: onHeadDown, onPointerMove: onHeadMove, onPointerUp: onHeadUp,
@@ -2715,7 +2953,7 @@ window.__ModuleLoader__.load({
2715
2953
  React.createElement('span', { className: 'dsh-music-panel-title' }, '播放列表'),
2716
2954
  React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: () => set({ panelOpen: false }) }, '✕')),
2717
2955
  React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '本地音乐'), tabBtn('qq', 'QQ音乐'), tabBtn('book', 'AI讲书')),
2718
- s.tab === 'qq' ? null : React.createElement(DirectorySetting, null),
2956
+ s.tab === 'qq' ? null : React.createElement(DirectorySetting, { panelRef }),
2719
2957
  s.tab === 'music' ? musicSubTabs : null,
2720
2958
  // While a novel is playing, keep music-only errors/scanning out of the
2721
2959
  // panel (novel status shows on the playback bar instead).
@@ -2728,11 +2966,71 @@ window.__ModuleLoader__.load({
2728
2966
  track: addMenu.track, anchor: { x: addMenu.x, y: addMenu.y },
2729
2967
  onClose: () => setAddMenu(null),
2730
2968
  }) : null,
2969
+ s.prompt ? React.createElement(PromptModal, { key: s.prompt.id, panelRef }) : null,
2970
+ s.confirm ? React.createElement(ConfirmModal, { key: s.confirm.title, panelRef }) : null,
2731
2971
  );
2732
2972
  }
2973
+ // 自定义输入弹窗(替代浏览器 prompt):新建/重命名歌单等需要名称输入的场景。
2974
+ // 以面板中心为基准居中;回车=确定、Esc/点遮罩/关闭=取消。key 由父级传 id,
2975
+ // 保证每次 openPrompt 打开时重新挂载、初始输入值正确。
2976
+ function PromptModal({ panelRef }) {
2977
+ const s = useStore();
2978
+ const p = s.prompt;
2979
+ if (p === null) return null;
2980
+ const [value, setValue] = useState(p.initial || '');
2981
+ const inputRef = useRef(null);
2982
+ useEffect(() => { if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, []);
2983
+ const submit = () => {
2984
+ const v = value.trim();
2985
+ if (v === '') return;
2986
+ closePrompt();
2987
+ if (typeof p.onOk === 'function') p.onOk(v);
2988
+ };
2989
+ const cancel = () => closePrompt();
2990
+ const onKeyDown = (e) => {
2991
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
2992
+ else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
2993
+ };
2994
+ return portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
2995
+ React.createElement('div', { className: 'dsh-music-picker prompt', style: panelCenterStyle(panelRef, true, 150, 160) },
2996
+ React.createElement('div', { className: 'dsh-music-picker-head' },
2997
+ React.createElement('span', { className: 'dsh-music-picker-title' }, p.title),
2998
+ React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: cancel }, '✕')),
2999
+ React.createElement('input', {
3000
+ className: 'dsh-music-prompt-input', ref: inputRef, value,
3001
+ placeholder: '请输入名称', onChange: (e) => setValue(e.target.value), onKeyDown,
3002
+ 'aria-label': p.title,
3003
+ }),
3004
+ React.createElement('div', { className: 'dsh-music-picker-foot' },
3005
+ React.createElement('button', { className: 'dsh-music-settings-btn', onClick: submit }, '确定'),
3006
+ React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: cancel }, '取消')),
3007
+ )));
3008
+ }
3009
+ // 自定义确认弹窗(替代浏览器 confirm):删除/清空歌单等破坏性操作前的确认。
3010
+ // 无输入框,仅标题 + 提示消息 + 确定/取消;以面板中心为基准居中。
3011
+ function ConfirmModal({ panelRef }) {
3012
+ const s = useStore();
3013
+ const c = s.confirm;
3014
+ if (c === null) return null;
3015
+ const ok = () => { closeConfirm(); if (typeof c.onOk === 'function') c.onOk(); };
3016
+ const onKeyDown = (e) => {
3017
+ if (e.key === 'Enter') { e.preventDefault(); ok(); }
3018
+ else if (e.key === 'Escape') { e.preventDefault(); closeConfirm(); }
3019
+ };
3020
+ return portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
3021
+ React.createElement('div', { className: 'dsh-music-picker confirm', style: panelCenterStyle(panelRef, true, 150, 280), onKeyDown },
3022
+ React.createElement('div', { className: 'dsh-music-picker-head' },
3023
+ React.createElement('span', { className: 'dsh-music-picker-title' }, c.title),
3024
+ React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: closeConfirm }, '✕')),
3025
+ c.message ? React.createElement('p', { className: 'dsh-music-hint' }, c.message) : null,
3026
+ React.createElement('div', { className: 'dsh-music-picker-foot' },
3027
+ React.createElement('button', { className: 'dsh-music-settings-btn' + (c.danger ? ' danger' : ''), onClick: ok }, c.okText),
3028
+ React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: closeConfirm }, '取消')),
3029
+ )));
3030
+ }
2733
3031
  // Directory setting block, embedded in the player panel (the former
2734
3032
  // 设置 → 音乐播放器 page moved in-panel so all library config lives in one place).
2735
- function DirectorySetting() {
3033
+ function DirectorySetting({ panelRef }) {
2736
3034
  const s = useStore();
2737
3035
  const [pickerOpen, setPickerOpen] = useState(false);
2738
3036
  const [dirs, setDirs] = useState([]);
@@ -2754,9 +3052,10 @@ window.__ModuleLoader__.load({
2754
3052
  React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle)),
2755
3053
  React.createElement('p', { className: 'dsh-music-hint' }, hint),
2756
3054
  pickerOpen ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
2757
- React.createElement('div', { className: 'dsh-music-picker' },
3055
+ React.createElement('div', { className: 'dsh-music-picker', style: panelCenterStyle(panelRef, pickerOpen, 320, Math.round(window.innerHeight * 0.72)) },
2758
3056
  React.createElement('div', { className: 'dsh-music-picker-head' },
2759
- React.createElement('span', { className: 'dsh-music-picker-title' }, pickerTitle)),
3057
+ React.createElement('span', { className: 'dsh-music-picker-title' }, pickerTitle),
3058
+ React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: () => setPickerOpen(false) }, '✕')),
2760
3059
  React.createElement('div', { className: 'dsh-music-picker-cur', title: curPath },
2761
3060
  renderCrumbs(curCrumbs, curPath, curName, browse)),
2762
3061
  React.createElement('div', { className: 'dsh-music-picker-list' },
@@ -2828,20 +3127,19 @@ window.__ModuleLoader__.load({
2828
3127
  const list = store.playlists || [];
2829
3128
  const addTo = (id) => { apiPlaylistAdd(id, [track.path], () => onClose()); };
2830
3129
  const addNew = () => {
2831
- const name = window.prompt('新建歌单名称', '');
2832
- if (name === null) return;
2833
- const trimmed = name.trim();
2834
- if (trimmed === '') return;
2835
- fetch('/dsh-music/playlist', {
2836
- method: 'POST', cache: 'no-store',
2837
- headers: { 'content-type': 'application/json' },
2838
- body: JSON.stringify({ name: trimmed }),
2839
- }).then((r) => r.json()).then((r) => {
2840
- if (r && r.playlist) {
2841
- set({ playlists: [...(store.playlists || []), r.playlist] });
2842
- apiPlaylistAdd(r.playlist.id, [track.path], () => onClose());
2843
- }
2844
- }).catch(() => onClose());
3130
+ openPrompt('新建歌单名称', '', (trimmed) => {
3131
+ if (!trimmed) return;
3132
+ fetch('/dsh-music/playlist', {
3133
+ method: 'POST', cache: 'no-store',
3134
+ headers: { 'content-type': 'application/json' },
3135
+ body: JSON.stringify({ name: trimmed }),
3136
+ }).then((r) => r.json()).then((r) => {
3137
+ if (r && r.playlist) {
3138
+ set({ playlists: [...(store.playlists || []), r.playlist] });
3139
+ apiPlaylistAdd(r.playlist.id, [track.path], () => onClose());
3140
+ }
3141
+ }).catch(() => onClose());
3142
+ });
2845
3143
  };
2846
3144
  return React.createElement('div', { className: 'dsh-music-add-pop', ref, style },
2847
3145
  list.length > 0 ? list.map((p) => React.createElement('button', {
@@ -2854,7 +3152,7 @@ window.__ModuleLoader__.load({
2854
3152
  );
2855
3153
  }
2856
3154
  // 歌单详情:添加歌曲 + 重命名/删除 + 歌曲列表(移除/上移/下移)。
2857
- function PlaylistDetail({ pl }) {
3155
+ function PlaylistDetail({ pl, panelRef }) {
2858
3156
  const [pickerOpen, setPickerOpen] = useState(false);
2859
3157
  const rows = (pl.tracks || []).map((t, idx) => {
2860
3158
  const active = t.id === store.currentId;
@@ -2884,11 +3182,11 @@ window.__ModuleLoader__.load({
2884
3182
  pl.missing > 0 ? React.createElement('span', { className: 'dsh-music-playlist-missing', title: '部分歌曲文件已被移动或删除' }, pl.missing + ' 首已失效') : null,
2885
3183
  ),
2886
3184
  rows.length > 0 ? rows : React.createElement('div', { className: 'dsh-music-empty dsh-music-playlist-empty' }, '歌单为空,点击「添加歌曲」从本地文件选择音乐。'),
2887
- pickerOpen ? React.createElement(FilePicker, { pl, onClose: () => setPickerOpen(false) }) : null,
3185
+ pickerOpen ? React.createElement(FilePicker, { pl, panelRef, onClose: () => setPickerOpen(false) }) : null,
2888
3186
  );
2889
3187
  }
2890
3188
  // 文件系统多选器:浏览目录 + 勾选音频文件,用于歌单「添加歌曲」。
2891
- function FilePicker({ pl, onClose }) {
3189
+ function FilePicker({ pl, panelRef, onClose }) {
2892
3190
  const [cur, setCur] = useState({ path: '', name: '', dirs: [], files: [], crumbs: [] });
2893
3191
  const [sel, setSel] = useState(new Set());
2894
3192
  const [err, setErr] = useState(null);
@@ -2915,10 +3213,10 @@ window.__ModuleLoader__.load({
2915
3213
  apiPlaylistAdd(pl.id, paths, () => onClose());
2916
3214
  };
2917
3215
  return portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
2918
- React.createElement('div', { className: 'dsh-music-picker' },
3216
+ React.createElement('div', { className: 'dsh-music-picker', style: panelCenterStyle(panelRef, true, 320, Math.round(window.innerHeight * 0.72)) },
2919
3217
  React.createElement('div', { className: 'dsh-music-picker-head' },
2920
3218
  React.createElement('span', { className: 'dsh-music-picker-title' }, '添加歌曲到「' + pl.name + '」'),
2921
- ),
3219
+ React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: onClose }, '✕')),
2922
3220
  React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path },
2923
3221
  renderCrumbs(cur.crumbs, cur.path, cur.name, browse)),
2924
3222
  React.createElement('div', { className: 'dsh-music-picker-list' },
@@ -3140,6 +3438,12 @@ window.__ModuleLoader__.load({
3140
3438
  '.dsh-music-mode-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsh-music-accent, #2f9e6e); }\n' +
3141
3439
  '.dsh-music-mode-item.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
3142
3440
  '.dsh-music-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; min-height: 60px; max-height: 40vh; }\n' +
3441
+ // pane 层的包裹 div:改成 flex 列容器(否则它是块级元素,QQ pane 的 flex:1
3442
+ // 不生效、没有确定高度,导致 .dsh-music-qq-body 不滚动、整棵被 .dsh-music-list
3443
+ // 滚走 → 滚动条会盖住固定的 head)。设为 flex:1+min-height:0 撑满列表区高度:
3444
+ // QQ pane 内的 .dsh-music-qq-body 成为唯一滚动容器,滚动条只出现在 head 下方。
3445
+ // 本地音乐/讲书 pane 因 min-height:auto 不会被压缩、仍超高溢出、由列表滚动,行为不变。
3446
+ '.dsh-music-list-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }\n' +
3143
3447
  '.dsh-music-track { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 6px 8px; border: none; background: transparent; border-radius: 6px; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 12px; }\n' +
3144
3448
  '.dsh-music-track:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
3145
3449
  '.dsh-music-track.active { color: var(--dsh-music-accent, #2f9e6e); }\n' +
@@ -3169,10 +3473,11 @@ window.__ModuleLoader__.load({
3169
3473
  '.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' +
3170
3474
  '.dsh-music-settings-btn { padding: 6px 12px; border-radius: 8px; border: none; background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); cursor: pointer; font-size: 13px; white-space: nowrap; }\n' +
3171
3475
  '.dsh-music-settings-btn.ghost { background: transparent; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
3476
+ '.dsh-music-settings-btn.danger { background: #c9352c; color: #fff; }\n' +
3172
3477
  '.dsh-music-picker-overlay { position: fixed; inset: 0; z-index: 2000; display: flex; overflow: auto; padding: 16px; background: rgba(0,0,0,0.45); }\n' +
3173
3478
  '.dsh-music-picker { box-sizing: border-box; width: 88%; max-width: 640px; max-height: 100%; margin: auto; 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; color: var(--dsw-alias-label-primary, #e6e6e6); }\n' +
3174
3479
  '.dsh-music-picker-head { display: flex; align-items: center; flex: none; }\n' +
3175
- '.dsh-music-picker-title { font-weight: 600; }\n' +
3480
+ '.dsh-music-picker-title { font-weight: 600; margin-right: auto; }\n' +
3176
3481
  '.dsh-music-picker-cur { flex: none; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); white-space: nowrap; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px; }\n' +
3177
3482
  '.dsh-music-picker-cur::-webkit-scrollbar { height: 4px; }\n' +
3178
3483
  '.dsh-music-picker-cur::-webkit-scrollbar-thumb { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.14)); border-radius: 4px; }\n' +
@@ -3186,7 +3491,12 @@ window.__ModuleLoader__.load({
3186
3491
  '.dsh-music-picker-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
3187
3492
  // 文件条目:仅作展示,不可点击(无 hover 高亮,光标为默认)。
3188
3493
  '.dsh-music-picker-item.file { color: var(--dsw-alias-label-secondary, #8a8f98); cursor: default; }\n' +
3189
- '.dsh-music-picker-foot { display: flex; gap: 8px; justify-content: flex-end; }\n' +
3494
+ '.dsh-music-picker-foot { display: flex; gap: 8px; justify-content: flex-end; flex: none; }\n' +
3495
+ // 自定义输入弹窗(新建/重命名歌单)的输入框。
3496
+ '.dsh-music-prompt-input { box-sizing: border-box; width: 100%; padding: 8px 10px; font-size: 13px; color: var(--dsw-alias-label-primary, #e6e6e6); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 8px; outline: none; }\n' +
3497
+ '.dsh-music-prompt-input:focus { border-color: var(--dsh-music-accent, #2f9e6e); }\n' +
3498
+ // 新建/重命名/删除/清空弹窗较窄,不用居中列表那种 640px 宽。
3499
+ '.dsh-music-picker.prompt, .dsh-music-picker.confirm { width: 300px; max-width: 90vw; }\n' +
3190
3500
  '.dsh-music-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
3191
3501
  // ---- 在线 QQ 音乐 ----
3192
3502
  '.dsh-music-qq { display: flex; flex-direction: column; gap: 10px; }\n' +
@@ -3221,8 +3531,14 @@ window.__ModuleLoader__.load({
3221
3531
  '.dsh-music-qq-topname { font-weight: 600; }\n' +
3222
3532
  '.dsh-music-qq-topmeta { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
3223
3533
  '.dsh-music-qq-detail-head { display: flex; gap: 8px; align-items: center; margin: 6px 0; }\n' +
3224
- '.dsh-music-qq { display: flex; flex-direction: column; flex: 1; min-height: 0; }\n' +
3225
- '.dsh-music-qq-head { flex: none; z-index: 3; background: var(--dsw-alias-bg-overlay, #1e1f22); padding-bottom: 4px; }\n' +
3534
+ '.dsh-music-qq { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }\n' +
3535
+ // QQ 面板所在 pane 不设 overflow:hidden(否则它自身会成为一个滚动容器,把
3536
+ // sticky 的 head 困在内部、无法吸附到真正滚动的 .dsh-music-list)。pane 保持
3537
+ // 普通流式布局,滚动交给 head 下方的 .dsh-music-list / .dsh-music-qq-body。
3538
+ '.dsh-music-qq-pane { flex: 1; min-height: 0; overflow: visible; display: flex; flex-direction: column; }\n' +
3539
+ // head 用 sticky 固定在滚动区顶部:无论实际滚动容器是 .dsh-music-list
3540
+ // 还是 .dsh-music-qq-body,返回按钮行 / 子tab 行都不会被列表滚走(内容在其下方滑动)。
3541
+ '.dsh-music-qq-head { flex: none; position: sticky; top: 0; z-index: 3; background: var(--dsw-alias-bg-overlay, #1e1f22); padding-bottom: 4px; }\n' +
3226
3542
  '.dsh-music-qq-body { flex: 1; overflow-y: auto; min-height: 0; }\n' +
3227
3543
  '.dsh-music-qq-section { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); margin: 10px 0 4px; font-weight: 600; }\n' +
3228
3544
  '.dsh-music-qq-now { display: flex; align-items: center; gap: 4px; 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)); font-size: 12px; color: var(--dsw-alias-label-primary, #e6e6e6); }\n' +
@@ -3233,7 +3549,12 @@ window.__ModuleLoader__.load({
3233
3549
  '.dsh-music-qq-login { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; }\n' +
3234
3550
  '.dsh-music-qq-login-center { display: flex; flex-direction: column; gap: 12px; align-items: center; max-width: 320px; }\n' +
3235
3551
  '.dsh-music-qq-login-btn { width: 200px; padding: 10px 16px; font-size: 15px; }\n' +
3236
- '.dsh-music-qq-login-warn { font-size: 12px; color: var(--dsw-alias-state-warn-primary, #d9a441); text-align: center; line-height: 1.6; margin-top: 4px; }\n' +
3552
+ // 免责声明:居中块内的左对齐编号列表,阅读更清晰。
3553
+ '.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' +
3554
+ '.dsh-music-qq-login-warn-title { font-weight: 600; margin-bottom: 2px; }\n' +
3555
+ '.dsh-music-qq-login-warn-p { margin: 0; }\n' +
3556
+ '.dsh-music-qq-login-warn-item { display: flex; gap: 6px; align-items: flex-start; }\n' +
3557
+ '.dsh-music-qq-login-warn-num { flex: none; }\n' +
3237
3558
  // 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。
3238
3559
  '.dsh-music-bar.book .dsh-music-bar-name { max-width: 24%; }\n' +
3239
3560
  '.dsh-music-bar-section { margin-left: 8px; flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 11px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); border-radius: 6px; padding: 0 6px; line-height: 16px; }\n' +
package/lib/index.js CHANGED
@@ -1592,11 +1592,12 @@ export function apply(ctx) {
1592
1592
  if (pathname === '/dsh-music/qq/search' && req.method === 'GET') {
1593
1593
  const w = (url.searchParams.get('w') || '').trim()
1594
1594
  if (w === '') { writeJson(res, { ok: false, error: 'missing query' }, 400); return }
1595
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1)
1595
1596
  await loadQQCookie()
1596
1597
  try {
1597
1598
  const isVip = await refreshQQVip()
1598
- const results = await QQ.search(w, qq.cookie)
1599
- writeJson(res, { ok: true, isVip, results })
1599
+ const s = await QQ.search(w, qq.cookie, page)
1600
+ writeJson(res, { ok: true, isVip, results: s.results, total: s.total, page: s.page })
1600
1601
  } catch (err) {
1601
1602
  writeJson(res, { ok: false, error: String((err && err.message) || err) }, 502)
1602
1603
  }
@@ -1643,8 +1644,12 @@ export function apply(ctx) {
1643
1644
  if (pathname === '/dsh-music/qq/playlist-search' && req.method === 'GET') {
1644
1645
  const w = (url.searchParams.get('w') || '').trim()
1645
1646
  if (w === '') { writeJson(res, { ok: false, error: 'missing query' }, 400); return }
1647
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1)
1646
1648
  await loadQQCookie()
1647
- try { writeJson(res, { ok: true, playlists: await QQ.searchPlaylist(w, qq.cookie) }) }
1649
+ try {
1650
+ const s = await QQ.searchPlaylist(w, qq.cookie, page)
1651
+ writeJson(res, { ok: true, playlists: s.results, total: s.total, page: s.page })
1652
+ }
1648
1653
  catch (err) { writeJson(res, { ok: false, error: String((err && err.message) || err) }, 502) }
1649
1654
  return
1650
1655
  }
@@ -1680,6 +1685,31 @@ export function apply(ctx) {
1680
1685
  } catch (err) { writeJson(res, { ok: false, error: String((err && err.message) || err) }, 502) }
1681
1686
  return
1682
1687
  }
1688
+ // 把在线歌曲加入某个自建歌单(dirId/tid 取自我的歌单)。
1689
+ if (pathname === '/dsh-music/qq/playlist-add' && req.method === 'POST') {
1690
+ const body = await readBody(req)
1691
+ const song = (body && body.song) || {}
1692
+ const dirId = Number((body && body.dirId)) || 0
1693
+ const tid = Number((body && body.tid)) || 0
1694
+ await loadQQCookie()
1695
+ try {
1696
+ const ok = await QQ.addSongToPlaylist(song, dirId, tid, qq.cookie)
1697
+ writeJson(res, { ok })
1698
+ } catch (err) { writeJson(res, { ok: false, error: String((err && err.message) || err) }, 502) }
1699
+ return
1700
+ }
1701
+ // 创建自建歌单(DirCreate),返回新歌单 id/name。
1702
+ if (pathname === '/dsh-music/qq/playlist-create' && req.method === 'POST') {
1703
+ const body = await readBody(req)
1704
+ const name = String((body && body.name) || '').trim()
1705
+ if (name === '') { writeJson(res, { ok: false, error: '歌单名不能为空' }, 400); return }
1706
+ await loadQQCookie()
1707
+ try {
1708
+ const created = await QQ.createPlaylist(name, qq.cookie)
1709
+ writeJson(res, { ok: true, playlist: created })
1710
+ } catch (err) { writeJson(res, { ok: false, error: String((err && err.message) || err) }, 502) }
1711
+ return
1712
+ }
1683
1713
  // 获取「我喜欢」已收藏歌曲的 songid + songmid 列表(供播放时判断当前曲目是否已收藏)。
1684
1714
  if (pathname === '/dsh-music/qq/liked' && req.method === 'GET') {
1685
1715
  await loadQQCookie()
@@ -1900,7 +1930,7 @@ export function apply(ctx) {
1900
1930
  if (action === 'play' && source === 'web' && query0 !== '') {
1901
1931
  try {
1902
1932
  const isVip = await refreshQQVip()
1903
- const results = await QQ.search(query0, qq.cookie)
1933
+ const { results } = await QQ.search(query0, qq.cookie)
1904
1934
  if (results.length === 0) {
1905
1935
  return { action, played: false, track: '', matches: 0, count: 0, notice: '在线 QQ 音乐未找到「' + query0 + '」' }
1906
1936
  }
@@ -1967,7 +1997,7 @@ export function apply(ctx) {
1967
1997
  // ---- light prompt hint so the agent knows it can play local music/novels ----
1968
1998
  ctx.systemPrompt.section({
1969
1999
  name: 'tool:music-player', order: 116,
1970
- text: '本机已挂载本地音乐播放器与 AI 讲书:可用 music_play 工具按关键词播放 ~/Music(或设置的目录)里的音乐,或按歌单名播放自建歌单(playlist 参数),或按小说名播放本地 .txt 小说(AI 讲书,需配置xiaomi提供方);也可传 source=web 播放在线 QQ 音乐(需在播放面板登录,登录后可播 VIP/高音质);并支持 action 暂停/继续/停止/下一首·下一章/上一首·上一章。',
2000
+ text: '本机已挂载 DSH音乐播放器 与 AI 讲书:可用 music_play 工具按关键词播放 ~/Music(或设置的目录)里的音乐,或按歌单名播放自建歌单(playlist 参数),或按小说名播放本地 .txt 小说(AI 讲书,需配置xiaomi提供方);也可传 source=web 播放在线 QQ 音乐(需在播放面板登录,登录后可播 VIP/高音质);并支持 action 暂停/继续/停止/下一首·下一章/上一首·上一章。',
1971
2001
  })
1972
2002
 
1973
2003
  void loadQQCookie()
package/lib/qq.js CHANGED
@@ -320,14 +320,16 @@ function parseWXUUID(raw) {
320
320
  // =====================================================================
321
321
  // 搜索(legacy,匿名可用)与登录态取链(UrlGetVkey,可反 VIP/高音质)
322
322
  // =====================================================================
323
- // 歌曲搜索:返回歌曲数组(兼容既有调用方:results[0].songmid / results.length)。
324
- export async function search(keyword, cookie = '') {
325
- const url = `https://c.y.qq.com/soso/fcgi-bin/client_search_cp?${new URLSearchParams({ w: keyword, format: 'json', p: '1', n: '20', t: '0', cr: '1' }).toString()}`
323
+ // 歌曲搜索:支持分页(page 从 1 起,每页 n=20)。返回 { results, total, page },
324
+ // total QQ 接口返回的总条数(totalnum),取不到时回退为当前页条数。
325
+ export async function search(keyword, cookie = '', page = 1) {
326
+ const p = Math.max(1, parseInt(page, 10) || 1)
327
+ const url = `https://c.y.qq.com/soso/fcgi-bin/client_search_cp?${new URLSearchParams({ w: keyword, format: 'json', p: String(p), n: '20', t: '0', cr: '1' }).toString()}`
326
328
  const headers = { 'User-Agent': UA, 'Referer': 'https://y.qq.com/' }
327
329
  if (cookie) headers['Cookie'] = cookie
328
330
  const res = await fetchWithTimeout(url, { headers })
329
331
  const j = await res.json()
330
- return (j.data?.song?.list || []).map(s => ({
332
+ const results = (j.data?.song?.list || []).map(s => ({
331
333
  id: s.songmid, songmid: s.songmid, title: dec(s.songname),
332
334
  songid: s.songid, songtype: s.songtype || 0,
333
335
  artists: (s.singer || []).map(x => dec(x.name)),
@@ -335,6 +337,8 @@ export async function search(keyword, cookie = '') {
335
337
  payplay: s.pay?.payplay, pay: s.pay,
336
338
  source: 'qq',
337
339
  }))
340
+ const total = Number(j.data?.song?.totalnum) || results.length
341
+ return { results, total, page: p }
338
342
  }
339
343
 
340
344
  export async function detectVip(cookie) {
@@ -437,19 +441,23 @@ export async function getCategoryPlaylists(categoryID = '10000000', page = 1, li
437
441
  }))
438
442
  }
439
443
 
440
- export async function searchPlaylist(keyword, cookie = '') {
441
- const p = new URLSearchParams({ query: keyword, page_no: '0', num_per_page: '20', format: 'json', remoteplace: 'txt.yqq.playlist', flag_qc: '0' })
442
- const apiURL = `http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?${p.toString()}`
444
+ export async function searchPlaylist(keyword, cookie = '', page = 1) {
445
+ const p = Math.max(1, parseInt(page, 10) || 1)
446
+ const param = new URLSearchParams({ query: keyword, page_no: String(p - 1), num_per_page: '20', format: 'json', remoteplace: 'txt.yqq.playlist', flag_qc: '0' })
447
+ const apiURL = `http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?${param.toString()}`
443
448
  const res = await fetchWithTimeout(apiURL, { headers: { 'User-Agent': UA, 'Referer': 'https://y.qq.com/portal/search.html', ...(cookie ? { Cookie: cookie } : {}) } })
444
449
  let text = await res.text()
445
450
  const i = text.indexOf('('); const e = text.lastIndexOf(')')
446
451
  if (i >= 0 && e > i) text = text.slice(i + 1, e)
447
452
  const j = JSON.parse(text)
448
- return (j.data && j.data.list || []).filter((x) => x.dissid && x.dissname).map((x) => ({
453
+ const results = (j.data && j.data.list || []).filter((x) => x.dissid && x.dissname).map((x) => ({
449
454
  id: x.dissid, name: dec(x.dissname), cover: httpsCover(x.imgurl),
450
455
  trackCount: x.song_count || 0, playCount: x.listennum || 0, creator: dec((x.creator && x.creator.name) || ''),
451
456
  source: 'qq', link: plLink(x.dissid),
452
457
  }))
458
+ const d = j.data || {}
459
+ const total = Number(d.totalnum ?? d.total_num) || results.length
460
+ return { results, total, page: p }
453
461
  }
454
462
 
455
463
  export async function getPlaylistSongs(id, cookie = '') {
@@ -520,7 +528,8 @@ export async function getMyPlaylists(cookie = '') {
520
528
  if (Number(res.code) !== 0) throw new Error('获取我的歌单失败:' + ((res.message || res.msg) || ('code ' + res.code)) + '|raw=' + rawSnippet(res))
521
529
  const list = res.data?.v_playlist || []
522
530
  return list.filter((x) => x.tid && x.dirName).map((x) => ({
523
- id: String(x.tid), name: dec(x.dirName), cover: httpsCover(x.picUrl),
531
+ id: String(x.tid), dirId: Number(x.dirId) || Number(x.tid) || 0, tid: Number(x.tid) || 0,
532
+ name: dec(x.dirName), cover: httpsCover(x.picUrl),
524
533
  trackCount: x.songNum || 0, playCount: 0, creator: dec(x.nick || ''), source: 'qq', link: plLink(x.tid),
525
534
  }))
526
535
  }
@@ -549,40 +558,39 @@ function qqGtk(musickey) {
549
558
  return h & 0x7fffffff
550
559
  }
551
560
 
552
- // 把歌曲加入/移出「我喜欢」歌单(dirId 固定 201)。song 需含 songid(数值)、songtype。
553
- // 参考 qq-music-api music.musicasset.PlaylistDetailWrite / AddSonglist(DelSonglist)。
554
- async function qqFavWrite(song, cookie, method) {
555
- if (!cookie || !song) throw new Error('未登录,无法收藏')
561
+ // 通用的「歌单写操作」:调用指定 module 的写接口。
562
+ // method AddSonglist / DelSonglist(PlaylistDetailWrite)或 AddPlaylist / DelPlaylist(PlaylistBaseWrite)。
563
+ // 成功返回 res.data(可能为 {});失败抛错。写操作需要登录态 + g_tk(CSRF)
564
+ async function playlistDetailWrite(method, param, cookie, module = 'music.musicasset.PlaylistDetailWrite') {
565
+ if (!cookie) throw new Error('未登录,无法操作歌单')
556
566
  const { comm, musickey } = buildAuthedComm(cookie)
557
- // 写操作需要 g_tk(CSRF)。
558
567
  const g_tk = qqGtk(musickey)
559
568
  if (g_tk) { comm.g_tk = g_tk; comm.g_tk_new_20200303 = g_tk }
560
- const songId = Number(song.songid) || 0
561
- if (!songId) throw new Error('缺少歌曲 songid,无法收藏')
562
- const body = {
563
- comm,
564
- req_0: {
565
- module: 'music.musicasset.PlaylistDetailWrite',
566
- method,
567
- param: {
568
- dirId: 201,
569
- tid: 0,
570
- bFmtUtf8: true,
571
- v_songInfo: [{ songId, songType: Number(song.songtype) || 0 }],
572
- },
573
- },
574
- }
569
+ const body = { comm, req_0: { module, method, param } }
575
570
  const j = await postMusicu(body, cookie)
576
- const res = j.req_0 || j['music.musicasset.PlaylistDetailWrite']
571
+ const res = j.req_0 || j[module]
577
572
  const rawSnippet = (x) => { try { const s = JSON.stringify(x); return s ? s.slice(0, 300) : String(x) } catch { return String(x) } }
578
573
  const retCode = res?.data?.retCode
579
574
  if (retCode !== undefined) {
580
- if (Number(retCode) === 0) return true
581
- throw new Error('收藏接口失败(retCode=' + retCode + ',raw=' + rawSnippet(j) + ')')
575
+ if (Number(retCode) === 0) return res.data || {}
576
+ throw new Error('歌单操作失败(retCode=' + retCode + ',raw=' + rawSnippet(j) + ')')
582
577
  }
583
578
  const code = res ? Number(res.code) : (j.code !== undefined ? Number(j.code) : NaN)
584
- if (code === 0) return true
585
- throw new Error('收藏接口失败(code=' + code + 'raw=' + rawSnippet(j) + ')')
579
+ if (code === 0) return (res && res.data) || {}
580
+ throw new Error('歌单操作失败(code=' + code + '|method=' + method + '|param=' + rawSnippet(param) + '|raw=' + rawSnippet(j) + ')')
581
+ }
582
+
583
+ // 收藏/取消收藏歌曲到「我喜欢」(dirId 固定 201)。song 需含 songid(数值)、songtype。
584
+ async function qqFavWrite(song, cookie, method) {
585
+ const songId = Number(song.songid) || 0
586
+ if (!songId) throw new Error('缺少歌曲 songid,无法收藏')
587
+ await playlistDetailWrite(method, {
588
+ dirId: 201,
589
+ tid: 0,
590
+ bFmtUtf8: true,
591
+ v_songInfo: [{ songId, songType: Number(song.songtype) || 0 }],
592
+ }, cookie)
593
+ return true
586
594
  }
587
595
 
588
596
  export async function addQQFav(song, cookie = '') {
@@ -592,6 +600,30 @@ export async function removeQQFav(song, cookie = '') {
592
600
  return qqFavWrite(song, cookie, 'DelSonglist')
593
601
  }
594
602
 
603
+ // 把歌曲加入某个自建歌单(dirId = 该歌单的目录 id,tid = 歌单 tid,可为 0)。
604
+ export async function addSongToPlaylist(song, dirId, tid = 0, cookie = '') {
605
+ const songId = Number(song.songid) || 0
606
+ if (!songId) throw new Error('缺少歌曲 songid,无法加入歌单')
607
+ await playlistDetailWrite('AddSonglist', {
608
+ dirId: Number(dirId) || 0,
609
+ tid: Number(tid) || 0,
610
+ bFmtUtf8: true,
611
+ v_songInfo: [{ songId, songType: Number(song.songtype) || 0 }],
612
+ }, cookie)
613
+ return true
614
+ }
615
+
616
+ // 创建自建歌单(AddPlaylist / PlaylistBaseWrite),返回 { id, name }。
617
+ // AddPlaylist 响应把新歌单信息嵌套在 data.result 下(dirId/dirName/tid)。
618
+ export async function createPlaylist(name, cookie = '') {
619
+ const dirName = String(name || '').trim()
620
+ if (dirName === '') throw new Error('歌单名不能为空')
621
+ const data = await playlistDetailWrite('AddPlaylist', { dirName }, cookie, 'music.musicasset.PlaylistBaseWrite')
622
+ const result = (data && data.result) || data || {}
623
+ const id = Number(result.dirId) || Number(result.dirid) || Number(result.tid) || Number(result.id) || Number(result.content_id) || 0
624
+ return { id, name: dec(result.dirName || result.dirname || result.name || dirName) }
625
+ }
626
+
595
627
  // 获取「我喜欢」(dirid=201)里已收藏歌曲的 songid + songmid 集合,用于播放时判断当前曲目是否已收藏。
596
628
  // 返回 { ids: number[], mids: string[] }——songmid 是稳定字符串标识,避免 songid 大整数/类型不一致导致匹配失败。
597
629
  export async function getQQFavIds(cookie = '') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",