dsh-music-player 0.5.2 → 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 +420 -114
- 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);
|
|
@@ -1813,7 +1863,7 @@ window.__ModuleLoader__.load({
|
|
|
1813
1863
|
React.createElement('div', { className: 'dsh-music-bar' + (isBook ? ' book' : '') },
|
|
1814
1864
|
hasTrack
|
|
1815
1865
|
? React.createElement('span', { className: 'dsh-music-bar-name', title: name + (artistText ? ' - ' + artistText : '') }, note, ' ', name, artistEl, sourceBadge, afterName)
|
|
1816
|
-
: React.createElement('span', { className: 'dsh-music-bar-idle' }, note, '
|
|
1866
|
+
: React.createElement('span', { className: 'dsh-music-bar-idle' }, note, ' DSH音乐播放器'),
|
|
1817
1867
|
// 章节名独立占一整行、完整显示(不再被省略号截断)。
|
|
1818
1868
|
sectionBadge,
|
|
1819
1869
|
!isBook && hasTrack && s.playing ? React.createElement('canvas', { className: 'dsh-music-viz', width: 64, height: 14, ref: (el) => { barCanvasNode = el; } }) : null,
|
|
@@ -1995,7 +2045,7 @@ window.__ModuleLoader__.load({
|
|
|
1995
2045
|
// 在线 QQ 面板的「上次所在层」只在本次会话首次挂载时恢复一次,避免切 tab
|
|
1996
2046
|
// (QQ 音乐 ↔ 本地音乐/AI讲书)重新挂载时又被 localStorage 里的旧层拉回播放列表页。
|
|
1997
2047
|
let qqUiRestored = false;
|
|
1998
|
-
function QQOnlinePanel() {
|
|
2048
|
+
function QQOnlinePanel({ panelRef }) {
|
|
1999
2049
|
const s = useStore();
|
|
2000
2050
|
const [loggedIn, setLoggedIn] = useState(s.qqLoggedIn || false);
|
|
2001
2051
|
const [uin, setUin] = useState(s.qqUin || '');
|
|
@@ -2013,6 +2063,15 @@ window.__ModuleLoader__.load({
|
|
|
2013
2063
|
const [plResults, setPlResults] = useState([]);
|
|
2014
2064
|
const [qError, setQError] = useState('');
|
|
2015
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);
|
|
2016
2075
|
// 搜索历史(localStorage 持久化,最近在前)。
|
|
2017
2076
|
const [hist, setHist] = useState([]);
|
|
2018
2077
|
const [histOpen, setHistOpen] = useState(false);
|
|
@@ -2026,6 +2085,17 @@ window.__ModuleLoader__.load({
|
|
|
2026
2085
|
const [curCategory, setCurCategory] = useState(null);
|
|
2027
2086
|
const [browseErr, setBrowseErr] = useState('');
|
|
2028
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]);
|
|
2029
2099
|
// 推荐歌单加载更多:热门推荐 12 条后用「全部分类」分页续载。
|
|
2030
2100
|
const [recPage, setRecPage] = useState(1);
|
|
2031
2101
|
const [recLoadingMore, setRecLoadingMore] = useState(false);
|
|
@@ -2079,20 +2149,34 @@ window.__ModuleLoader__.load({
|
|
|
2079
2149
|
setActivePl({ name: s.qqSource || '在线播放列表', songs: s.qqQueue || [], source: s.qqSource || '在线' });
|
|
2080
2150
|
}
|
|
2081
2151
|
};
|
|
2152
|
+
// ① 挂载时只做本地初始化:读搜索历史(localStorage)+ 查登录态(status 由
|
|
2153
|
+
// host 读本地 cookie 文件,不发任何外部网络请求)。外部数据(分类/推荐/我的
|
|
2154
|
+
// 歌单)一律延后到登录后(见下方 [loggedIn] effect),未登录零外部请求——
|
|
2155
|
+
// 本插件 QQ 在线功能以「登录」为门槛。
|
|
2082
2156
|
useEffect(() => {
|
|
2083
2157
|
setHist(loadHist());
|
|
2084
2158
|
jsonGet('/dsh-music/qq/status').then((d) => { if (d) { setLoggedIn(!!d.loggedIn); setUin(d.uin || ''); setNickname(d.nickname || ''); } }).catch(() => {});
|
|
2085
|
-
json('/dsh-music/qq/playlist-categories').then((d) => { if (d && d.ok) setCategories(d.categories || []); });
|
|
2086
|
-
// 默认落在「我的歌单」tab:登录态下加载我的歌单;未登录时主 UI 不显示。
|
|
2087
|
-
loadMine();
|
|
2088
|
-
loadRecommended();
|
|
2089
|
-
// 仅本次会话首次挂载时恢复上次所在层;之后切 tab 重挂不再拉回旧层。
|
|
2090
|
-
if (!qqUiRestored) { qqUiRestored = true; restoreUi(loadUi()); }
|
|
2091
2159
|
// 点击搜索框外时关闭历史下拉。
|
|
2092
2160
|
const onDocClick = (e) => { if (histRef.current !== null && !histRef.current.contains(e.target)) setHistOpen(false); };
|
|
2093
2161
|
document.addEventListener('mousedown', onDocClick);
|
|
2094
2162
|
return () => { clearPoll(); document.removeEventListener('mousedown', onDocClick); };
|
|
2095
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]);
|
|
2096
2180
|
|
|
2097
2181
|
async function loadRecommended() {
|
|
2098
2182
|
setBrowseLoading(true); setBrowseErr('');
|
|
@@ -2190,12 +2274,14 @@ window.__ModuleLoader__.load({
|
|
|
2190
2274
|
setHistOpen(false);
|
|
2191
2275
|
saveHist(kw);
|
|
2192
2276
|
setSearching(true); setQError(''); setResults([]); setPlResults([]); setSearched(true); setCurCategory(null);
|
|
2277
|
+
setSearchPage(1); setSearchLastLen(0); setPlSearchPage(1); setPlSearchLastLen(0);
|
|
2193
2278
|
const [songs, pls] = await Promise.all([
|
|
2194
|
-
json('/dsh-music/qq/search?w=' + encodeURIComponent(kw)),
|
|
2195
|
-
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'),
|
|
2196
2281
|
]);
|
|
2197
|
-
if (songs && songs.ok) setResults(songs.results || []);
|
|
2198
|
-
|
|
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); }
|
|
2199
2285
|
// 默认打开「有结果」的那个 tab(歌曲优先)。
|
|
2200
2286
|
const sLen = (songs && songs.ok && (songs.results || []).length) || 0;
|
|
2201
2287
|
const pLen = (pls && pls.ok && (pls.playlists || []).length) || 0;
|
|
@@ -2203,6 +2289,37 @@ window.__ModuleLoader__.load({
|
|
|
2203
2289
|
else if (pLen > 0) setResultTab('playlists');
|
|
2204
2290
|
setSearching(false);
|
|
2205
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
|
+
}
|
|
2206
2323
|
function playSong(song, queue, sourceLabel) {
|
|
2207
2324
|
// 未登录时点击 VIP 歌曲:不要启动注定失败的播放(否则播放条会误报
|
|
2208
2325
|
// 「频谱不可用/音频加载失败」,重试也无济于事),改为提示并弹出登录。
|
|
@@ -2261,15 +2378,79 @@ window.__ModuleLoader__.load({
|
|
|
2261
2378
|
const playing = active && s.playing;
|
|
2262
2379
|
const vip = song.payplay === 1;
|
|
2263
2380
|
const artists = (song.artists || []).join('/');
|
|
2264
|
-
return React.createElement('
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
React.createElement('span', { className: 'dsh-music-track-
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
+
}, '+'));
|
|
2272
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;
|
|
2273
2454
|
const playRow = (pl) => {
|
|
2274
2455
|
const meta = (pl.trackCount > 0 ? pl.trackCount + ' 首' : '')
|
|
2275
2456
|
+ (pl.playCount ? ' · 播放 ' + fmtCount(pl.playCount) : '');
|
|
@@ -2328,8 +2509,10 @@ window.__ModuleLoader__.load({
|
|
|
2328
2509
|
}, kw)))
|
|
2329
2510
|
: null);
|
|
2330
2511
|
|
|
2512
|
+
// 扫码框以面板中心为基准居中(面板可拖拽),贴边时 clamp 不被裁掉。
|
|
2513
|
+
const qqLoginStyle = panelCenterStyle(panelRef, loginMode !== null, 170, 460);
|
|
2331
2514
|
const loginModal = loginMode !== null ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
|
|
2332
|
-
React.createElement('div', { className: 'dsh-music-picker qq-login' },
|
|
2515
|
+
React.createElement('div', { className: 'dsh-music-picker qq-login', style: qqLoginStyle },
|
|
2333
2516
|
React.createElement('div', { className: 'dsh-music-picker-head' },
|
|
2334
2517
|
React.createElement('span', { className: 'dsh-music-picker-title' }, 'QQ 音乐登录'),
|
|
2335
2518
|
React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: closeLogin }, '✕')),
|
|
@@ -2349,9 +2532,23 @@ window.__ModuleLoader__.load({
|
|
|
2349
2532
|
React.createElement('div', { className: 'dsh-music-qq-login-center' },
|
|
2350
2533
|
React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('qq') }, 'QQ 登录'),
|
|
2351
2534
|
React.createElement('button', { className: 'dsh-music-qq-login-btn', onClick: () => startLogin('wx') }, '微信登录'),
|
|
2352
|
-
React.createElement('
|
|
2353
|
-
'
|
|
2354
|
-
|
|
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);
|
|
2355
2552
|
}
|
|
2356
2553
|
|
|
2357
2554
|
// ---- 播放列表UI(第 2 层):返回 + 可滚动歌曲列表 ----
|
|
@@ -2370,7 +2567,8 @@ window.__ModuleLoader__.load({
|
|
|
2370
2567
|
: (pl.songs && pl.songs.length
|
|
2371
2568
|
? React.createElement('div', null, pl.songs.map((song) => songRow(song, pl.songs, pl.name)))
|
|
2372
2569
|
: React.createElement('div', { className: 'dsh-music-hint' }, '暂无歌曲。'))),
|
|
2373
|
-
loginModal
|
|
2570
|
+
loginModal,
|
|
2571
|
+
qqJoinMenu);
|
|
2374
2572
|
}
|
|
2375
2573
|
|
|
2376
2574
|
// ---- 主UI(第 1 层):顶部工具栏 + 4 个子tab,只滚动子tab内容区 ----
|
|
@@ -2379,6 +2577,17 @@ window.__ModuleLoader__.load({
|
|
|
2379
2577
|
const hasSongs = results.length > 0;
|
|
2380
2578
|
const hasPls = plResults.length > 0;
|
|
2381
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;
|
|
2382
2591
|
if (searching) {
|
|
2383
2592
|
resultContent = React.createElement('div', { className: 'dsh-music-hint' }, '搜索中…');
|
|
2384
2593
|
} else if (qError || !(hasSongs || hasPls)) {
|
|
@@ -2390,12 +2599,12 @@ window.__ModuleLoader__.load({
|
|
|
2390
2599
|
resultTabBtn('songs', '歌曲'),
|
|
2391
2600
|
resultTabBtn('playlists', '相关歌单')),
|
|
2392
2601
|
resultTab === 'playlists'
|
|
2393
|
-
? plResults.map(playRow)
|
|
2394
|
-
: 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));
|
|
2395
2604
|
} else if (hasSongs) {
|
|
2396
|
-
resultContent = results.map((song) => songRow(song, results, '搜索结果'));
|
|
2605
|
+
resultContent = React.createElement('div', null, results.map((song) => songRow(song, results, '搜索结果')), songMoreBtn);
|
|
2397
2606
|
} else {
|
|
2398
|
-
resultContent = plResults.map(playRow);
|
|
2607
|
+
resultContent = React.createElement('div', null, plResults.map(playRow), plMoreBtn);
|
|
2399
2608
|
}
|
|
2400
2609
|
body = React.createElement('div', null, searchBox,
|
|
2401
2610
|
searched ? React.createElement('div', null, resultContent) : null);
|
|
@@ -2515,6 +2724,7 @@ window.__ModuleLoader__.load({
|
|
|
2515
2724
|
head,
|
|
2516
2725
|
React.createElement('div', { className: 'dsh-music-qq-body' }, body),
|
|
2517
2726
|
loginModal,
|
|
2727
|
+
qqJoinMenu,
|
|
2518
2728
|
);
|
|
2519
2729
|
}
|
|
2520
2730
|
|
|
@@ -2541,7 +2751,10 @@ window.__ModuleLoader__.load({
|
|
|
2541
2751
|
// with only top+left and the CSS max-height:72vh still applying, a fixed
|
|
2542
2752
|
// element whose CSS also sets the translate would collapse/clamp and shift
|
|
2543
2753
|
// by half its own size while dragging.
|
|
2544
|
-
|
|
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' };
|
|
2545
2758
|
|
|
2546
2759
|
const onHeadDown = (e) => {
|
|
2547
2760
|
if (e.button !== undefined && e.button !== 0) return;
|
|
@@ -2630,9 +2843,10 @@ window.__ModuleLoader__.load({
|
|
|
2630
2843
|
// The directory/file pickers are portaled to <body>, so a click inside
|
|
2631
2844
|
// them is technically outside the panel's DOM — treat those as "inside"
|
|
2632
2845
|
// so interacting with the picker never closes the panel underneath.
|
|
2846
|
+
// The「加入歌单」弹窗(含本地+QQ)同样 portal 到 body,点击其内菜单不应关面板。
|
|
2633
2847
|
const onDown = (e) => {
|
|
2634
2848
|
if (panelRef.current !== null && !panelRef.current.contains(e.target)
|
|
2635
|
-
&& !(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')))) {
|
|
2636
2850
|
set({ panelOpen: false });
|
|
2637
2851
|
}
|
|
2638
2852
|
};
|
|
@@ -2648,7 +2862,7 @@ window.__ModuleLoader__.load({
|
|
|
2648
2862
|
}, [s.panelOpen, s.currentId]);
|
|
2649
2863
|
// 面板关闭时清掉「加入歌单」弹层状态,避免重开面板时残留。
|
|
2650
2864
|
useEffect(() => { if (!s.panelOpen) setAddMenu(null); }, [s.panelOpen]);
|
|
2651
|
-
|
|
2865
|
+
// 面板常驻不卸载:关闭时用根 div 的 display:none 隐藏,而非 return null。
|
|
2652
2866
|
const rows = s.tracks.map((t) => {
|
|
2653
2867
|
const active = t.id === s.currentId;
|
|
2654
2868
|
const playing = active && s.playing;
|
|
@@ -2708,20 +2922,29 @@ window.__ModuleLoader__.load({
|
|
|
2708
2922
|
const isPlaylistView = s.subTab !== 'library';
|
|
2709
2923
|
const plView = isPlaylistView ? playlistById(s.subTab) : null;
|
|
2710
2924
|
const musicBody = plView
|
|
2711
|
-
? React.createElement(PlaylistDetail, { pl: plView })
|
|
2925
|
+
? React.createElement(PlaylistDetail, { pl: plView, panelRef })
|
|
2712
2926
|
: (rows.length > 0
|
|
2713
2927
|
? rows
|
|
2714
2928
|
: React.createElement('div', { className: 'dsh-music-empty' }, '暂无音乐。点击上方“选择音乐目录”并选择目录后自动扫描。'));
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
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 },
|
|
2725
2948
|
React.createElement('div', {
|
|
2726
2949
|
className: 'dsh-music-panel-head dsh-music-panel-drag',
|
|
2727
2950
|
onPointerDown: onHeadDown, onPointerMove: onHeadMove, onPointerUp: onHeadUp,
|
|
@@ -2730,7 +2953,7 @@ window.__ModuleLoader__.load({
|
|
|
2730
2953
|
React.createElement('span', { className: 'dsh-music-panel-title' }, '播放列表'),
|
|
2731
2954
|
React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: () => set({ panelOpen: false }) }, '✕')),
|
|
2732
2955
|
React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '本地音乐'), tabBtn('qq', 'QQ音乐'), tabBtn('book', 'AI讲书')),
|
|
2733
|
-
s.tab === 'qq' ? null : React.createElement(DirectorySetting,
|
|
2956
|
+
s.tab === 'qq' ? null : React.createElement(DirectorySetting, { panelRef }),
|
|
2734
2957
|
s.tab === 'music' ? musicSubTabs : null,
|
|
2735
2958
|
// While a novel is playing, keep music-only errors/scanning out of the
|
|
2736
2959
|
// panel (novel status shows on the playback bar instead).
|
|
@@ -2743,11 +2966,71 @@ window.__ModuleLoader__.load({
|
|
|
2743
2966
|
track: addMenu.track, anchor: { x: addMenu.x, y: addMenu.y },
|
|
2744
2967
|
onClose: () => setAddMenu(null),
|
|
2745
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,
|
|
2746
2971
|
);
|
|
2747
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
|
+
}
|
|
2748
3031
|
// Directory setting block, embedded in the player panel (the former
|
|
2749
3032
|
// 设置 → 音乐播放器 page moved in-panel so all library config lives in one place).
|
|
2750
|
-
function DirectorySetting() {
|
|
3033
|
+
function DirectorySetting({ panelRef }) {
|
|
2751
3034
|
const s = useStore();
|
|
2752
3035
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
2753
3036
|
const [dirs, setDirs] = useState([]);
|
|
@@ -2769,9 +3052,10 @@ window.__ModuleLoader__.load({
|
|
|
2769
3052
|
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => openPicker() }, pickerTitle)),
|
|
2770
3053
|
React.createElement('p', { className: 'dsh-music-hint' }, hint),
|
|
2771
3054
|
pickerOpen ? portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
|
|
2772
|
-
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)) },
|
|
2773
3056
|
React.createElement('div', { className: 'dsh-music-picker-head' },
|
|
2774
|
-
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) }, '✕')),
|
|
2775
3059
|
React.createElement('div', { className: 'dsh-music-picker-cur', title: curPath },
|
|
2776
3060
|
renderCrumbs(curCrumbs, curPath, curName, browse)),
|
|
2777
3061
|
React.createElement('div', { className: 'dsh-music-picker-list' },
|
|
@@ -2843,20 +3127,19 @@ window.__ModuleLoader__.load({
|
|
|
2843
3127
|
const list = store.playlists || [];
|
|
2844
3128
|
const addTo = (id) => { apiPlaylistAdd(id, [track.path], () => onClose()); };
|
|
2845
3129
|
const addNew = () => {
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
}).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
|
+
});
|
|
2860
3143
|
};
|
|
2861
3144
|
return React.createElement('div', { className: 'dsh-music-add-pop', ref, style },
|
|
2862
3145
|
list.length > 0 ? list.map((p) => React.createElement('button', {
|
|
@@ -2869,7 +3152,7 @@ window.__ModuleLoader__.load({
|
|
|
2869
3152
|
);
|
|
2870
3153
|
}
|
|
2871
3154
|
// 歌单详情:添加歌曲 + 重命名/删除 + 歌曲列表(移除/上移/下移)。
|
|
2872
|
-
function PlaylistDetail({ pl }) {
|
|
3155
|
+
function PlaylistDetail({ pl, panelRef }) {
|
|
2873
3156
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
2874
3157
|
const rows = (pl.tracks || []).map((t, idx) => {
|
|
2875
3158
|
const active = t.id === store.currentId;
|
|
@@ -2899,11 +3182,11 @@ window.__ModuleLoader__.load({
|
|
|
2899
3182
|
pl.missing > 0 ? React.createElement('span', { className: 'dsh-music-playlist-missing', title: '部分歌曲文件已被移动或删除' }, pl.missing + ' 首已失效') : null,
|
|
2900
3183
|
),
|
|
2901
3184
|
rows.length > 0 ? rows : React.createElement('div', { className: 'dsh-music-empty dsh-music-playlist-empty' }, '歌单为空,点击「添加歌曲」从本地文件选择音乐。'),
|
|
2902
|
-
pickerOpen ? React.createElement(FilePicker, { pl, onClose: () => setPickerOpen(false) }) : null,
|
|
3185
|
+
pickerOpen ? React.createElement(FilePicker, { pl, panelRef, onClose: () => setPickerOpen(false) }) : null,
|
|
2903
3186
|
);
|
|
2904
3187
|
}
|
|
2905
3188
|
// 文件系统多选器:浏览目录 + 勾选音频文件,用于歌单「添加歌曲」。
|
|
2906
|
-
function FilePicker({ pl, onClose }) {
|
|
3189
|
+
function FilePicker({ pl, panelRef, onClose }) {
|
|
2907
3190
|
const [cur, setCur] = useState({ path: '', name: '', dirs: [], files: [], crumbs: [] });
|
|
2908
3191
|
const [sel, setSel] = useState(new Set());
|
|
2909
3192
|
const [err, setErr] = useState(null);
|
|
@@ -2930,10 +3213,10 @@ window.__ModuleLoader__.load({
|
|
|
2930
3213
|
apiPlaylistAdd(pl.id, paths, () => onClose());
|
|
2931
3214
|
};
|
|
2932
3215
|
return portalToBody(React.createElement('div', { className: 'dsh-music-picker-overlay' },
|
|
2933
|
-
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)) },
|
|
2934
3217
|
React.createElement('div', { className: 'dsh-music-picker-head' },
|
|
2935
3218
|
React.createElement('span', { className: 'dsh-music-picker-title' }, '添加歌曲到「' + pl.name + '」'),
|
|
2936
|
-
|
|
3219
|
+
React.createElement('button', { className: 'dsh-music-icon-btn', title: '关闭', onClick: onClose }, '✕')),
|
|
2937
3220
|
React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path },
|
|
2938
3221
|
renderCrumbs(cur.crumbs, cur.path, cur.name, browse)),
|
|
2939
3222
|
React.createElement('div', { className: 'dsh-music-picker-list' },
|
|
@@ -3155,6 +3438,12 @@ window.__ModuleLoader__.load({
|
|
|
3155
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' +
|
|
3156
3439
|
'.dsh-music-mode-item.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
|
|
3157
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' +
|
|
3158
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' +
|
|
3159
3448
|
'.dsh-music-track:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
|
|
3160
3449
|
'.dsh-music-track.active { color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
@@ -3184,10 +3473,11 @@ window.__ModuleLoader__.load({
|
|
|
3184
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' +
|
|
3185
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' +
|
|
3186
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' +
|
|
3187
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' +
|
|
3188
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' +
|
|
3189
3479
|
'.dsh-music-picker-head { display: flex; align-items: center; flex: none; }\n' +
|
|
3190
|
-
'.dsh-music-picker-title { font-weight: 600; }\n' +
|
|
3480
|
+
'.dsh-music-picker-title { font-weight: 600; margin-right: auto; }\n' +
|
|
3191
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' +
|
|
3192
3482
|
'.dsh-music-picker-cur::-webkit-scrollbar { height: 4px; }\n' +
|
|
3193
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' +
|
|
@@ -3201,7 +3491,12 @@ window.__ModuleLoader__.load({
|
|
|
3201
3491
|
'.dsh-music-picker-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
|
|
3202
3492
|
// 文件条目:仅作展示,不可点击(无 hover 高亮,光标为默认)。
|
|
3203
3493
|
'.dsh-music-picker-item.file { color: var(--dsw-alias-label-secondary, #8a8f98); cursor: default; }\n' +
|
|
3204
|
-
'.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' +
|
|
3205
3500
|
'.dsh-music-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
3206
3501
|
// ---- 在线 QQ 音乐 ----
|
|
3207
3502
|
'.dsh-music-qq { display: flex; flex-direction: column; gap: 10px; }\n' +
|
|
@@ -3236,8 +3531,14 @@ window.__ModuleLoader__.load({
|
|
|
3236
3531
|
'.dsh-music-qq-topname { font-weight: 600; }\n' +
|
|
3237
3532
|
'.dsh-music-qq-topmeta { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
3238
3533
|
'.dsh-music-qq-detail-head { display: flex; gap: 8px; align-items: center; margin: 6px 0; }\n' +
|
|
3239
|
-
'.dsh-music-qq { display: flex; flex-direction: column; flex: 1; min-height: 0; }\n' +
|
|
3240
|
-
|
|
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' +
|
|
3241
3542
|
'.dsh-music-qq-body { flex: 1; overflow-y: auto; min-height: 0; }\n' +
|
|
3242
3543
|
'.dsh-music-qq-section { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); margin: 10px 0 4px; font-weight: 600; }\n' +
|
|
3243
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' +
|
|
@@ -3248,7 +3549,12 @@ window.__ModuleLoader__.load({
|
|
|
3248
3549
|
'.dsh-music-qq-login { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; }\n' +
|
|
3249
3550
|
'.dsh-music-qq-login-center { display: flex; flex-direction: column; gap: 12px; align-items: center; max-width: 320px; }\n' +
|
|
3250
3551
|
'.dsh-music-qq-login-btn { width: 200px; padding: 10px 16px; font-size: 15px; }\n' +
|
|
3251
|
-
|
|
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' +
|
|
3252
3558
|
// 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。
|
|
3253
3559
|
'.dsh-music-bar.book .dsh-music-bar-name { max-width: 24%; }\n' +
|
|
3254
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",
|