dsh-music-player 0.2.1 → 0.3.0
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 +19 -5
- package/lib/client.js +496 -51
- package/lib/index.js +309 -4
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
DeepSeek Harness 本地音乐 + AI 讲书插件(bundle)。
|
|
6
6
|
|
|
7
|
-
在 Host 进程扫描本地音乐目录(默认 `~/Music`,可在面板里改)并以 HTTP Range 流式给浏览器提供音频;浏览器侧提供**正在播放条**(上一首/播放暂停/下一首/停止、播放模式、音量、实时频谱)与浮动的**播放面板**(曲目列表 / 目录设置)。支持 **AI 讲书**:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别书名/前言/章节/尾声,可跳章、可选声音。同时注册 `music_play` 模型工具,让 agent 可按关键词播放音乐或启动讲书。
|
|
7
|
+
在 Host 进程扫描本地音乐目录(默认 `~/Music`,可在面板里改)并以 HTTP Range 流式给浏览器提供音频;浏览器侧提供**正在播放条**(上一首/播放暂停/下一首/停止、播放模式、音量、实时频谱)与浮动的**播放面板**(曲目列表 / 自建歌单 / 目录设置)。支持 **AI 讲书**:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别书名/前言/章节/尾声,可跳章、可选声音。同时注册 `music_play` 模型工具,让 agent 可按关键词播放音乐或启动讲书。
|
|
8
8
|
|
|
9
9
|
## 特性
|
|
10
10
|
|
|
@@ -16,6 +16,7 @@ DeepSeek Harness 本地音乐 + AI 讲书插件(bundle)。
|
|
|
16
16
|
- AI 讲书:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转、章节切歌,可选 4 种中文 AI 声音(默认白桦)
|
|
17
17
|
- `music_play` 模型工具:agent 可按关键词播放本地音乐,也可按小说名启动 AI 讲书
|
|
18
18
|
- 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
|
|
19
|
+
- **自建歌单**:可新建多个歌单,从本地文件(支持多选、可跨目录)添加歌曲;播放条爱心按钮一键收藏到默认歌单「我最喜欢」;歌单作为播放来源时,顺序/乱序循环只在该歌单内进行
|
|
19
20
|
|
|
20
21
|
## 截图
|
|
21
22
|
|
|
@@ -66,8 +67,8 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
66
67
|
|
|
67
68
|
插件为「Host 端 + Web 端」双面结构:
|
|
68
69
|
|
|
69
|
-
- Host 端(`lib/index.js`):音乐扫描、HTTP
|
|
70
|
-
- Web 端(`lib/client.js`):浏览器里的播放条 / 播放面板 / 频谱 / 讲书控制
|
|
70
|
+
- Host 端(`lib/index.js`):音乐扫描、HTTP 流式、歌单 CRUD/持久化、`music_play` 工具、AI 讲书(小说结构解析 + TTS 合成)
|
|
71
|
+
- Web 端(`lib/client.js`):浏览器里的播放条 / 播放面板 / 频谱 / 歌单(收藏、一键清空)/ 讲书控制
|
|
71
72
|
|
|
72
73
|
两者由一个 `cordis.patch.yml` 插入 `music-player` 行并自动组对(在 Web 端 `dsh.client` 声明即指回该行名并加载浏览器半体):
|
|
73
74
|
|
|
@@ -79,6 +80,19 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
79
80
|
|
|
80
81
|
播放模式与音量保存在浏览器 `localStorage`,当前曲目与进度也会在刷新后恢复(浏览器的自动播放可能被拦截,点一次 ▶ 即可解锁)。
|
|
81
82
|
|
|
83
|
+
## 自建歌单(收藏)
|
|
84
|
+
|
|
85
|
+
播放面板「音乐」页内新增子标签:**曲库 / ♥ 我最喜欢 / +**,支持自建歌单并把歌单作为播放来源——此时顺序/乱序循环只在该歌单内进行。
|
|
86
|
+
|
|
87
|
+
- **新建歌单**:点「+」输入名称即建(可建多个)。
|
|
88
|
+
- **曲库加入**:在「曲库」列表每首歌行尾有「+」按钮,点击可把该曲加入任一已有歌单,或直接新建歌单加入。
|
|
89
|
+
- **添加歌曲**:进入某歌单 → 点「添加歌曲」→ 打开本地文件多选框(可多选、可跨目录)加入歌单;歌单内每首歌支持上移/下移排序与移除。
|
|
90
|
+
- **清空歌单**:每个歌单(含「我最喜欢」)详情内都有「清空」按钮,二次确认后一键移除全部歌曲(歌曲文件不会被删除)。
|
|
91
|
+
- **收藏**:播放条上的爱心按钮一键把当前曲加入默认歌单「我最喜欢」,再点取消;「我最喜欢」固定不可删除/重命名。
|
|
92
|
+
- **播放范围**:在歌单里点歌,则顺序/乱序/单曲循环都在该歌单内;在「曲库」点歌则回到全库循环。
|
|
93
|
+
- **命令**:`music_play` 工具新增 `playlist` 参数,可让 agent 直接播放某个歌单(如「播放歌单 我最喜欢」)。
|
|
94
|
+
- 歌单数据保存在 `~/.dsh/music-player-playlists.json`,刷新/重启不丢;歌单可包含曲库目录之外的本地音频文件。
|
|
95
|
+
|
|
82
96
|
## AI 讲书
|
|
83
97
|
|
|
84
98
|
把本地 `.txt` 小说交给 AI 朗读(复用 DSH 已配置的 xiaomi/MiMo TTS)。**AI 语音目前仅支持 xiaomi 提供方(限时免费),请在设置中配置好再使用此功能。**
|
|
@@ -101,11 +115,11 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
101
115
|
|
|
102
116
|
## 开发
|
|
103
117
|
|
|
104
|
-
需要 Node.js ≥ 20(vitest 建议 20.19+)与 npm
|
|
118
|
+
需要 Node.js ≥ 20(vitest 建议 20.19+)与 npm。开发依赖:`vitest` + `react`/`react-dom`/`jsdom`(用于前端渲染冒烟测试):
|
|
105
119
|
|
|
106
120
|
```sh
|
|
107
121
|
npm install
|
|
108
|
-
npm test # 跑 vitest
|
|
122
|
+
npm test # 跑 vitest 测试套件(Host 单测 + Web 渲染冒烟,共 50+ 用例)
|
|
109
123
|
```
|
|
110
124
|
|
|
111
125
|
修改 `lib/` 后,在本机 profile 里用 link 方式本地调试并验证:
|
package/lib/client.js
CHANGED
|
@@ -56,6 +56,7 @@ window.__ModuleLoader__.load({
|
|
|
56
56
|
const PREF_ROOT = 'dsh-music-root';
|
|
57
57
|
const PREF_PANEL_POS = 'dsh-music-panel-pos';
|
|
58
58
|
const PREF_VOICE = 'dsh-music-voice';
|
|
59
|
+
const PREF_SCOPE = 'dsh-music-scope';
|
|
59
60
|
const loadPref = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
|
60
61
|
const savePref = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
|
|
61
62
|
const clearPref = (k) => { try { localStorage.removeItem(k); } catch (e) {} };
|
|
@@ -169,6 +170,9 @@ window.__ModuleLoader__.load({
|
|
|
169
170
|
// persists in localStorage and rides the chunk URL so the host re-synthesizes.
|
|
170
171
|
// voiceSwitching = a new voice is being synthesized in the background.
|
|
171
172
|
voices: [], voice: '白桦', voiceSwitching: false,
|
|
173
|
+
// 自建歌单:manifest.playlists 即数据源;scope 为当前播放范围(曲库/歌单),
|
|
174
|
+
// subTab 为音乐页内的子标签('library' 或歌单 id)。
|
|
175
|
+
playlists: [], scope: { kind: 'library' }, subTab: 'library',
|
|
172
176
|
};
|
|
173
177
|
const listeners = new Set();
|
|
174
178
|
function set(patch) {
|
|
@@ -176,6 +180,7 @@ window.__ModuleLoader__.load({
|
|
|
176
180
|
if ('mode' in patch) savePref(PREF_MODE, patch.mode);
|
|
177
181
|
if ('volume' in patch) savePref(PREF_VOL, String(patch.volume));
|
|
178
182
|
if ('voice' in patch) savePref(PREF_VOICE, patch.voice);
|
|
183
|
+
if ('scope' in patch) savePref(PREF_SCOPE, JSON.stringify(patch.scope));
|
|
179
184
|
for (const fn of [...listeners]) fn();
|
|
180
185
|
}
|
|
181
186
|
function useStore() {
|
|
@@ -190,6 +195,154 @@ window.__ModuleLoader__.load({
|
|
|
190
195
|
}
|
|
191
196
|
const trackById = (id) => (store.tracks || []).find((t) => t.id === id) || null;
|
|
192
197
|
|
|
198
|
+
// ---- 自建歌单:范围 / 解析 / 收藏 ----
|
|
199
|
+
const FAV_PLAYLIST_ID = 'pl-fav';
|
|
200
|
+
const playlistById = (id) => (store.playlists || []).find((p) => p.id === id) || null;
|
|
201
|
+
// 解析任意可播放对象:歌单成员 id('p:'+path)优先,其次曲库曲目。
|
|
202
|
+
function resolvePlayable(id) {
|
|
203
|
+
if (id === null || id === undefined) return null;
|
|
204
|
+
if (String(id).startsWith('p:')) {
|
|
205
|
+
for (const p of store.playlists || []) {
|
|
206
|
+
const m = (p.tracks || []).find((t) => t.id === id);
|
|
207
|
+
if (m) return m;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return trackById(id);
|
|
212
|
+
}
|
|
213
|
+
// 当前范围的有序 id 列表:歌单非空则用歌单,否则回退曲库(空/已删歌单优雅回退)。
|
|
214
|
+
function activeIds() {
|
|
215
|
+
const s = store.scope || { kind: 'library' };
|
|
216
|
+
if (s.kind === 'playlist') {
|
|
217
|
+
const pl = playlistById(s.id);
|
|
218
|
+
if (pl && pl.tracks && pl.tracks.length > 0) return pl.tracks.map((t) => t.id);
|
|
219
|
+
return (store.tracks || []).map((t) => t.id);
|
|
220
|
+
}
|
|
221
|
+
return (store.tracks || []).map((t) => t.id);
|
|
222
|
+
}
|
|
223
|
+
function scopeKey() {
|
|
224
|
+
const s = store.scope || { kind: 'library' };
|
|
225
|
+
return s.kind === 'playlist' ? 'pl:' + s.id : 'lib';
|
|
226
|
+
}
|
|
227
|
+
// 当前播放曲目对应的绝对路径(用于收藏判断)。
|
|
228
|
+
function currentTrackPath() {
|
|
229
|
+
if (store.currentId === null) return null;
|
|
230
|
+
if (String(store.currentId).startsWith('p:')) return String(store.currentId).slice(2);
|
|
231
|
+
const t = trackById(store.currentId);
|
|
232
|
+
return t && t.path ? t.path : null;
|
|
233
|
+
}
|
|
234
|
+
function isCurrentFaved() {
|
|
235
|
+
const path = currentTrackPath();
|
|
236
|
+
if (path === null) return false;
|
|
237
|
+
const fav = playlistById(FAV_PLAYLIST_ID);
|
|
238
|
+
return fav !== null && (fav.tracks || []).some((m) => m.path === path);
|
|
239
|
+
}
|
|
240
|
+
function updatePlaylistInStore(pl) {
|
|
241
|
+
if (!pl || !pl.id) return;
|
|
242
|
+
set({ playlists: (store.playlists || []).map((p) => (p.id === pl.id ? pl : p)) });
|
|
243
|
+
}
|
|
244
|
+
function apiPlaylistAdd(id, paths, then) {
|
|
245
|
+
fetch('/dsh-music/playlist/add', {
|
|
246
|
+
method: 'POST', cache: 'no-store',
|
|
247
|
+
headers: { 'content-type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({ id, paths }),
|
|
249
|
+
}).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); if (then) then(r); }).catch(() => {});
|
|
250
|
+
}
|
|
251
|
+
function apiPlaylistRemove(id, paths, then) {
|
|
252
|
+
fetch('/dsh-music/playlist/remove', {
|
|
253
|
+
method: 'POST', cache: 'no-store',
|
|
254
|
+
headers: { 'content-type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({ id, paths }),
|
|
256
|
+
}).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); if (then) then(r); }).catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
function apiPlaylistReorder(id, paths) {
|
|
259
|
+
fetch('/dsh-music/playlist/reorder', {
|
|
260
|
+
method: 'POST', cache: 'no-store',
|
|
261
|
+
headers: { 'content-type': 'application/json' },
|
|
262
|
+
body: JSON.stringify({ id, paths }),
|
|
263
|
+
}).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); }).catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
// 收藏切换:加入/移出「我最喜欢」。
|
|
266
|
+
function toggleFav() {
|
|
267
|
+
const path = currentTrackPath();
|
|
268
|
+
if (path === null) return;
|
|
269
|
+
const fav = playlistById(FAV_PLAYLIST_ID);
|
|
270
|
+
if (fav === null) return;
|
|
271
|
+
if (isCurrentFaved()) apiPlaylistRemove(FAV_PLAYLIST_ID, [path]);
|
|
272
|
+
else apiPlaylistAdd(FAV_PLAYLIST_ID, [path]);
|
|
273
|
+
}
|
|
274
|
+
// 从歌单/曲库点歌:来源即范围。
|
|
275
|
+
function startPlayFrom(id, kind, plId) {
|
|
276
|
+
if (kind === 'playlist') set({ scope: { kind: 'playlist', id: plId } });
|
|
277
|
+
else set({ scope: { kind: 'library' } });
|
|
278
|
+
startPlay(id);
|
|
279
|
+
}
|
|
280
|
+
// 歌单管理:新建 / 重命名 / 删除 / 移动歌曲。
|
|
281
|
+
function onCreatePlaylist() {
|
|
282
|
+
const name = window.prompt('新建歌单名称', '');
|
|
283
|
+
if (name === null) return;
|
|
284
|
+
const trimmed = name.trim();
|
|
285
|
+
if (trimmed === '') return;
|
|
286
|
+
fetch('/dsh-music/playlist', {
|
|
287
|
+
method: 'POST', cache: 'no-store',
|
|
288
|
+
headers: { 'content-type': 'application/json' },
|
|
289
|
+
body: JSON.stringify({ name: trimmed }),
|
|
290
|
+
}).then((r) => r.json()).then((r) => {
|
|
291
|
+
if (r && r.playlist) {
|
|
292
|
+
set({ playlists: [...(store.playlists || []), r.playlist], subTab: r.playlist.id });
|
|
293
|
+
}
|
|
294
|
+
}).catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
function onRenamePlaylist(pl) {
|
|
297
|
+
const name = window.prompt('重命名歌单「' + pl.name + '」', pl.name);
|
|
298
|
+
if (name === null) return;
|
|
299
|
+
const trimmed = name.trim();
|
|
300
|
+
if (trimmed === '' || trimmed === pl.name) return;
|
|
301
|
+
fetch('/dsh-music/playlist/rename', {
|
|
302
|
+
method: 'POST', cache: 'no-store',
|
|
303
|
+
headers: { 'content-type': 'application/json' },
|
|
304
|
+
body: JSON.stringify({ id: pl.id, name: trimmed }),
|
|
305
|
+
}).then((r) => r.json()).then((r) => { if (r && r.playlist) updatePlaylistInStore(r.playlist); }).catch(() => {});
|
|
306
|
+
}
|
|
307
|
+
function onDeletePlaylist(pl) {
|
|
308
|
+
if (!window.confirm('删除歌单「' + pl.name + '」?歌曲文件不会被删除。')) return;
|
|
309
|
+
fetch('/dsh-music/playlist/delete', {
|
|
310
|
+
method: 'POST', cache: 'no-store',
|
|
311
|
+
headers: { 'content-type': 'application/json' },
|
|
312
|
+
body: JSON.stringify({ id: pl.id }),
|
|
313
|
+
}).then((r) => r.json()).then((r) => {
|
|
314
|
+
if (r && r.ok) {
|
|
315
|
+
const next = (store.playlists || []).filter((p) => p.id !== pl.id);
|
|
316
|
+
set({ playlists: next, subTab: 'library' });
|
|
317
|
+
if (store.scope && store.scope.kind === 'playlist' && store.scope.id === pl.id) {
|
|
318
|
+
set({ scope: { kind: 'library' } });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}).catch(() => {});
|
|
322
|
+
}
|
|
323
|
+
// 一键清空歌单(任何歌单都可用,含系统「我最喜欢」;仅从歌单移除,不删文件)。
|
|
324
|
+
function onClearPlaylist(pl) {
|
|
325
|
+
const n = (pl.tracks || []).length;
|
|
326
|
+
if (n === 0 && !pl.missing) return;
|
|
327
|
+
if (!window.confirm('清空歌单「' + pl.name + '」?将移除全部 ' + n + ' 首歌曲' + (pl.missing > 0 ? '(另有 ' + pl.missing + ' 首已失效一并清除)' : '') + ',歌曲文件不会被删除。')) return;
|
|
328
|
+
fetch('/dsh-music/playlist/clear', {
|
|
329
|
+
method: 'POST', cache: 'no-store',
|
|
330
|
+
headers: { 'content-type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ id: pl.id }),
|
|
332
|
+
}).then((r) => r.json()).then((r) => {
|
|
333
|
+
if (r && r.playlist) updatePlaylistInStore(r.playlist);
|
|
334
|
+
}).catch(() => {});
|
|
335
|
+
}
|
|
336
|
+
function movePlaylistTrack(pl, path, dir) {
|
|
337
|
+
const paths = (pl.tracks || []).map((t) => t.path);
|
|
338
|
+
const i = paths.indexOf(path);
|
|
339
|
+
const j = i + dir;
|
|
340
|
+
if (i < 0 || j < 0 || j >= paths.length) return;
|
|
341
|
+
const next = paths.slice();
|
|
342
|
+
const tmp = next[i]; next[i] = next[j]; next[j] = tmp;
|
|
343
|
+
apiPlaylistReorder(pl.id, next);
|
|
344
|
+
}
|
|
345
|
+
|
|
193
346
|
// restore persisted prefs
|
|
194
347
|
try {
|
|
195
348
|
const m = loadPref(PREF_MODE);
|
|
@@ -296,21 +449,23 @@ window.__ModuleLoader__.load({
|
|
|
296
449
|
} catch { if (!isPrefetch && isCurrent()) set({ vizState: 'unavailable' }); }
|
|
297
450
|
}
|
|
298
451
|
function prefetchNext() {
|
|
299
|
-
|
|
452
|
+
const ids = activeIds();
|
|
453
|
+
if (ids.length === 0 || store.currentId === null) return;
|
|
300
454
|
let nextId = null;
|
|
301
455
|
if (store.mode === 'shuffle') {
|
|
302
456
|
// Prefetch the queued next track so a shuffle "next" starts instantly.
|
|
303
|
-
|
|
457
|
+
ensureShuffleReady();
|
|
458
|
+
if (shuffleQueue.length === ids.length) {
|
|
304
459
|
const pos = shuffleQueue.indexOf(store.currentId);
|
|
305
460
|
if (pos >= 0 && pos + 1 < shuffleQueue.length) nextId = shuffleQueue[pos + 1];
|
|
306
461
|
}
|
|
307
462
|
} else {
|
|
308
|
-
const idx =
|
|
309
|
-
const next =
|
|
310
|
-
if (next !== undefined) nextId = next
|
|
463
|
+
const idx = ids.indexOf(store.currentId);
|
|
464
|
+
const next = ids[(idx + 1) % ids.length];
|
|
465
|
+
if (next !== undefined) nextId = next;
|
|
311
466
|
}
|
|
312
467
|
if (nextId !== null) {
|
|
313
|
-
const next =
|
|
468
|
+
const next = resolvePlayable(nextId);
|
|
314
469
|
if (next !== undefined && !envCache.has(next.id)) loadEnvelope(next.id, next.url, true);
|
|
315
470
|
}
|
|
316
471
|
}
|
|
@@ -321,15 +476,30 @@ window.__ModuleLoader__.load({
|
|
|
321
476
|
const smoothCur = new Float32Array(SMOOTH_BARS);
|
|
322
477
|
const smoothPeak = new Float32Array(SMOOTH_BARS);
|
|
323
478
|
const targetBuf = new Float32Array(SMOOTH_BARS);
|
|
324
|
-
// Accent color for the spectrum bars
|
|
325
|
-
// --dsh-music-accent
|
|
479
|
+
// Accent color for the spectrum bars. DSH defines its --dsw-alias-* theme
|
|
480
|
+
// tokens on <body> — never on :root — so --dsh-music-accent must be read
|
|
481
|
+
// from body (reading documentElement would always return the fallback and
|
|
482
|
+
// the bars would never follow the theme). The value is cached but the cache
|
|
483
|
+
// is invalidated whenever the theme changes at runtime: the ThemePresenter
|
|
484
|
+
// projects tokens + the dark attribute onto body, so a MutationObserver on
|
|
485
|
+
// body's style/dark-attribute keeps the bars tracking live brand changes.
|
|
326
486
|
let accentColor = null;
|
|
487
|
+
let accentObserver = null;
|
|
488
|
+
function readAccent() {
|
|
489
|
+
const el = document.body || document.documentElement;
|
|
490
|
+
return getComputedStyle(el).getPropertyValue('--dsh-music-accent').trim() || '#2f9e6e';
|
|
491
|
+
}
|
|
327
492
|
function currentAccent() {
|
|
328
|
-
if (accentColor === null)
|
|
329
|
-
accentColor = getComputedStyle(document.documentElement).getPropertyValue('--dsh-music-accent').trim() || '#2f9e6e';
|
|
330
|
-
}
|
|
493
|
+
if (accentColor === null) accentColor = readAccent();
|
|
331
494
|
return accentColor;
|
|
332
495
|
}
|
|
496
|
+
function watchAccent() {
|
|
497
|
+
if (accentObserver !== null) return accentObserver;
|
|
498
|
+
if (typeof MutationObserver === 'undefined') return null;
|
|
499
|
+
accentObserver = new MutationObserver(() => { accentColor = readAccent(); });
|
|
500
|
+
accentObserver.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] });
|
|
501
|
+
return accentObserver;
|
|
502
|
+
}
|
|
333
503
|
function drawBars(canvas, useCaps) {
|
|
334
504
|
const c = canvas.getContext('2d');
|
|
335
505
|
const w = canvas.width; const h = canvas.height;
|
|
@@ -394,8 +564,9 @@ window.__ModuleLoader__.load({
|
|
|
394
564
|
// played one, instead of random-without-repeat or list-order neighbors.
|
|
395
565
|
let shuffleQueue = [];
|
|
396
566
|
let shufflePos = -1;
|
|
567
|
+
let shuffleScopeKey = null;
|
|
397
568
|
function buildShuffleQueue(anchorId) {
|
|
398
|
-
const ids =
|
|
569
|
+
const ids = activeIds();
|
|
399
570
|
// Fisher-Yates
|
|
400
571
|
for (let i = ids.length - 1; i > 0; i--) {
|
|
401
572
|
const j = Math.floor(Math.random() * (i + 1));
|
|
@@ -407,19 +578,25 @@ window.__ModuleLoader__.load({
|
|
|
407
578
|
if (ai !== 0) { ids.splice(ai, 1); ids.unshift(a); }
|
|
408
579
|
}
|
|
409
580
|
shuffleQueue = ids;
|
|
581
|
+
shuffleScopeKey = scopeKey();
|
|
410
582
|
shufflePos = a !== null && ids[0] === a ? 0 : -1;
|
|
411
583
|
}
|
|
412
|
-
|
|
584
|
+
// 确保乱序队列与当前范围一致(范围切换后自动重建)。
|
|
585
|
+
function ensureShuffleReady() {
|
|
413
586
|
if (store.mode !== 'shuffle') return;
|
|
414
|
-
|
|
415
|
-
if (
|
|
587
|
+
const ids = activeIds();
|
|
588
|
+
if (shuffleScopeKey !== scopeKey() || shuffleQueue.length !== ids.length
|
|
589
|
+
|| (store.currentId !== null && !shuffleQueue.includes(store.currentId))) {
|
|
416
590
|
buildShuffleQueue(store.currentId);
|
|
417
|
-
return;
|
|
418
591
|
}
|
|
419
|
-
shufflePos = shuffleQueue.indexOf(store.currentId);
|
|
592
|
+
if (store.currentId !== null) shufflePos = shuffleQueue.indexOf(store.currentId);
|
|
593
|
+
}
|
|
594
|
+
function syncShufflePos() {
|
|
595
|
+
if (store.mode !== 'shuffle') return;
|
|
596
|
+
ensureShuffleReady();
|
|
420
597
|
}
|
|
421
598
|
function startPlay(id) {
|
|
422
|
-
const track =
|
|
599
|
+
const track = resolvePlayable(id);
|
|
423
600
|
if (track === null) return;
|
|
424
601
|
restoredMusicPos = null;
|
|
425
602
|
bookRestorePos = -1;
|
|
@@ -672,13 +849,13 @@ window.__ModuleLoader__.load({
|
|
|
672
849
|
}
|
|
673
850
|
function togglePlay() {
|
|
674
851
|
if (store.pendingId !== null && store.currentId === null) { startPlay(store.pendingId); return; }
|
|
675
|
-
if (store.currentId === null) { if (
|
|
852
|
+
if (store.currentId === null) { const ids = activeIds(); if (ids.length > 0) startPlay(ids[0]); return; }
|
|
676
853
|
if (audio.paused) {
|
|
677
854
|
// A restored track's <audio> was not pre-loaded (restore never touches
|
|
678
855
|
// the element, to avoid the Chromium 'getTopURL' quirk), so load it now,
|
|
679
856
|
// then apply the deferred seek so it resumes from the saved spot.
|
|
680
857
|
if (restoredMusicPos !== null && restoredMusicPos > 0) {
|
|
681
|
-
const track =
|
|
858
|
+
const track = resolvePlayable(store.currentId);
|
|
682
859
|
if (track !== null && audio.currentSrc !== new URL(track.url, window.location.href).href) {
|
|
683
860
|
audio.src = track.url;
|
|
684
861
|
audio.load();
|
|
@@ -704,19 +881,17 @@ window.__ModuleLoader__.load({
|
|
|
704
881
|
// Envelope decoding is deferred to play (see loadTracks) — decode the
|
|
705
882
|
// restored track lazily so its spectrum is ready once it resumes.
|
|
706
883
|
if (!String(store.currentId).startsWith('book:')) {
|
|
707
|
-
const track =
|
|
884
|
+
const track = resolvePlayable(store.currentId);
|
|
708
885
|
if (track !== null && !envCache.has(track.id)) loadEnvelope(track.id, track.url);
|
|
709
886
|
}
|
|
710
887
|
} else audio.pause();
|
|
711
888
|
}
|
|
712
889
|
function step(delta) {
|
|
713
|
-
|
|
714
|
-
if (
|
|
890
|
+
const ids = activeIds();
|
|
891
|
+
if (ids.length === 0) return;
|
|
892
|
+
if (store.mode === 'shuffle' && ids.length > 1) { // Walk the shuffled queue: next plays the next unplayed track, prev
|
|
715
893
|
// returns to the previously played one (not a list-order neighbor).
|
|
716
|
-
|
|
717
|
-
|| store.currentId === null || !shuffleQueue.includes(store.currentId)) {
|
|
718
|
-
buildShuffleQueue(store.currentId);
|
|
719
|
-
}
|
|
894
|
+
ensureShuffleReady();
|
|
720
895
|
const pos = shuffleQueue.indexOf(store.currentId);
|
|
721
896
|
if (store.currentId === null) {
|
|
722
897
|
// Nothing playing yet: start from the head of the shuffled queue.
|
|
@@ -740,9 +915,9 @@ window.__ModuleLoader__.load({
|
|
|
740
915
|
}
|
|
741
916
|
return;
|
|
742
917
|
}
|
|
743
|
-
const idx =
|
|
744
|
-
const nextIdx = idx < 0 ? 0 : (idx + delta +
|
|
745
|
-
startPlay(
|
|
918
|
+
const idx = ids.indexOf(store.currentId);
|
|
919
|
+
const nextIdx = idx < 0 ? 0 : (idx + delta + ids.length) % ids.length;
|
|
920
|
+
startPlay(ids[nextIdx]);
|
|
746
921
|
}
|
|
747
922
|
// In book (AI 讲书) mode the transport prev/next buttons jump between
|
|
748
923
|
// CHAPTERS instead of music tracks. The current chapter is the section
|
|
@@ -970,10 +1145,31 @@ window.__ModuleLoader__.load({
|
|
|
970
1145
|
};
|
|
971
1146
|
}
|
|
972
1147
|
|
|
1148
|
+
// Restore the persisted playback scope (playlist/library); a stale playlist id falls back.
|
|
1149
|
+
function restoreScope(plists) {
|
|
1150
|
+
const raw = loadPref(PREF_SCOPE);
|
|
1151
|
+
try {
|
|
1152
|
+
const o = JSON.parse(raw);
|
|
1153
|
+
if (o && o.kind === 'playlist' && (plists || []).some((p) => p.id === o.id)) {
|
|
1154
|
+
set({ scope: { kind: 'playlist', id: o.id } });
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
} catch (e) {}
|
|
1158
|
+
set({ scope: { kind: 'library' } });
|
|
1159
|
+
}
|
|
1160
|
+
|
|
973
1161
|
function restorePlayback(list) {
|
|
974
1162
|
const saved = loadPlayback();
|
|
975
1163
|
if (saved === null) return;
|
|
976
|
-
|
|
1164
|
+
// A saved current track may be a library track or a playlist member ('p:'+path).
|
|
1165
|
+
let track = list.find((t) => t.id === saved.id);
|
|
1166
|
+
let scope = { kind: 'library' };
|
|
1167
|
+
if (track === undefined && String(saved.id).startsWith('p:')) {
|
|
1168
|
+
for (const p of store.playlists || []) {
|
|
1169
|
+
const m = (p.tracks || []).find((t) => t.id === saved.id);
|
|
1170
|
+
if (m) { track = m; scope = { kind: 'playlist', id: p.id }; break; }
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
977
1173
|
if (track === undefined) return;
|
|
978
1174
|
const pos = Number.isFinite(saved.position) ? saved.position : 0;
|
|
979
1175
|
const savedDur = Number.isFinite(saved.duration) && saved.duration > 0 ? saved.duration : 0;
|
|
@@ -984,7 +1180,7 @@ window.__ModuleLoader__.load({
|
|
|
984
1180
|
set({
|
|
985
1181
|
currentId: track.id, currentName: track.name,
|
|
986
1182
|
position: pos, duration: savedDur,
|
|
987
|
-
pendingId: null, pendingName: null, error: null,
|
|
1183
|
+
pendingId: null, pendingName: null, error: null, scope,
|
|
988
1184
|
});
|
|
989
1185
|
if (pos > 0) {
|
|
990
1186
|
restoredMusicPos = pos;
|
|
@@ -1077,6 +1273,7 @@ window.__ModuleLoader__.load({
|
|
|
1077
1273
|
set({
|
|
1078
1274
|
root: result.root || null, bookRoot: result.bookRoot || null,
|
|
1079
1275
|
tracks: result.tracks || [], books: result.books || [],
|
|
1276
|
+
playlists: result.playlists || [],
|
|
1080
1277
|
count: result.count || 0, loading: false, error: result.error || null,
|
|
1081
1278
|
ttsConfigured: !!result.ttsConfigured, ttsReason: result.ttsReason || '',
|
|
1082
1279
|
voices: Array.isArray(result.voices) ? result.voices : [],
|
|
@@ -1085,6 +1282,7 @@ window.__ModuleLoader__.load({
|
|
|
1085
1282
|
// Envelope (spectrum) decoding is deferred to actual playback — no need
|
|
1086
1283
|
// to decode several full files eagerly at page load; the current track's
|
|
1087
1284
|
// envelope decodes on play (startPlay / resume).
|
|
1285
|
+
restoreScope(result.playlists || []);
|
|
1088
1286
|
restoreLatest(list);
|
|
1089
1287
|
} catch (err) {
|
|
1090
1288
|
set({ loading: false, error: '\u65e0\u6cd5\u8bfb\u53d6\u97f3\u4e50\u5e93\uff1a' + String((err && err.message) || err) });
|
|
@@ -1261,7 +1459,7 @@ window.__ModuleLoader__.load({
|
|
|
1261
1459
|
vizBadge = React.createElement('button', {
|
|
1262
1460
|
className: 'dsh-music-bar-warn',
|
|
1263
1461
|
title: '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5',
|
|
1264
|
-
onClick: () => { const t =
|
|
1462
|
+
onClick: () => { const t = resolvePlayable(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
|
|
1265
1463
|
}, '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5');
|
|
1266
1464
|
}
|
|
1267
1465
|
const note = React.createElement(MusicNote, { className: 'dsh-music-note' });
|
|
@@ -1277,6 +1475,16 @@ window.__ModuleLoader__.load({
|
|
|
1277
1475
|
sectionBadge = React.createElement('span', { className: 'dsh-music-bar-section', title: s.currentSection },
|
|
1278
1476
|
'\u25b8 ' + s.currentSection);
|
|
1279
1477
|
}
|
|
1478
|
+
// 自建歌单:收藏爱心按钮(收藏时用主题色)。
|
|
1479
|
+
const faved = hasTrack && !isBook && isCurrentFaved();
|
|
1480
|
+
const heartBtn = hasTrack && !isBook ? React.createElement('button', {
|
|
1481
|
+
className: 'dsh-music-bar-btn fav' + (faved ? ' on' : ''),
|
|
1482
|
+
title: faved ? '取消收藏(从「我最喜欢」移除)' : '收藏到「我最喜欢」',
|
|
1483
|
+
onClick: toggleFav,
|
|
1484
|
+
}, React.createElement('svg', {
|
|
1485
|
+
viewBox: '0 0 24 24', width: 14, height: 14,
|
|
1486
|
+
fill: faved ? 'currentColor' : 'none', stroke: 'currentColor', strokeWidth: 2, 'aria-hidden': true,
|
|
1487
|
+
}, React.createElement('path', { d: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' }))) : null;
|
|
1280
1488
|
return React.createElement('div', { className: 'dsh-music-bar-wrap' },
|
|
1281
1489
|
React.createElement('div', { className: 'dsh-music-bar' + (isBook ? ' book' : '') },
|
|
1282
1490
|
hasTrack
|
|
@@ -1291,6 +1499,7 @@ window.__ModuleLoader__.load({
|
|
|
1291
1499
|
? React.createElement('span', { className: 'dsh-music-bar-hint' }, '\u26a0 \u81ea\u52a8\u64ad\u653e\u88ab\u62e6\u622a\uff0c\u70b9\u51fb\u25b6\u89e3\u9501')
|
|
1292
1500
|
: React.createElement('span', { className: 'dsh-music-bar-time' }, fmtTime(s.position) + ' / ' + fmtTime(s.duration)))
|
|
1293
1501
|
: null,
|
|
1502
|
+
heartBtn,
|
|
1294
1503
|
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '\u4e0a\u4e00\u7ae0' : '\u4e0a\u4e00\u9996', onClick: () => (isBook ? stepBook(-1) : step(-1)) }, '\u23ee') : null,
|
|
1295
1504
|
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '\u64ad\u653e/\u6682\u505c', onClick: togglePlay }, s.playing ? '\u23f8' : '\u25b6') : null,
|
|
1296
1505
|
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '\u4e0b\u4e00\u7ae0' : '\u4e0b\u4e00\u9996', onClick: () => (isBook ? stepBook(1) : step(1)) }, '\u23ed') : null,
|
|
@@ -1425,6 +1634,12 @@ window.__ModuleLoader__.load({
|
|
|
1425
1634
|
// Draggable panel position ({x, y, h} left/top/height once dragged; null = default right/bottom)
|
|
1426
1635
|
const [pos, setPos] = useState(loadPanelPos);
|
|
1427
1636
|
const dragRef = useRef(null);
|
|
1637
|
+
// 曲库每行「+」打开的「加入歌单」菜单:{track, x, y}(锚点=按钮右上角视口坐标)。
|
|
1638
|
+
const [addMenu, setAddMenu] = useState(null);
|
|
1639
|
+
const openAddMenu = (track, e) => {
|
|
1640
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
1641
|
+
setAddMenu({ track, x: r.right, y: r.top });
|
|
1642
|
+
};
|
|
1428
1643
|
|
|
1429
1644
|
// Once the panel is dragged we switch from CSS right/bottom anchoring to an
|
|
1430
1645
|
// explicit left/top/height. Locking the height matters: with only top+left set
|
|
@@ -1485,18 +1700,26 @@ window.__ModuleLoader__.load({
|
|
|
1485
1700
|
const active = list.querySelector('.dsh-music-track.active');
|
|
1486
1701
|
if (active !== null && typeof active.scrollIntoView === 'function') active.scrollIntoView({ block: 'nearest' });
|
|
1487
1702
|
}, [s.panelOpen, s.currentId]);
|
|
1703
|
+
// 面板关闭时清掉「加入歌单」弹层状态,避免重开面板时残留。
|
|
1704
|
+
useEffect(() => { if (!s.panelOpen) setAddMenu(null); }, [s.panelOpen]);
|
|
1488
1705
|
if (!s.panelOpen) return null;
|
|
1489
1706
|
const rows = s.tracks.map((t) => {
|
|
1490
1707
|
const active = t.id === s.currentId;
|
|
1491
1708
|
const playing = active && s.playing;
|
|
1492
|
-
return React.createElement('
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1709
|
+
return React.createElement('div', { key: t.id, className: 'dsh-music-track-row' + (active ? ' active' : '') },
|
|
1710
|
+
React.createElement('button', {
|
|
1711
|
+
className: 'dsh-music-track' + (active ? ' active' : ''),
|
|
1712
|
+
title: t.url,
|
|
1713
|
+
onClick: () => { if (active) togglePlay(); else startPlayFrom(t.id, 'library'); },
|
|
1714
|
+
},
|
|
1715
|
+
React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + t.name),
|
|
1716
|
+
React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
|
|
1717
|
+
),
|
|
1718
|
+
React.createElement('button', {
|
|
1719
|
+
className: 'dsh-music-playlist-mini add',
|
|
1720
|
+
title: '加入歌单',
|
|
1721
|
+
onClick: (e) => { e.stopPropagation(); openAddMenu(t, e); },
|
|
1722
|
+
}, '\uff0b'),
|
|
1500
1723
|
);
|
|
1501
1724
|
});
|
|
1502
1725
|
const bookRows = s.books.map((b) => {
|
|
@@ -1516,8 +1739,29 @@ window.__ModuleLoader__.load({
|
|
|
1516
1739
|
className: 'dsh-music-tab' + (s.tab === key ? ' active' : ''),
|
|
1517
1740
|
onClick: () => set({ tab: key }),
|
|
1518
1741
|
}, label);
|
|
1742
|
+
// 音乐页子标签:曲库 / 我最喜欢 / + / 自建歌单
|
|
1743
|
+
const subTabBtn = (key, label, extraCls, rkey) => React.createElement('button', {
|
|
1744
|
+
key: rkey,
|
|
1745
|
+
className: 'dsh-music-subtab' + (s.subTab === key ? ' active' : '') + (extraCls ? ' ' + extraCls : ''),
|
|
1746
|
+
title: label,
|
|
1747
|
+
onClick: () => set({ subTab: key }),
|
|
1748
|
+
}, label);
|
|
1749
|
+
const musicSubTabs = React.createElement('div', { className: 'dsh-music-subtabs' },
|
|
1750
|
+
subTabBtn('library', '\u66f2\u5e93'),
|
|
1751
|
+
subTabBtn(FAV_PLAYLIST_ID, '\u2665 \u6211\u6700\u559c\u6b22'),
|
|
1752
|
+
// 自建歌单排在 + 号之前;+ 固定在末尾用于新建。
|
|
1753
|
+
(s.playlists || []).filter((p) => p.id !== FAV_PLAYLIST_ID).map((p) => subTabBtn(p.id, p.name, null, p.id)),
|
|
1754
|
+
React.createElement('button', { className: 'dsh-music-subtab add', title: '新建歌单', onClick: onCreatePlaylist }, '\uff0b'),
|
|
1755
|
+
);
|
|
1756
|
+
const isPlaylistView = s.subTab !== 'library';
|
|
1757
|
+
const plView = isPlaylistView ? playlistById(s.subTab) : null;
|
|
1758
|
+
const musicBody = plView
|
|
1759
|
+
? React.createElement(PlaylistDetail, { pl: plView })
|
|
1760
|
+
: (rows.length > 0
|
|
1761
|
+
? rows
|
|
1762
|
+
: React.createElement('div', { className: 'dsh-music-empty' }, '\u6682\u65e0\u97f3\u4e50\u3002\u70b9\u51fb\u4e0a\u65b9\u201c\u9009\u62e9\u97f3\u4e50\u76ee\u5f55\u201d\u5e76\u9009\u62e9\u76ee\u5f55\u540e\u81ea\u52a8\u626b\u63cf\u3002'));
|
|
1519
1763
|
const listBody = s.tab === 'music'
|
|
1520
|
-
?
|
|
1764
|
+
? musicBody
|
|
1521
1765
|
: (s.books.length > 0
|
|
1522
1766
|
? bookRows
|
|
1523
1767
|
: (s.ttsConfigured
|
|
@@ -1533,11 +1777,16 @@ window.__ModuleLoader__.load({
|
|
|
1533
1777
|
React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: () => set({ panelOpen: false }) }, '\u2715')),
|
|
1534
1778
|
React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '\u97f3\u4e50'), tabBtn('book', '\u5c0f\u8bf4')),
|
|
1535
1779
|
React.createElement(DirectorySetting, null),
|
|
1780
|
+
s.tab === 'music' ? musicSubTabs : null,
|
|
1536
1781
|
// While a novel is playing, keep music-only errors/scanning out of the
|
|
1537
1782
|
// panel (novel status shows on the playback bar instead).
|
|
1538
1783
|
!isBook && s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
|
|
1539
1784
|
!isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '\u626b\u63cf\u4e2d\u2026') : null,
|
|
1540
1785
|
React.createElement('div', { className: 'dsh-music-list', ref: (el) => { listRef.current = el; } }, listBody),
|
|
1786
|
+
addMenu ? React.createElement(AddToPlaylistMenu, {
|
|
1787
|
+
track: addMenu.track, anchor: { x: addMenu.x, y: addMenu.y },
|
|
1788
|
+
onClose: () => setAddMenu(null),
|
|
1789
|
+
}) : null,
|
|
1541
1790
|
);
|
|
1542
1791
|
}
|
|
1543
1792
|
// Directory setting block, embedded in the player panel (the former
|
|
@@ -1630,6 +1879,151 @@ window.__ModuleLoader__.load({
|
|
|
1630
1879
|
saveRoot(p, isBook ? 'book' : 'music');
|
|
1631
1880
|
}
|
|
1632
1881
|
}
|
|
1882
|
+
// 「加入歌单」弹层:曲库每行「+」点击后出现,列出所有歌单(含我最喜欢)并可新建。
|
|
1883
|
+
// 用 fixed 定位(锚点为按钮视口坐标),避免被面板滚动列表裁剪。
|
|
1884
|
+
function AddToPlaylistMenu({ track, anchor, onClose }) {
|
|
1885
|
+
const ref = useRef(null);
|
|
1886
|
+
useEffect(() => {
|
|
1887
|
+
const onDown = (e) => { if (ref.current !== null && !ref.current.contains(e.target)) onClose(); };
|
|
1888
|
+
document.addEventListener('mousedown', onDown);
|
|
1889
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
1890
|
+
}, [onClose]);
|
|
1891
|
+
const openUp = (anchor.y || 0) > ((window.innerHeight || 0) - 240);
|
|
1892
|
+
const style = {
|
|
1893
|
+
left: Math.max(8, (anchor.x || 0) - 150),
|
|
1894
|
+
top: openUp ? (anchor.y || 0) - 6 : (anchor.y || 0) + 8,
|
|
1895
|
+
transform: openUp ? 'translateY(-100%)' : 'none',
|
|
1896
|
+
};
|
|
1897
|
+
const list = store.playlists || [];
|
|
1898
|
+
const addTo = (id) => { apiPlaylistAdd(id, [track.path], () => onClose()); };
|
|
1899
|
+
const addNew = () => {
|
|
1900
|
+
const name = window.prompt('新建歌单名称', '');
|
|
1901
|
+
if (name === null) return;
|
|
1902
|
+
const trimmed = name.trim();
|
|
1903
|
+
if (trimmed === '') return;
|
|
1904
|
+
fetch('/dsh-music/playlist', {
|
|
1905
|
+
method: 'POST', cache: 'no-store',
|
|
1906
|
+
headers: { 'content-type': 'application/json' },
|
|
1907
|
+
body: JSON.stringify({ name: trimmed }),
|
|
1908
|
+
}).then((r) => r.json()).then((r) => {
|
|
1909
|
+
if (r && r.playlist) {
|
|
1910
|
+
set({ playlists: [...(store.playlists || []), r.playlist] });
|
|
1911
|
+
apiPlaylistAdd(r.playlist.id, [track.path], () => onClose());
|
|
1912
|
+
}
|
|
1913
|
+
}).catch(() => onClose());
|
|
1914
|
+
};
|
|
1915
|
+
return React.createElement('div', { className: 'dsh-music-add-pop', ref, style },
|
|
1916
|
+
list.length > 0 ? list.map((p) => React.createElement('button', {
|
|
1917
|
+
key: p.id,
|
|
1918
|
+
className: 'dsh-music-add-pop-item',
|
|
1919
|
+
title: '加入「' + p.name + '」',
|
|
1920
|
+
onClick: () => addTo(p.id),
|
|
1921
|
+
}, (p.id === FAV_PLAYLIST_ID ? '\u2665 ' : '') + p.name + '\uff08' + p.count + '\uff09')) : null,
|
|
1922
|
+
React.createElement('button', { className: 'dsh-music-add-pop-item new', onClick: addNew }, '\uff0b \u65b0\u5efa\u6b4c\u5355'),
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
// 歌单详情:添加歌曲 + 重命名/删除 + 歌曲列表(移除/上移/下移)。
|
|
1926
|
+
function PlaylistDetail({ pl }) {
|
|
1927
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
1928
|
+
const rows = (pl.tracks || []).map((t, idx) => {
|
|
1929
|
+
const active = t.id === store.currentId;
|
|
1930
|
+
const playing = active && store.playing;
|
|
1931
|
+
return React.createElement('div', { key: t.id, className: 'dsh-music-playlist-row' + (active ? ' active' : '') },
|
|
1932
|
+
React.createElement('button', {
|
|
1933
|
+
className: 'dsh-music-track',
|
|
1934
|
+
title: t.url,
|
|
1935
|
+
onClick: () => { if (active) togglePlay(); else startPlayFrom(t.id, 'playlist', pl.id); },
|
|
1936
|
+
},
|
|
1937
|
+
React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + (idx + 1) + '. ' + t.name),
|
|
1938
|
+
React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
|
|
1939
|
+
),
|
|
1940
|
+
React.createElement('button', { className: 'dsh-music-playlist-mini', title: '上移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, -1); } }, '\u2191'),
|
|
1941
|
+
React.createElement('button', { className: 'dsh-music-playlist-mini', title: '下移', onClick: (e) => { e.stopPropagation(); movePlaylistTrack(pl, t.path, 1); } }, '\u2193'),
|
|
1942
|
+
React.createElement('button', { className: 'dsh-music-playlist-mini del', title: '从歌单移除', onClick: (e) => { e.stopPropagation(); apiPlaylistRemove(pl.id, [t.path]); } }, '\u00d7'),
|
|
1943
|
+
);
|
|
1944
|
+
});
|
|
1945
|
+
return React.createElement('div', { className: 'dsh-music-playlist' },
|
|
1946
|
+
React.createElement('div', { className: 'dsh-music-playlist-head' },
|
|
1947
|
+
React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => setPickerOpen(true) }, '\uff0b \u6dfb\u52a0\u6b4c\u66f2'),
|
|
1948
|
+
React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => onClearPlaylist(pl) }, '清空'),
|
|
1949
|
+
!pl.fixed ? React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => onRenamePlaylist(pl) }, '重命名') : null,
|
|
1950
|
+
!pl.fixed ? React.createElement('button', { className: 'dsh-music-playlist-btn', onClick: () => onDeletePlaylist(pl) }, '删除') : null,
|
|
1951
|
+
pl.missing > 0 ? React.createElement('span', { className: 'dsh-music-playlist-missing', title: '部分歌曲文件已被移动或删除' }, pl.missing + ' 首已失效') : null,
|
|
1952
|
+
),
|
|
1953
|
+
rows.length > 0 ? rows : React.createElement('div', { className: 'dsh-music-empty dsh-music-playlist-empty' }, '歌单为空,点击「添加歌曲」从本地文件选择音乐。'),
|
|
1954
|
+
pickerOpen ? React.createElement(FilePicker, { pl, onClose: () => setPickerOpen(false) }) : null,
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
// 文件系统多选器:浏览目录 + 勾选音频文件,用于歌单「添加歌曲」。
|
|
1958
|
+
function FilePicker({ pl, onClose }) {
|
|
1959
|
+
const [cur, setCur] = useState({ path: '', name: '', up: null, dirs: [], files: [] });
|
|
1960
|
+
const [sel, setSel] = useState(new Set());
|
|
1961
|
+
const [err, setErr] = useState(null);
|
|
1962
|
+
const [busy, setBusy] = useState(false);
|
|
1963
|
+
const browse = async (p) => {
|
|
1964
|
+
setErr(null);
|
|
1965
|
+
try {
|
|
1966
|
+
const data = await jsonGet('/dsh-music/files?path=' + encodeURIComponent(p || ''));
|
|
1967
|
+
if (data && data.error) { setErr(data.error); return; }
|
|
1968
|
+
setCur({ path: data.path || '', name: data.name || '', up: data.up || null, dirs: data.dirs || [], files: data.files || [] });
|
|
1969
|
+
} catch (e) { setErr('读取目录失败:' + String((e && e.message) || e)); }
|
|
1970
|
+
};
|
|
1971
|
+
// 默认定位到音乐目录(store.root),未配置时回退家目录。
|
|
1972
|
+
useEffect(() => { browse(store.root || ''); }, []);
|
|
1973
|
+
const toggle = (p) => {
|
|
1974
|
+
const next = new Set(sel);
|
|
1975
|
+
if (next.has(p)) next.delete(p); else next.add(p);
|
|
1976
|
+
setSel(next);
|
|
1977
|
+
};
|
|
1978
|
+
const confirmAdd = async () => {
|
|
1979
|
+
const paths = [...sel];
|
|
1980
|
+
if (paths.length === 0 || busy) { onClose(); return; }
|
|
1981
|
+
setBusy(true);
|
|
1982
|
+
apiPlaylistAdd(pl.id, paths, () => onClose());
|
|
1983
|
+
};
|
|
1984
|
+
return React.createElement('div', { className: 'dsh-music-picker-overlay' },
|
|
1985
|
+
React.createElement('div', { className: 'dsh-music-picker' },
|
|
1986
|
+
React.createElement('div', { className: 'dsh-music-picker-head' },
|
|
1987
|
+
React.createElement('span', { className: 'dsh-music-picker-title' }, '\u6dfb\u52a0\u6b4c\u66f2\u5230\u300c' + pl.name + '\u300d'),
|
|
1988
|
+
),
|
|
1989
|
+
React.createElement('div', { className: 'dsh-music-picker-cur', title: cur.path }, cur.name || cur.path || '\u5bb6\u76ee\u5f55'),
|
|
1990
|
+
React.createElement('div', { className: 'dsh-music-picker-list' },
|
|
1991
|
+
(cur.dirs || []).map((d) => React.createElement('button', {
|
|
1992
|
+
key: d.path, className: 'dsh-music-picker-item', title: d.path,
|
|
1993
|
+
onClick: () => browse(d.path),
|
|
1994
|
+
}, '\ud83d\udcc1 ' + d.name)),
|
|
1995
|
+
(cur.files || []).map((f) => {
|
|
1996
|
+
const checked = sel.has(f.path);
|
|
1997
|
+
return React.createElement('button', {
|
|
1998
|
+
key: f.path,
|
|
1999
|
+
className: 'dsh-music-file-item' + (checked ? ' checked' : ''),
|
|
2000
|
+
title: f.path,
|
|
2001
|
+
onClick: () => toggle(f.path),
|
|
2002
|
+
},
|
|
2003
|
+
React.createElement('span', { className: 'dsh-music-file-check' }, checked ? '\u2713' : ''),
|
|
2004
|
+
React.createElement('span', { className: 'dsh-music-file-name' }, f.name),
|
|
2005
|
+
React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(f.size)),
|
|
2006
|
+
);
|
|
2007
|
+
}),
|
|
2008
|
+
err ? React.createElement('div', { className: 'dsh-music-error' }, err) : null,
|
|
2009
|
+
),
|
|
2010
|
+
React.createElement('div', { className: 'dsh-music-picker-foot' },
|
|
2011
|
+
React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: () => goUp() }, '上一级'),
|
|
2012
|
+
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: confirmAdd, disabled: busy }, '确定添加(' + sel.size + ')'),
|
|
2013
|
+
React.createElement('button', { className: 'dsh-music-settings-btn ghost', onClick: onClose }, '取消'),
|
|
2014
|
+
),
|
|
2015
|
+
),
|
|
2016
|
+
);
|
|
2017
|
+
function goUp() {
|
|
2018
|
+
const u = cur.up;
|
|
2019
|
+
if (u === '__drives__') { browse('__drives__'); return; }
|
|
2020
|
+
if (u) { browse(u); return; }
|
|
2021
|
+
if (cur.path === '' || cur.path === '/' || /^[A-Za-z]:[\\/]?$/.test(cur.path)) return;
|
|
2022
|
+
const idx = Math.max(cur.path.lastIndexOf('/'), cur.path.lastIndexOf('\\'));
|
|
2023
|
+
if (idx <= 0) return;
|
|
2024
|
+
browse(cur.path.slice(0, idx));
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
1633
2027
|
|
|
1634
2028
|
const inject = ['slots'];
|
|
1635
2029
|
function apply(ctx) {
|
|
@@ -1648,6 +2042,7 @@ window.__ModuleLoader__.load({
|
|
|
1648
2042
|
attachAudioElements();
|
|
1649
2043
|
const unbind = bindAudio();
|
|
1650
2044
|
startRaf();
|
|
2045
|
+
const accentWatch = watchAccent();
|
|
1651
2046
|
// Browsers auto-release a wake lock when the page is hidden; re-acquire
|
|
1652
2047
|
// on return if playback is still running, and drop it on hide so we
|
|
1653
2048
|
// don't hold it while the tab is backgrounded.
|
|
@@ -1667,6 +2062,8 @@ window.__ModuleLoader__.load({
|
|
|
1667
2062
|
return () => {
|
|
1668
2063
|
window.removeEventListener('pagehide', onPageHide);
|
|
1669
2064
|
document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); releaseWakeLock();
|
|
2065
|
+
if (accentWatch !== null) accentWatch.disconnect();
|
|
2066
|
+
accentObserver = null;
|
|
1670
2067
|
};
|
|
1671
2068
|
}, 'music-player: audio + viz engine');
|
|
1672
2069
|
|
|
@@ -1693,6 +2090,14 @@ window.__ModuleLoader__.load({
|
|
|
1693
2090
|
if (store.currentId !== null && String(store.currentId).startsWith('book:')) stepBook(-1); else step(-1);
|
|
1694
2091
|
return;
|
|
1695
2092
|
}
|
|
2093
|
+
// play with a playlist: switch scope to that playlist and start it.
|
|
2094
|
+
if (intent.playlistId) {
|
|
2095
|
+
const pl = playlistById(intent.playlistId);
|
|
2096
|
+
if (pl && pl.tracks && pl.tracks.length > 0) {
|
|
2097
|
+
startPlayFrom(pl.tracks[0].id, 'playlist', pl.id);
|
|
2098
|
+
}
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
1696
2101
|
// play (default): needs an id — a book id (e.g. "b0") starts AI 讲书.
|
|
1697
2102
|
if (intent.id === undefined) return;
|
|
1698
2103
|
const book = bookById(intent.id);
|
|
@@ -1701,11 +2106,11 @@ window.__ModuleLoader__.load({
|
|
|
1701
2106
|
resumeOrPlayBook(book.id);
|
|
1702
2107
|
return;
|
|
1703
2108
|
}
|
|
1704
|
-
const track =
|
|
2109
|
+
const track = resolvePlayable(intent.id);
|
|
1705
2110
|
if (track !== null) {
|
|
1706
2111
|
audio.src = track.url;
|
|
1707
2112
|
audio.load();
|
|
1708
|
-
set({ currentId: intent.id, currentName: track.name, error: null });
|
|
2113
|
+
set({ currentId: intent.id, currentName: track.name, error: null, scope: { kind: 'library' } });
|
|
1709
2114
|
loadEnvelope(intent.id, track.url);
|
|
1710
2115
|
prefetchNext();
|
|
1711
2116
|
savePlayback();
|
|
@@ -1740,8 +2145,13 @@ window.__ModuleLoader__.load({
|
|
|
1740
2145
|
const PLAYER_CSS = '\n' +
|
|
1741
2146
|
// Accent follows the host app's theme brand color (stable from the start —
|
|
1742
2147
|
// no green-default-to-sampled-blue flash); green is only the fallback when
|
|
1743
|
-
// the app exposes no brand color.
|
|
1744
|
-
|
|
2148
|
+
// the app exposes no brand color. The alias must be declared on BODY, not
|
|
2149
|
+
// :root: DSH defines its --dsw-alias-* theme tokens on <body> only, and a
|
|
2150
|
+
// var() reference resolves against the element that declares it — on
|
|
2151
|
+
// :root (html) it cannot see body's tokens and would always fall back to
|
|
2152
|
+
// green. Declared on body, the reference resolves and children inherit
|
|
2153
|
+
// the theme's actual brand color.
|
|
2154
|
+
'body { --dsh-music-accent: var(--dsw-alias-brand-primary, #2f9e6e); }\n' +
|
|
1745
2155
|
'.dsh-music-bar-wrap { box-sizing: border-box; width: 100%; padding: 0 var(--dsh-composer-side-clearance, 16px); }\n' +
|
|
1746
2156
|
'.dsh-music-bar { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; max-width: var(--dsh-composer-card-max-width, 780px); margin: 0 auto; padding: 4px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04)); border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); border-radius: 8px; }\n' +
|
|
1747
2157
|
'.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
|
|
@@ -1811,11 +2221,11 @@ window.__ModuleLoader__.load({
|
|
|
1811
2221
|
'.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' +
|
|
1812
2222
|
'.dsh-music-settings-btn { padding: 6px 12px; border-radius: 8px; border: none; background: var(--dsh-music-accent, #2f9e6e); color: #fff; cursor: pointer; font-size: 13px; white-space: nowrap; }\n' +
|
|
1813
2223
|
'.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' +
|
|
1814
|
-
'.dsh-music-picker-overlay { position: absolute; inset: 0; z-index: 70; display: flex;
|
|
1815
|
-
'.dsh-music-picker { box-sizing: border-box; width: 88%; max-height:
|
|
1816
|
-
'.dsh-music-picker-head { display: flex; align-items: center; }\n' +
|
|
2224
|
+
'.dsh-music-picker-overlay { position: absolute; inset: 0; z-index: 70; display: flex; overflow: auto; padding: 16px; background: rgba(0,0,0,0.45); }\n' +
|
|
2225
|
+
'.dsh-music-picker { box-sizing: border-box; width: 88%; 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' +
|
|
2226
|
+
'.dsh-music-picker-head { display: flex; align-items: center; flex: none; }\n' +
|
|
1817
2227
|
'.dsh-music-picker-title { font-weight: 600; }\n' +
|
|
1818
|
-
'.dsh-music-picker-cur { font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
|
|
2228
|
+
'.dsh-music-picker-cur { flex: none; font-size: 12px; color: var(--dsw-alias-label-secondary, #8a8f98); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
|
|
1819
2229
|
'.dsh-music-picker-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }\n' +
|
|
1820
2230
|
'.dsh-music-picker-item { text-align: left; padding: 6px 8px; border: none; background: transparent; border-radius: 6px; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 13px; }\n' +
|
|
1821
2231
|
'.dsh-music-picker-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
|
|
@@ -1834,7 +2244,42 @@ window.__ModuleLoader__.load({
|
|
|
1834
2244
|
'.dsh-music-toc-item.active { color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
1835
2245
|
'.dsh-music-toc-type { flex: none; font-size: 10px; padding: 1px 5px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.08)); color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
1836
2246
|
'.dsh-music-toc-item.active .dsh-music-toc-type { background: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
|
|
1837
|
-
'.dsh-music-toc-heading { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n'
|
|
2247
|
+
'.dsh-music-toc-heading { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
|
|
2248
|
+
// 自建歌单:音乐页子标签 / 歌单详情 / 文件多选 / 播放条收藏
|
|
2249
|
+
'.dsh-music-subtabs { display: flex; gap: 4px; flex-wrap: wrap; }\n' +
|
|
2250
|
+
'.dsh-music-subtab { flex: none; padding: 4px 10px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); background: transparent; border-radius: 16px; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
|
|
2251
|
+
'.dsh-music-subtab:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
|
|
2252
|
+
'.dsh-music-subtab.active { background: var(--dsh-music-accent, #2f9e6e); border-color: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
|
|
2253
|
+
'.dsh-music-subtab.add { width: 30px; padding: 4px 0; text-align: center; color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2254
|
+
'.dsh-music-playlist { display: flex; flex-direction: column; flex: 1; }\n' +
|
|
2255
|
+
'.dsh-music-playlist-empty { flex: 1; display: flex; align-items: center; justify-content: center; }\n' +
|
|
2256
|
+
'.dsh-music-playlist-head { display: flex; align-items: center; gap: 6px; padding: 2px 2px 0; }\n' +
|
|
2257
|
+
'.dsh-music-playlist-btn { flex: none; background: transparent; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3)); border-radius: 6px; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 11px; padding: 2px 8px; }\n' +
|
|
2258
|
+
'.dsh-music-playlist-btn:hover { color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2259
|
+
'.dsh-music-playlist-missing { flex: none; margin-left: auto; font-size: 11px; color: var(--dsw-alias-state-warn-primary, #d9a441); }\n' +
|
|
2260
|
+
'.dsh-music-playlist-row { display: flex; align-items: center; gap: 4px; }\n' +
|
|
2261
|
+
'.dsh-music-playlist-row .dsh-music-track { flex: 1; min-width: 0; }\n' +
|
|
2262
|
+
'.dsh-music-playlist-row.active .dsh-music-track { color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2263
|
+
'.dsh-music-playlist-mini { flex: none; width: 20px; height: 20px; padding: 0; border: none; background: transparent; border-radius: 4px; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; line-height: 1; }\n' +
|
|
2264
|
+
'.dsh-music-playlist-mini:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2265
|
+
'.dsh-music-playlist-mini.del:hover { color: var(--dsw-alias-state-error-primary, #e5534b); }\n' +
|
|
2266
|
+
'.dsh-music-file-item { 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' +
|
|
2267
|
+
'.dsh-music-file-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
|
|
2268
|
+
'.dsh-music-file-item.checked { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.1)); color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2269
|
+
'.dsh-music-file-check { flex: none; width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.4)); display: inline-flex; align-items: center; justify-content: center; font-size: 10px; }\n' +
|
|
2270
|
+
'.dsh-music-file-item.checked .dsh-music-file-check { background: var(--dsh-music-accent, #2f9e6e); border-color: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
|
|
2271
|
+
'.dsh-music-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
|
|
2272
|
+
// 曲库每行:track 按钮 + 行尾「+」(加入歌单)
|
|
2273
|
+
'.dsh-music-track-row { display: flex; align-items: center; gap: 4px; }\n' +
|
|
2274
|
+
'.dsh-music-track-row .dsh-music-track { flex: 1; min-width: 0; }\n' +
|
|
2275
|
+
'.dsh-music-track-row.active .dsh-music-track { color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2276
|
+
'.dsh-music-playlist-mini.add { color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2277
|
+
'.dsh-music-add-pop { position: fixed; z-index: 1200; min-width: 150px; max-width: 210px; display: flex; flex-direction: column; gap: 2px; padding: 6px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.3); }\n' +
|
|
2278
|
+
'.dsh-music-add-pop-item { display: block; width: 100%; text-align: left; padding: 5px 8px; border: none; background: transparent; border-radius: 6px; color: var(--dsw-alias-label-primary, #e6e6e6); cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n' +
|
|
2279
|
+
'.dsh-music-add-pop-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsh-music-accent, #2f9e6e); }\n' +
|
|
2280
|
+
'.dsh-music-add-pop-item.new { color: var(--dsh-music-accent, #2f9e6e); border-top: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); margin-top: 2px; padding-top: 6px; }\n' +
|
|
2281
|
+
'.dsh-music-bar-btn.fav { color: var(--dsw-alias-label-secondary, #8a8f98); }\n' +
|
|
2282
|
+
'.dsh-music-bar-btn.fav.on { color: var(--dsh-music-accent, #2f9e6e); }\n';
|
|
1838
2283
|
|
|
1839
2284
|
return module.exports;
|
|
1840
2285
|
},
|
package/lib/index.js
CHANGED
|
@@ -352,12 +352,89 @@ export function apply(ctx) {
|
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
const publicTracks = () => tracks.map((t) => ({
|
|
355
|
-
id: t.id, name: t.name, url: t.url, size: t.size, ext: t.ext,
|
|
355
|
+
id: t.id, name: t.name, url: t.url, size: t.size, ext: t.ext, path: t.path,
|
|
356
356
|
}))
|
|
357
357
|
const publicBooks = () => books.map((b) => ({
|
|
358
358
|
id: b.id, name: b.name, size: b.size, url: '/dsh-music/book/' + b.id,
|
|
359
359
|
}))
|
|
360
360
|
|
|
361
|
+
// ---- 自建歌单(playlists)----
|
|
362
|
+
// 歌单数据独立持久化到 ~/.dsh/music-player-playlists.json。成员以「绝对路径」为稳定键,
|
|
363
|
+
// 不受曲库重扫的 id 变化影响;「我最喜欢」(pl-fav) 是系统默认歌单,首次启动自动创建。
|
|
364
|
+
const FAV_PLAYLIST_ID = 'pl-fav'
|
|
365
|
+
const FAV_PLAYLIST_NAME = '我最喜欢'
|
|
366
|
+
let playlists = [] // [{id,name,fixed,trackPaths:[absPath],createdAt,updatedAt}]
|
|
367
|
+
|
|
368
|
+
const playlistsFile = async () => {
|
|
369
|
+
const h = await getHome()
|
|
370
|
+
const base = (process.env.DSH_HOME) || (h === null ? null : h + '/.dsh')
|
|
371
|
+
return base === null ? null : base + '/music-player-playlists.json'
|
|
372
|
+
}
|
|
373
|
+
const loadPlaylists = async () => {
|
|
374
|
+
const file = await playlistsFile()
|
|
375
|
+
let list = []
|
|
376
|
+
if (file !== null) {
|
|
377
|
+
try {
|
|
378
|
+
const text = readFileSync(file, 'utf8')
|
|
379
|
+
const data = JSON.parse(text)
|
|
380
|
+
if (data && Array.isArray(data.playlists)) list = data.playlists
|
|
381
|
+
} catch { /* unreadable -> start with the system playlist only */ }
|
|
382
|
+
}
|
|
383
|
+
list = list
|
|
384
|
+
.filter((p) => p && typeof p === 'object' && typeof p.id === 'string' && typeof p.name === 'string')
|
|
385
|
+
.map((p) => ({
|
|
386
|
+
id: p.id, name: p.name, fixed: !!p.fixed,
|
|
387
|
+
trackPaths: Array.isArray(p.trackPaths)
|
|
388
|
+
? p.trackPaths.filter((x) => typeof x === 'string' && x !== '')
|
|
389
|
+
: [],
|
|
390
|
+
createdAt: p.createdAt || 0, updatedAt: p.updatedAt || 0,
|
|
391
|
+
}))
|
|
392
|
+
// 系统默认歌单「我最喜欢」恒存在(固定第二位)。
|
|
393
|
+
if (!list.some((p) => p.id === FAV_PLAYLIST_ID)) {
|
|
394
|
+
list.unshift({ id: FAV_PLAYLIST_ID, name: FAV_PLAYLIST_NAME, fixed: true, trackPaths: [], createdAt: Date.now(), updatedAt: Date.now() })
|
|
395
|
+
}
|
|
396
|
+
playlists = list
|
|
397
|
+
}
|
|
398
|
+
const savePlaylists = async () => {
|
|
399
|
+
const file = await playlistsFile()
|
|
400
|
+
if (file === null) return
|
|
401
|
+
try {
|
|
402
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
403
|
+
writeFileSync(file, JSON.stringify({ version: 1, playlists }, null, 2) + '\n', 'utf8')
|
|
404
|
+
} catch { /* best-effort */ }
|
|
405
|
+
}
|
|
406
|
+
const findPlaylist = (id) => playlists.find((p) => p.id === id) || null
|
|
407
|
+
// 把歌单成员(绝对路径)解析为可播放对象;文件已删/不可读 -> null(计入 missing)。
|
|
408
|
+
const resolvePlaylistMember = (path) => {
|
|
409
|
+
try {
|
|
410
|
+
const st = statSync(path)
|
|
411
|
+
if (!st.isFile()) return null
|
|
412
|
+
return {
|
|
413
|
+
id: 'p:' + path,
|
|
414
|
+
name: basename(path),
|
|
415
|
+
url: '/dsh-music/file?path=' + encodeURIComponent(path),
|
|
416
|
+
size: st.size || 0,
|
|
417
|
+
path,
|
|
418
|
+
}
|
|
419
|
+
} catch { return null }
|
|
420
|
+
}
|
|
421
|
+
const publicPlaylist = (p) => {
|
|
422
|
+
const members = p.trackPaths.map(resolvePlaylistMember).filter(Boolean)
|
|
423
|
+
return {
|
|
424
|
+
id: p.id, name: p.name, fixed: p.fixed,
|
|
425
|
+
count: members.length,
|
|
426
|
+
missing: p.trackPaths.length - members.length,
|
|
427
|
+
tracks: members,
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const publicPlaylists = () => playlists.map(publicPlaylist)
|
|
431
|
+
// /dsh-music/file 只放行已登记路径(歌单成员 ∪ 曲库扫描集),防任意文件访问。
|
|
432
|
+
const isRegisteredAudioPath = (path) => {
|
|
433
|
+
const inPlaylist = playlists.some((p) => p.trackPaths.includes(path))
|
|
434
|
+
const inLibrary = tracks.some((t) => t.path === path)
|
|
435
|
+
return inPlaylist || inLibrary
|
|
436
|
+
}
|
|
437
|
+
|
|
361
438
|
const scan = async (rootPath, kinds = { music: true, books: false }) => {
|
|
362
439
|
const target = await ctx.fs.resolve(rootPath)
|
|
363
440
|
const info = await ctx.fs.stat(target)
|
|
@@ -456,6 +533,7 @@ export function apply(ctx) {
|
|
|
456
533
|
if (broot === null) broot = root // default books = music dir
|
|
457
534
|
musicRoot = root
|
|
458
535
|
bookRoot = broot
|
|
536
|
+
await loadPlaylists()
|
|
459
537
|
try {
|
|
460
538
|
return await refresh()
|
|
461
539
|
} catch (err) {
|
|
@@ -882,6 +960,7 @@ export function apply(ctx) {
|
|
|
882
960
|
const ts = await ttsState()
|
|
883
961
|
writeJson(res, {
|
|
884
962
|
root: musicRoot, bookRoot, tracks: publicTracks(), books: publicBooks(), count: tracks.length,
|
|
963
|
+
playlists: publicPlaylists(),
|
|
885
964
|
ttsConfigured: ts.configured, ttsReason: ts.reason, voices: MIMO_VOICES,
|
|
886
965
|
})
|
|
887
966
|
return
|
|
@@ -1043,6 +1122,210 @@ export function apply(ctx) {
|
|
|
1043
1122
|
}
|
|
1044
1123
|
return
|
|
1045
1124
|
}
|
|
1125
|
+
// ---- 自建歌单 CRUD ----
|
|
1126
|
+
// POST /dsh-music/playlist {name} -> 新建
|
|
1127
|
+
if (pathname === '/dsh-music/playlist' && req.method === 'POST') {
|
|
1128
|
+
await ensureStarted()
|
|
1129
|
+
const body = await readBody(req)
|
|
1130
|
+
const name = body && typeof body.name === 'string' ? body.name.trim() : ''
|
|
1131
|
+
if (name === '') { writeJson(res, { ok: false, error: '歌单名不能为空' }, 400); return }
|
|
1132
|
+
const id = 'pl-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7)
|
|
1133
|
+
playlists.push({ id, name, fixed: false, trackPaths: [], createdAt: Date.now(), updatedAt: Date.now() })
|
|
1134
|
+
await savePlaylists()
|
|
1135
|
+
writeJson(res, { ok: true, playlist: publicPlaylist(findPlaylist(id)) })
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
// POST /dsh-music/playlist/rename {id,name}
|
|
1139
|
+
if (pathname === '/dsh-music/playlist/rename' && req.method === 'POST') {
|
|
1140
|
+
await ensureStarted()
|
|
1141
|
+
const body = await readBody(req)
|
|
1142
|
+
const pl = findPlaylist(body && typeof body.id === 'string' ? body.id : '')
|
|
1143
|
+
const name = body && typeof body.name === 'string' ? body.name.trim() : ''
|
|
1144
|
+
if (pl === null) { writeJson(res, { ok: false, error: '歌单不存在' }, 404); return }
|
|
1145
|
+
if (name === '') { writeJson(res, { ok: false, error: '歌单名不能为空' }, 400); return }
|
|
1146
|
+
if (pl.fixed) { writeJson(res, { ok: false, error: '系统默认歌单不可重命名' }, 400); return }
|
|
1147
|
+
pl.name = name
|
|
1148
|
+
pl.updatedAt = Date.now()
|
|
1149
|
+
await savePlaylists()
|
|
1150
|
+
writeJson(res, { ok: true, playlist: publicPlaylist(pl) })
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
// POST /dsh-music/playlist/delete {id}
|
|
1154
|
+
if (pathname === '/dsh-music/playlist/delete' && req.method === 'POST') {
|
|
1155
|
+
await ensureStarted()
|
|
1156
|
+
const body = await readBody(req)
|
|
1157
|
+
const id = body && typeof body.id === 'string' ? body.id : ''
|
|
1158
|
+
const idx = playlists.findIndex((p) => p.id === id)
|
|
1159
|
+
if (idx < 0) { writeJson(res, { ok: false, error: '歌单不存在' }, 404); return }
|
|
1160
|
+
if (playlists[idx].fixed) { writeJson(res, { ok: false, error: '系统默认歌单不可删除' }, 400); return }
|
|
1161
|
+
playlists.splice(idx, 1)
|
|
1162
|
+
await savePlaylists()
|
|
1163
|
+
writeJson(res, { ok: true })
|
|
1164
|
+
return
|
|
1165
|
+
}
|
|
1166
|
+
// POST /dsh-music/playlist/add {id,paths:[...]}(去重、保序追加、跳过无效/非音频)
|
|
1167
|
+
if (pathname === '/dsh-music/playlist/add' && req.method === 'POST') {
|
|
1168
|
+
await ensureStarted()
|
|
1169
|
+
const body = await readBody(req)
|
|
1170
|
+
const pl = findPlaylist(body && typeof body.id === 'string' ? body.id : '')
|
|
1171
|
+
const paths = Array.isArray(body && body.paths)
|
|
1172
|
+
? body.paths.filter((x) => typeof x === 'string' && x.trim() !== '')
|
|
1173
|
+
: []
|
|
1174
|
+
if (pl === null) { writeJson(res, { ok: false, error: '歌单不存在' }, 404); return }
|
|
1175
|
+
let added = 0
|
|
1176
|
+
for (const raw of paths) {
|
|
1177
|
+
const p = raw.trim()
|
|
1178
|
+
if (!isAudioName(p) || pl.trackPaths.includes(p)) continue
|
|
1179
|
+
try { const st = statSync(p); if (!st.isFile()) continue } catch { continue }
|
|
1180
|
+
pl.trackPaths.push(p)
|
|
1181
|
+
added++
|
|
1182
|
+
}
|
|
1183
|
+
if (added > 0) pl.updatedAt = Date.now()
|
|
1184
|
+
await savePlaylists()
|
|
1185
|
+
writeJson(res, { ok: true, added, playlist: publicPlaylist(pl) })
|
|
1186
|
+
return
|
|
1187
|
+
}
|
|
1188
|
+
// POST /dsh-music/playlist/remove {id,paths:[...]}
|
|
1189
|
+
if (pathname === '/dsh-music/playlist/remove' && req.method === 'POST') {
|
|
1190
|
+
await ensureStarted()
|
|
1191
|
+
const body = await readBody(req)
|
|
1192
|
+
const pl = findPlaylist(body && typeof body.id === 'string' ? body.id : '')
|
|
1193
|
+
const paths = new Set(Array.isArray(body && body.paths) ? body.paths : [])
|
|
1194
|
+
if (pl === null) { writeJson(res, { ok: false, error: '歌单不存在' }, 404); return }
|
|
1195
|
+
const before = pl.trackPaths.length
|
|
1196
|
+
pl.trackPaths = pl.trackPaths.filter((p) => !paths.has(p))
|
|
1197
|
+
if (pl.trackPaths.length !== before) pl.updatedAt = Date.now()
|
|
1198
|
+
await savePlaylists()
|
|
1199
|
+
writeJson(res, { ok: true, removed: before - pl.trackPaths.length, playlist: publicPlaylist(pl) })
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
// POST /dsh-music/playlist/clear {id}(一键清空:移出全部歌曲,含已失效;任何歌单都允许,fixed 也可清空)
|
|
1203
|
+
if (pathname === '/dsh-music/playlist/clear' && req.method === 'POST') {
|
|
1204
|
+
await ensureStarted()
|
|
1205
|
+
const body = await readBody(req)
|
|
1206
|
+
const pl = findPlaylist(body && typeof body.id === 'string' ? body.id : '')
|
|
1207
|
+
if (pl === null) { writeJson(res, { ok: false, error: '歌单不存在' }, 404); return }
|
|
1208
|
+
const cleared = pl.trackPaths.length
|
|
1209
|
+
pl.trackPaths = []
|
|
1210
|
+
pl.updatedAt = Date.now()
|
|
1211
|
+
await savePlaylists()
|
|
1212
|
+
writeJson(res, { ok: true, cleared, playlist: publicPlaylist(pl) })
|
|
1213
|
+
return
|
|
1214
|
+
}
|
|
1215
|
+
// POST /dsh-music/playlist/reorder {id,paths:[...]}(全量顺序替换,补回未提及的旧成员)
|
|
1216
|
+
if (pathname === '/dsh-music/playlist/reorder' && req.method === 'POST') {
|
|
1217
|
+
await ensureStarted()
|
|
1218
|
+
const body = await readBody(req)
|
|
1219
|
+
const pl = findPlaylist(body && typeof body.id === 'string' ? body.id : '')
|
|
1220
|
+
const paths = Array.isArray(body && body.paths) ? body.paths.filter((x) => typeof x === 'string') : []
|
|
1221
|
+
if (pl === null) { writeJson(res, { ok: false, error: '歌单不存在' }, 404); return }
|
|
1222
|
+
const current = new Set(pl.trackPaths)
|
|
1223
|
+
const reordered = paths.filter((p) => current.has(p))
|
|
1224
|
+
for (const p of pl.trackPaths) if (!reordered.includes(p)) reordered.push(p)
|
|
1225
|
+
pl.trackPaths = reordered
|
|
1226
|
+
pl.updatedAt = Date.now()
|
|
1227
|
+
await savePlaylists()
|
|
1228
|
+
writeJson(res, { ok: true, playlist: publicPlaylist(pl) })
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
// ---- 文件系统多选器:列目录 + 音频文件(歌单「添加歌曲」用)----
|
|
1232
|
+
// 与 /dsh-music/dir 相同的浏览体验(上级/跨盘符),但额外返回音频文件供多选勾选。
|
|
1233
|
+
if (pathname === '/dsh-music/files' && req.method === 'GET') {
|
|
1234
|
+
await ensureStarted()
|
|
1235
|
+
const raw = url.searchParams.get('path') || ''
|
|
1236
|
+
try {
|
|
1237
|
+
if (raw === '__drives__') {
|
|
1238
|
+
const isWin = typeof process !== 'undefined' && process.platform === 'win32'
|
|
1239
|
+
if (isWin) {
|
|
1240
|
+
const roots = []
|
|
1241
|
+
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
1242
|
+
const root = letter + ':\\'
|
|
1243
|
+
try { if (existsSync(root)) roots.push({ name: root, path: root }) } catch {}
|
|
1244
|
+
}
|
|
1245
|
+
writeJson(res, { path: '__drives__', name: '\u672c\u673a\u78c1\u76d8', up: null, dirs: roots, files: [] })
|
|
1246
|
+
} else {
|
|
1247
|
+
writeJson(res, { path: '/', name: '/', up: null, dirs: [], files: [] })
|
|
1248
|
+
}
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
const base = raw === '' ? ((await getHome()) || '/') : raw
|
|
1252
|
+
const expanded = base.startsWith('~/') ? ((await getHome()) || '') + '/' + base.slice(2) : base
|
|
1253
|
+
const target = await ctx.fs.resolve(expanded)
|
|
1254
|
+
const info = await ctx.fs.stat(target)
|
|
1255
|
+
if (info === undefined || info.type !== 'directory') {
|
|
1256
|
+
writeJson(res, { error: '目录不存在或不可读', path: expanded }, 400)
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
const abs = ctx.fs.processPath(target)
|
|
1260
|
+
const atRoot = pathParse(abs).dir === abs
|
|
1261
|
+
const up = atRoot ? (process.platform === 'win32' ? '__drives__' : null) : dirname(abs)
|
|
1262
|
+
const dirs = []
|
|
1263
|
+
const files = []
|
|
1264
|
+
for (const e of listEntries(abs)) {
|
|
1265
|
+
try {
|
|
1266
|
+
if (e.isDir) { dirs.push({ name: e.name, path: pathJoin(abs, e.name) }); continue }
|
|
1267
|
+
if (isAudioName(e.name)) {
|
|
1268
|
+
const st = statSync(pathJoin(abs, e.name))
|
|
1269
|
+
if (st.isFile()) {
|
|
1270
|
+
files.push({ name: e.name, path: pathJoin(abs, e.name), size: st.size || 0, ext: e.name.slice(e.name.lastIndexOf('.') + 1).toLowerCase() })
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
} catch { /* skip unreadable entries */ }
|
|
1274
|
+
}
|
|
1275
|
+
writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs, files })
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
writeJson(res, { error: String((err && err.message) || err) }, 500)
|
|
1278
|
+
}
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
// ---- 通用文件流式路由(歌单成员专用,Range/seek)----
|
|
1282
|
+
// 只放行已登记路径(任一歌单成员 ∪ 曲库扫描集)。直接用 node:fs 读写,
|
|
1283
|
+
// 避免 DSH ctx.fs 对工作区外路径的围栏(与讲书读取小说文件的方式一致)。
|
|
1284
|
+
if (pathname === '/dsh-music/file' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
1285
|
+
await ensureStarted()
|
|
1286
|
+
const rawPath = url.searchParams.get('path') || ''
|
|
1287
|
+
if (rawPath === '') { res.writeHead(400); res.end(); return }
|
|
1288
|
+
if (!isAudioName(rawPath) || !isRegisteredAudioPath(rawPath)) { res.writeHead(403); res.end(); return }
|
|
1289
|
+
let st
|
|
1290
|
+
try { st = statSync(rawPath) } catch { res.writeHead(404); res.end(); return }
|
|
1291
|
+
if (!st.isFile()) { res.writeHead(404); res.end(); return }
|
|
1292
|
+
const size = st.size || 0
|
|
1293
|
+
let start = 0
|
|
1294
|
+
let end = size - 1
|
|
1295
|
+
let status = 200
|
|
1296
|
+
const range = req.headers.range
|
|
1297
|
+
if (typeof range === 'string') {
|
|
1298
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim())
|
|
1299
|
+
if (m !== null && (m[1] !== '' || m[2] !== '')) {
|
|
1300
|
+
if (m[1] !== '') {
|
|
1301
|
+
start = parseInt(m[1], 10)
|
|
1302
|
+
end = m[2] !== '' ? Math.min(parseInt(m[2], 10), size - 1) : size - 1
|
|
1303
|
+
} else {
|
|
1304
|
+
start = Math.max(size - parseInt(m[2], 10), 0)
|
|
1305
|
+
end = size - 1
|
|
1306
|
+
}
|
|
1307
|
+
if (!Number.isFinite(start) || start > end || start >= size) {
|
|
1308
|
+
res.writeHead(416, { 'Content-Range': 'bytes */' + size })
|
|
1309
|
+
res.end()
|
|
1310
|
+
return
|
|
1311
|
+
}
|
|
1312
|
+
status = 206
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
const bytes = readFileSync(rawPath)
|
|
1316
|
+
const slice = bytes.slice(start, end + 1)
|
|
1317
|
+
const headers = {
|
|
1318
|
+
'Content-Type': audioType(rawPath),
|
|
1319
|
+
'Accept-Ranges': 'bytes',
|
|
1320
|
+
'Content-Length': String(end - start + 1),
|
|
1321
|
+
'Cache-Control': 'no-store',
|
|
1322
|
+
}
|
|
1323
|
+
if (status === 206) headers['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size
|
|
1324
|
+
res.writeHead(status, headers)
|
|
1325
|
+
if (req.method === 'HEAD') { res.end(); return }
|
|
1326
|
+
res.end(slice)
|
|
1327
|
+
return
|
|
1328
|
+
}
|
|
1046
1329
|
// audio streaming
|
|
1047
1330
|
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return }
|
|
1048
1331
|
// Ensure the library scan completes before resolving a track, so a
|
|
@@ -1100,12 +1383,13 @@ export function apply(ctx) {
|
|
|
1100
1383
|
const PLAY_ACTIONS = ['play', 'pause', 'resume', 'stop', 'next', 'prev']
|
|
1101
1384
|
const tool = {
|
|
1102
1385
|
name: 'music_play',
|
|
1103
|
-
description: '控制 DSH 本地音乐库与小说库的播放。播放时可按歌曲名/歌手/小说名关键词搜索并播放(不传 query
|
|
1386
|
+
description: '控制 DSH 本地音乐库与小说库的播放。播放时可按歌曲名/歌手/小说名关键词搜索并播放(不传 query 则播放第一首音乐,没有音乐则播放第一本小说),或按歌单名播放自建歌单(playlist 参数);也可用 action 执行暂停/继续/停止/下一首·下一章/上一首·上一章。',
|
|
1104
1387
|
parameters: {
|
|
1105
1388
|
type: 'object',
|
|
1106
1389
|
additionalProperties: false,
|
|
1107
1390
|
properties: {
|
|
1108
1391
|
query: { type: 'string', description: '歌曲名/歌手/小说名关键词,用于搜索并播放。仅当 action 为 play(默认)时使用,可留空' },
|
|
1392
|
+
playlist: { type: 'string', description: '歌单名关键词,播放指定自建歌单(含默认歌单「我最喜欢」)。仅当 action 为 play(默认)时使用,优先级高于 query,可留空' },
|
|
1109
1393
|
action: { type: 'string', enum: PLAY_ACTIONS, description: '要执行的动作:play 播放(默认)、pause 暂停、resume 继续、stop 停止、next 下一首/下一章、prev 上一首/上一章' },
|
|
1110
1394
|
},
|
|
1111
1395
|
},
|
|
@@ -1127,8 +1411,10 @@ export function apply(ctx) {
|
|
|
1127
1411
|
const musicCount = tracks.length
|
|
1128
1412
|
const bookCount = books.length
|
|
1129
1413
|
const action = args && typeof args.action === 'string' && PLAY_ACTIONS.includes(args.action) ? args.action : 'play'
|
|
1414
|
+
// 歌单播放不依赖曲库是否为空(歌单可含曲库外的本地文件),因此先于空库守卫判断。
|
|
1415
|
+
const playlistQuery = args && typeof args.playlist === 'string' ? args.playlist.trim().toLowerCase() : ''
|
|
1130
1416
|
|
|
1131
|
-
if (musicCount === 0 && bookCount === 0) {
|
|
1417
|
+
if (musicCount === 0 && bookCount === 0 && playlistQuery === '') {
|
|
1132
1418
|
const notice = '本地音乐库与小说库均为空。请打开播放列表面板,点击「选择音乐目录」/「选择小说目录」配置。'
|
|
1133
1419
|
return { action, played: false, track: '', matches: 0, count: 0, notice }
|
|
1134
1420
|
}
|
|
@@ -1145,6 +1431,25 @@ export function apply(ctx) {
|
|
|
1145
1431
|
return { action, played: false, track: '', matches: 0, count: Math.max(musicCount, bookCount), notice }
|
|
1146
1432
|
}
|
|
1147
1433
|
|
|
1434
|
+
// play with a playlist name: play the whole playlist (priority over query).
|
|
1435
|
+
if (playlistQuery !== '') {
|
|
1436
|
+
const pools = playlists.filter((p) => p.name.toLowerCase().includes(playlistQuery))
|
|
1437
|
+
const hit = playlists.find((p) => p.name.toLowerCase() === playlistQuery) || pools[0]
|
|
1438
|
+
if (hit === undefined) {
|
|
1439
|
+
const names = playlists.map((p) => p.name).join('、') || '(暂无歌单)'
|
|
1440
|
+
return { action, played: false, track: '', matches: 0, count: 0, notice: '没有找到歌单「' + (args && args.playlist) + '」。现有歌单:' + names }
|
|
1441
|
+
}
|
|
1442
|
+
const members = publicPlaylist(hit).tracks
|
|
1443
|
+
if (members.length === 0) {
|
|
1444
|
+
return { action, played: false, track: '', matches: 0, count: 0, notice: '歌单「' + hit.name + '」为空,请先在播放面板该歌单里点「添加歌曲」加入音乐。' }
|
|
1445
|
+
}
|
|
1446
|
+
pendingIntent = { action: 'play', playlistId: hit.id, playlistName: hit.name, id: members[0].id, name: members[0].name }
|
|
1447
|
+
return {
|
|
1448
|
+
action, played: true, track: members[0].name, matches: members.length, count: members.length,
|
|
1449
|
+
notice: '已请求播放歌单「' + hit.name + '」(' + members.length + ' 首)。若被拦截请点 ▶ 解锁。',
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1148
1453
|
// play: search both music tracks and novels (.txt). A book-only match
|
|
1149
1454
|
// starts AI 讲书; otherwise fall back to the music behaviour.
|
|
1150
1455
|
const query = args && typeof args.query === 'string' ? args.query.trim().toLowerCase() : ''
|
|
@@ -1197,7 +1502,7 @@ export function apply(ctx) {
|
|
|
1197
1502
|
// ---- light prompt hint so the agent knows it can play local music/novels ----
|
|
1198
1503
|
ctx.systemPrompt.section({
|
|
1199
1504
|
name: 'tool:music-player', order: 116,
|
|
1200
|
-
text: '本机已挂载本地音乐播放器与 AI 讲书:可用 music_play 工具按关键词播放 ~/Music
|
|
1505
|
+
text: '本机已挂载本地音乐播放器与 AI 讲书:可用 music_play 工具按关键词播放 ~/Music(或设置的目录)里的音乐,或按歌单名播放自建歌单(playlist 参数),或按小说名播放本地 .txt 小说(AI 讲书,需配置 xiaomi/MiMo TTS);并支持 action 暂停/继续/停止/下一首·下一章/上一首·上一章。',
|
|
1201
1506
|
})
|
|
1202
1507
|
|
|
1203
1508
|
void ensureStarted()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-music-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"react": "^18.2.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
+
"jsdom": "^24.1.3",
|
|
56
|
+
"react-dom": "^18.3.1",
|
|
55
57
|
"vitest": "^4.1.10"
|
|
56
58
|
}
|
|
57
59
|
}
|