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 +2 -2
- package/lib/client.js +436 -115
- package/lib/index.js +35 -5
- package/lib/qq.js +66 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
DeepSeek Harness 本地音乐/小说播放插件。
|
|
6
6
|
|
|
7
|
-
写代码写累了、想摸鱼又不想切窗口?这个插件就是你的**摸鱼神器**——直接在 DeepSeek Harness
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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 || []);
|
|
2183
|
-
|
|
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('
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
React.createElement('span', { className: 'dsh-music-track-
|
|
2255
|
-
|
|
2256
|
-
|
|
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('
|
|
2338
|
-
'
|
|
2339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
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,
|
|
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
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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: '
|
|
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
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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 =
|
|
442
|
-
const
|
|
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
|
-
|
|
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),
|
|
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
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
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[
|
|
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
|
|
581
|
-
throw new Error('
|
|
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
|
|
585
|
-
throw new Error('
|
|
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.
|
|
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",
|