dsh-music-player 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
4
 
5
- DeepSeek Harness 本地音乐 + AI 讲书插件(bundle)。
5
+ DeepSeek Harness 本地音乐/小说播放插件。
6
6
 
7
- Host 进程扫描本地音乐目录(默认 `~/Music`,可在面板里改)并以 HTTP Range 流式给浏览器提供音频;浏览器侧提供**正在播放条**(上一首/播放暂停/下一首/停止、播放模式、音量、实时频谱)与浮动的**播放面板**(曲目列表 / 目录设置)。支持 **AI 讲书**:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别书名/前言/章节/尾声,可跳章、可选声音。同时注册 `music_play` 模型工具,让 agent 可按关键词播放音乐或启动讲书。
7
+ 写代码写累了、想摸鱼又不想切窗口?这个插件就是你的**摸鱼神器**——直接在 DeepSeek Harness 的网页里塞进一个本地音乐播放器:扫一下你电脑上的音乐目录(默认 `~/Music`)就能在浏览器里听歌,带播放条和可拖拽的播放面板,还能自己建歌单。
8
+
9
+ 光听歌还不够,它还能**听书**:把本地 `.txt` 小说丢给 AI 朗读,想听哪章点哪章、声音随便挑。最绝的是它还注册了 `music_play` 模型工具——你连鼠标都不用动,在对话框里跟 agent 说句「播放周杰伦的歌」,音乐分分钟响起来,摸鱼摸出新境界。
8
10
 
9
11
  ## 特性
10
12
 
@@ -16,6 +18,7 @@ DeepSeek Harness 本地音乐 + AI 讲书插件(bundle)。
16
18
  - AI 讲书:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转、章节切歌,可选 4 种中文 AI 声音(默认白桦)
17
19
  - `music_play` 模型工具:agent 可按关键词播放本地音乐,也可按小说名启动 AI 讲书
18
20
  - 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
21
+ - **自建歌单**:可新建多个歌单,从本地文件(支持多选、可跨目录)添加歌曲;播放条爱心按钮一键收藏到默认歌单「我最喜欢」;歌单作为播放来源时,顺序/乱序循环只在该歌单内进行
19
22
 
20
23
  ## 截图
21
24
 
@@ -66,8 +69,8 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
66
69
 
67
70
  插件为「Host 端 + Web 端」双面结构:
68
71
 
69
- - Host 端(`lib/index.js`):音乐扫描、HTTP 流式、`music_play` 工具、AI 讲书(小说结构解析 + TTS 合成)
70
- - Web 端(`lib/client.js`):浏览器里的播放条 / 播放面板 / 频谱 / 讲书控制
72
+ - Host 端(`lib/index.js`):音乐扫描、HTTP 流式、歌单 CRUD/持久化、`music_play` 工具、AI 讲书(小说结构解析 + TTS 合成)
73
+ - Web 端(`lib/client.js`):浏览器里的播放条 / 播放面板 / 频谱 / 歌单(收藏、一键清空)/ 讲书控制
71
74
 
72
75
  两者由一个 `cordis.patch.yml` 插入 `music-player` 行并自动组对(在 Web 端 `dsh.client` 声明即指回该行名并加载浏览器半体):
73
76
 
@@ -79,6 +82,19 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
79
82
 
80
83
  播放模式与音量保存在浏览器 `localStorage`,当前曲目与进度也会在刷新后恢复(浏览器的自动播放可能被拦截,点一次 ▶ 即可解锁)。
81
84
 
85
+ ## 自建歌单(收藏)
86
+
87
+ 播放面板「音乐」页内新增子标签:**曲库 / ♥ 我最喜欢 / +**,支持自建歌单并把歌单作为播放来源——此时顺序/乱序循环只在该歌单内进行。
88
+
89
+ - **新建歌单**:点「+」输入名称即建(可建多个)。
90
+ - **曲库加入**:在「曲库」列表每首歌行尾有「+」按钮,点击可把该曲加入任一已有歌单,或直接新建歌单加入。
91
+ - **添加歌曲**:进入某歌单 → 点「添加歌曲」→ 打开本地文件多选框(可多选、可跨目录)加入歌单;歌单内每首歌支持上移/下移排序与移除。
92
+ - **清空歌单**:每个歌单(含「我最喜欢」)详情内都有「清空」按钮,二次确认后一键移除全部歌曲(歌曲文件不会被删除)。
93
+ - **收藏**:播放条上的爱心按钮一键把当前曲加入默认歌单「我最喜欢」,再点取消;「我最喜欢」固定不可删除/重命名。
94
+ - **播放范围**:在歌单里点歌,则顺序/乱序/单曲循环都在该歌单内;在「曲库」点歌则回到全库循环。
95
+ - **命令**:`music_play` 工具新增 `playlist` 参数,可让 agent 直接播放某个歌单(如「播放歌单 我最喜欢」)。
96
+ - 歌单数据保存在 `~/.dsh/music-player-playlists.json`,刷新/重启不丢;歌单可包含曲库目录之外的本地音频文件。
97
+
82
98
  ## AI 讲书
83
99
 
84
100
  把本地 `.txt` 小说交给 AI 朗读(复用 DSH 已配置的 xiaomi/MiMo TTS)。**AI 语音目前仅支持 xiaomi 提供方(限时免费),请在设置中配置好再使用此功能。**
@@ -101,11 +117,11 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
101
117
 
102
118
  ## 开发
103
119
 
104
- 需要 Node.js ≥ 20(vitest 建议 20.19+)与 npm。开发依赖仅 `vitest`:
120
+ 需要 Node.js ≥ 20(vitest 建议 20.19+)与 npm。开发依赖:`vitest` + `react`/`react-dom`/`jsdom`(用于前端渲染冒烟测试):
105
121
 
106
122
  ```sh
107
123
  npm install
108
- npm test # 跑 vitest 测试套件
124
+ npm test # 跑 vitest 测试套件(Host 单测 + Web 渲染冒烟,共 50+ 用例)
109
125
  ```
110
126
 
111
127
  修改 `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
- if (store.tracks.length === 0 || store.currentId === null) return;
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
- if (shuffleQueue.length === store.tracks.length) {
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 = store.tracks.findIndex((t) => t.id === store.currentId);
309
- const next = store.tracks[(idx + 1) % store.tracks.length];
310
- if (next !== undefined) nextId = next.id;
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 = trackById(nextId);
468
+ const next = resolvePlayable(nextId);
314
469
  if (next !== undefined && !envCache.has(next.id)) loadEnvelope(next.id, next.url, true);
315
470
  }
316
471
  }
@@ -409,8 +564,9 @@ window.__ModuleLoader__.load({
409
564
  // played one, instead of random-without-repeat or list-order neighbors.
410
565
  let shuffleQueue = [];
411
566
  let shufflePos = -1;
567
+ let shuffleScopeKey = null;
412
568
  function buildShuffleQueue(anchorId) {
413
- const ids = store.tracks.map((t) => t.id);
569
+ const ids = activeIds();
414
570
  // Fisher-Yates
415
571
  for (let i = ids.length - 1; i > 0; i--) {
416
572
  const j = Math.floor(Math.random() * (i + 1));
@@ -422,19 +578,25 @@ window.__ModuleLoader__.load({
422
578
  if (ai !== 0) { ids.splice(ai, 1); ids.unshift(a); }
423
579
  }
424
580
  shuffleQueue = ids;
581
+ shuffleScopeKey = scopeKey();
425
582
  shufflePos = a !== null && ids[0] === a ? 0 : -1;
426
583
  }
427
- function syncShufflePos() {
584
+ // 确保乱序队列与当前范围一致(范围切换后自动重建)。
585
+ function ensureShuffleReady() {
428
586
  if (store.mode !== 'shuffle') return;
429
- if (store.currentId === null) return;
430
- if (shuffleQueue.length !== store.tracks.length || !shuffleQueue.includes(store.currentId)) {
587
+ const ids = activeIds();
588
+ if (shuffleScopeKey !== scopeKey() || shuffleQueue.length !== ids.length
589
+ || (store.currentId !== null && !shuffleQueue.includes(store.currentId))) {
431
590
  buildShuffleQueue(store.currentId);
432
- return;
433
591
  }
434
- 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();
435
597
  }
436
598
  function startPlay(id) {
437
- const track = trackById(id);
599
+ const track = resolvePlayable(id);
438
600
  if (track === null) return;
439
601
  restoredMusicPos = null;
440
602
  bookRestorePos = -1;
@@ -687,13 +849,13 @@ window.__ModuleLoader__.load({
687
849
  }
688
850
  function togglePlay() {
689
851
  if (store.pendingId !== null && store.currentId === null) { startPlay(store.pendingId); return; }
690
- if (store.currentId === null) { if (store.tracks.length > 0) startPlay(store.tracks[0].id); return; }
852
+ if (store.currentId === null) { const ids = activeIds(); if (ids.length > 0) startPlay(ids[0]); return; }
691
853
  if (audio.paused) {
692
854
  // A restored track's <audio> was not pre-loaded (restore never touches
693
855
  // the element, to avoid the Chromium 'getTopURL' quirk), so load it now,
694
856
  // then apply the deferred seek so it resumes from the saved spot.
695
857
  if (restoredMusicPos !== null && restoredMusicPos > 0) {
696
- const track = trackById(store.currentId);
858
+ const track = resolvePlayable(store.currentId);
697
859
  if (track !== null && audio.currentSrc !== new URL(track.url, window.location.href).href) {
698
860
  audio.src = track.url;
699
861
  audio.load();
@@ -719,19 +881,17 @@ window.__ModuleLoader__.load({
719
881
  // Envelope decoding is deferred to play (see loadTracks) — decode the
720
882
  // restored track lazily so its spectrum is ready once it resumes.
721
883
  if (!String(store.currentId).startsWith('book:')) {
722
- const track = trackById(store.currentId);
884
+ const track = resolvePlayable(store.currentId);
723
885
  if (track !== null && !envCache.has(track.id)) loadEnvelope(track.id, track.url);
724
886
  }
725
887
  } else audio.pause();
726
888
  }
727
889
  function step(delta) {
728
- if (store.tracks.length === 0) return;
729
- if (store.mode === 'shuffle' && store.tracks.length > 1) { // Walk the shuffled queue: next plays the next unplayed track, prev
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
730
893
  // returns to the previously played one (not a list-order neighbor).
731
- if (shuffleQueue.length !== store.tracks.length
732
- || store.currentId === null || !shuffleQueue.includes(store.currentId)) {
733
- buildShuffleQueue(store.currentId);
734
- }
894
+ ensureShuffleReady();
735
895
  const pos = shuffleQueue.indexOf(store.currentId);
736
896
  if (store.currentId === null) {
737
897
  // Nothing playing yet: start from the head of the shuffled queue.
@@ -755,9 +915,9 @@ window.__ModuleLoader__.load({
755
915
  }
756
916
  return;
757
917
  }
758
- const idx = store.tracks.findIndex((t) => t.id === store.currentId);
759
- const nextIdx = idx < 0 ? 0 : (idx + delta + store.tracks.length) % store.tracks.length;
760
- startPlay(store.tracks[nextIdx].id);
918
+ const idx = ids.indexOf(store.currentId);
919
+ const nextIdx = idx < 0 ? 0 : (idx + delta + ids.length) % ids.length;
920
+ startPlay(ids[nextIdx]);
761
921
  }
762
922
  // In book (AI 讲书) mode the transport prev/next buttons jump between
763
923
  // CHAPTERS instead of music tracks. The current chapter is the section
@@ -985,10 +1145,31 @@ window.__ModuleLoader__.load({
985
1145
  };
986
1146
  }
987
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
+
988
1161
  function restorePlayback(list) {
989
1162
  const saved = loadPlayback();
990
1163
  if (saved === null) return;
991
- const track = list.find((t) => t.id === saved.id);
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
+ }
992
1173
  if (track === undefined) return;
993
1174
  const pos = Number.isFinite(saved.position) ? saved.position : 0;
994
1175
  const savedDur = Number.isFinite(saved.duration) && saved.duration > 0 ? saved.duration : 0;
@@ -999,7 +1180,7 @@ window.__ModuleLoader__.load({
999
1180
  set({
1000
1181
  currentId: track.id, currentName: track.name,
1001
1182
  position: pos, duration: savedDur,
1002
- pendingId: null, pendingName: null, error: null,
1183
+ pendingId: null, pendingName: null, error: null, scope,
1003
1184
  });
1004
1185
  if (pos > 0) {
1005
1186
  restoredMusicPos = pos;
@@ -1092,6 +1273,7 @@ window.__ModuleLoader__.load({
1092
1273
  set({
1093
1274
  root: result.root || null, bookRoot: result.bookRoot || null,
1094
1275
  tracks: result.tracks || [], books: result.books || [],
1276
+ playlists: result.playlists || [],
1095
1277
  count: result.count || 0, loading: false, error: result.error || null,
1096
1278
  ttsConfigured: !!result.ttsConfigured, ttsReason: result.ttsReason || '',
1097
1279
  voices: Array.isArray(result.voices) ? result.voices : [],
@@ -1100,6 +1282,7 @@ window.__ModuleLoader__.load({
1100
1282
  // Envelope (spectrum) decoding is deferred to actual playback — no need
1101
1283
  // to decode several full files eagerly at page load; the current track's
1102
1284
  // envelope decodes on play (startPlay / resume).
1285
+ restoreScope(result.playlists || []);
1103
1286
  restoreLatest(list);
1104
1287
  } catch (err) {
1105
1288
  set({ loading: false, error: '\u65e0\u6cd5\u8bfb\u53d6\u97f3\u4e50\u5e93\uff1a' + String((err && err.message) || err) });
@@ -1276,7 +1459,7 @@ window.__ModuleLoader__.load({
1276
1459
  vizBadge = React.createElement('button', {
1277
1460
  className: 'dsh-music-bar-warn',
1278
1461
  title: '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5',
1279
- onClick: () => { const t = trackById(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
1462
+ onClick: () => { const t = resolvePlayable(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
1280
1463
  }, '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5');
1281
1464
  }
1282
1465
  const note = React.createElement(MusicNote, { className: 'dsh-music-note' });
@@ -1292,6 +1475,16 @@ window.__ModuleLoader__.load({
1292
1475
  sectionBadge = React.createElement('span', { className: 'dsh-music-bar-section', title: s.currentSection },
1293
1476
  '\u25b8 ' + s.currentSection);
1294
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;
1295
1488
  return React.createElement('div', { className: 'dsh-music-bar-wrap' },
1296
1489
  React.createElement('div', { className: 'dsh-music-bar' + (isBook ? ' book' : '') },
1297
1490
  hasTrack
@@ -1306,6 +1499,7 @@ window.__ModuleLoader__.load({
1306
1499
  ? React.createElement('span', { className: 'dsh-music-bar-hint' }, '\u26a0 \u81ea\u52a8\u64ad\u653e\u88ab\u62e6\u622a\uff0c\u70b9\u51fb\u25b6\u89e3\u9501')
1307
1500
  : React.createElement('span', { className: 'dsh-music-bar-time' }, fmtTime(s.position) + ' / ' + fmtTime(s.duration)))
1308
1501
  : null,
1502
+ heartBtn,
1309
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,
1310
1504
  hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '\u64ad\u653e/\u6682\u505c', onClick: togglePlay }, s.playing ? '\u23f8' : '\u25b6') : null,
1311
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,
@@ -1440,6 +1634,12 @@ window.__ModuleLoader__.load({
1440
1634
  // Draggable panel position ({x, y, h} left/top/height once dragged; null = default right/bottom)
1441
1635
  const [pos, setPos] = useState(loadPanelPos);
1442
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
+ };
1443
1643
 
1444
1644
  // Once the panel is dragged we switch from CSS right/bottom anchoring to an
1445
1645
  // explicit left/top/height. Locking the height matters: with only top+left set
@@ -1500,18 +1700,26 @@ window.__ModuleLoader__.load({
1500
1700
  const active = list.querySelector('.dsh-music-track.active');
1501
1701
  if (active !== null && typeof active.scrollIntoView === 'function') active.scrollIntoView({ block: 'nearest' });
1502
1702
  }, [s.panelOpen, s.currentId]);
1703
+ // 面板关闭时清掉「加入歌单」弹层状态,避免重开面板时残留。
1704
+ useEffect(() => { if (!s.panelOpen) setAddMenu(null); }, [s.panelOpen]);
1503
1705
  if (!s.panelOpen) return null;
1504
1706
  const rows = s.tracks.map((t) => {
1505
1707
  const active = t.id === s.currentId;
1506
1708
  const playing = active && s.playing;
1507
- return React.createElement('button', {
1508
- key: t.id,
1509
- className: 'dsh-music-track' + (active ? ' active' : ''),
1510
- title: t.url,
1511
- onClick: () => { if (active) togglePlay(); else startPlay(t.id); },
1512
- },
1513
- React.createElement('span', { className: 'dsh-music-track-name' }, (playing ? '\u25b6 ' : '') + t.name),
1514
- React.createElement('span', { className: 'dsh-music-track-size' }, formatSize(t.size)),
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'),
1515
1723
  );
1516
1724
  });
1517
1725
  const bookRows = s.books.map((b) => {
@@ -1531,8 +1739,29 @@ window.__ModuleLoader__.load({
1531
1739
  className: 'dsh-music-tab' + (s.tab === key ? ' active' : ''),
1532
1740
  onClick: () => set({ tab: key }),
1533
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'));
1534
1763
  const listBody = s.tab === 'music'
1535
- ? (rows.length > 0 ? rows : 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'))
1764
+ ? musicBody
1536
1765
  : (s.books.length > 0
1537
1766
  ? bookRows
1538
1767
  : (s.ttsConfigured
@@ -1548,11 +1777,16 @@ window.__ModuleLoader__.load({
1548
1777
  React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: () => set({ panelOpen: false }) }, '\u2715')),
1549
1778
  React.createElement('div', { className: 'dsh-music-tabs' }, tabBtn('music', '\u97f3\u4e50'), tabBtn('book', '\u5c0f\u8bf4')),
1550
1779
  React.createElement(DirectorySetting, null),
1780
+ s.tab === 'music' ? musicSubTabs : null,
1551
1781
  // While a novel is playing, keep music-only errors/scanning out of the
1552
1782
  // panel (novel status shows on the playback bar instead).
1553
1783
  !isBook && s.error ? React.createElement('div', { className: 'dsh-music-error' }, s.error) : null,
1554
1784
  !isBook && s.loading ? React.createElement('div', { className: 'dsh-music-loading' }, '\u626b\u63cf\u4e2d\u2026') : null,
1555
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,
1556
1790
  );
1557
1791
  }
1558
1792
  // Directory setting block, embedded in the player panel (the former
@@ -1645,6 +1879,151 @@ window.__ModuleLoader__.load({
1645
1879
  saveRoot(p, isBook ? 'book' : 'music');
1646
1880
  }
1647
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
+ }
1648
2027
 
1649
2028
  const inject = ['slots'];
1650
2029
  function apply(ctx) {
@@ -1711,6 +2090,14 @@ window.__ModuleLoader__.load({
1711
2090
  if (store.currentId !== null && String(store.currentId).startsWith('book:')) stepBook(-1); else step(-1);
1712
2091
  return;
1713
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
+ }
1714
2101
  // play (default): needs an id — a book id (e.g. "b0") starts AI 讲书.
1715
2102
  if (intent.id === undefined) return;
1716
2103
  const book = bookById(intent.id);
@@ -1719,11 +2106,11 @@ window.__ModuleLoader__.load({
1719
2106
  resumeOrPlayBook(book.id);
1720
2107
  return;
1721
2108
  }
1722
- const track = trackById(intent.id);
2109
+ const track = resolvePlayable(intent.id);
1723
2110
  if (track !== null) {
1724
2111
  audio.src = track.url;
1725
2112
  audio.load();
1726
- set({ currentId: intent.id, currentName: track.name, error: null });
2113
+ set({ currentId: intent.id, currentName: track.name, error: null, scope: { kind: 'library' } });
1727
2114
  loadEnvelope(intent.id, track.url);
1728
2115
  prefetchNext();
1729
2116
  savePlayback();
@@ -1764,7 +2151,7 @@ window.__ModuleLoader__.load({
1764
2151
  // :root (html) it cannot see body's tokens and would always fall back to
1765
2152
  // green. Declared on body, the reference resolves and children inherit
1766
2153
  // the theme's actual brand color.
1767
- 'body { --dsh-music-accent: var(--dsw-alias-brand-primary, #2f9e6e); }\n' +
2154
+ 'body { --dsh-music-accent: var(--dsw-alias-brand-primary, #2f9e6e); --dsh-music-accent-fg: var(--dsw-alias-label-primary-foreground, #fff); }\n' +
1768
2155
  '.dsh-music-bar-wrap { box-sizing: border-box; width: 100%; padding: 0 var(--dsh-composer-side-clearance, 16px); }\n' +
1769
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' +
1770
2157
  '.dsh-music-bar-idle { color: var(--dsw-alias-label-primary, #e6e6e6); font-weight: 500; display: inline-flex; align-items: center; }\n' +
@@ -1805,7 +2192,7 @@ window.__ModuleLoader__.load({
1805
2192
  '.dsh-music-tabs { display: flex; gap: 4px; }\n' +
1806
2193
  '.dsh-music-tab { flex: 1; padding: 5px 0; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 12px; }\n' +
1807
2194
  '.dsh-music-tab:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
1808
- '.dsh-music-tab.active { background: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
2195
+ '.dsh-music-tab.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
1809
2196
  '.dsh-music-panel-drag { cursor: move; touch-action: none; user-select: none; }\n' +
1810
2197
  '.dsh-music-panel-grip { color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 12px; letter-spacing: -1px; opacity: 0.7; }\n' +
1811
2198
  '.dsh-music-panel-title { font-weight: 600; margin-right: auto; }\n' +
@@ -1815,11 +2202,11 @@ window.__ModuleLoader__.load({
1815
2202
  '.dsh-music-mode-menu { position: relative; flex: none; }\n' +
1816
2203
  '.dsh-music-mode-menu.right { margin-left: auto; }\n' +
1817
2204
  '.dsh-music-mode-trigger { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.05)); color: var(--dsh-music-accent, #2f9e6e); cursor: pointer; }\n' +
1818
- '.dsh-music-mode-trigger:hover, .dsh-music-mode-trigger.active { background: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
2205
+ '.dsh-music-mode-trigger:hover, .dsh-music-mode-trigger.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
1819
2206
  '.dsh-music-mode-pop { position: absolute; left: 50%; transform: translateX(-50%); bottom: calc(100% + 6px); z-index: 60; display: flex; flex-direction: column; gap: 4px; 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' +
1820
2207
  '.dsh-music-mode-item { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border: none; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; }\n' +
1821
2208
  '.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' +
1822
- '.dsh-music-mode-item.active { background: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
2209
+ '.dsh-music-mode-item.active { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\n' +
1823
2210
  '.dsh-music-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; min-height: 60px; max-height: 40vh; }\n' +
1824
2211
  '.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' +
1825
2212
  '.dsh-music-track:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
@@ -1832,13 +2219,13 @@ window.__ModuleLoader__.load({
1832
2219
  '.dsh-music-settings { display: flex; flex-direction: column; gap: 10px; }\n' +
1833
2220
  '.dsh-music-settings-row { display: flex; gap: 8px; align-items: center; }\n' +
1834
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' +
1835
- '.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' +
2222
+ '.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' +
1836
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' +
1837
- '.dsh-music-picker-overlay { position: absolute; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.45); }\n' +
1838
- '.dsh-music-picker { box-sizing: border-box; width: 88%; max-height: 80%; 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' +
1839
- '.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' +
1840
2227
  '.dsh-music-picker-title { font-weight: 600; }\n' +
1841
- '.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' +
1842
2229
  '.dsh-music-picker-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }\n' +
1843
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' +
1844
2231
  '.dsh-music-picker-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
@@ -1856,8 +2243,43 @@ window.__ModuleLoader__.load({
1856
2243
  '.dsh-music-toc-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
1857
2244
  '.dsh-music-toc-item.active { color: var(--dsh-music-accent, #2f9e6e); }\n' +
1858
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' +
1859
- '.dsh-music-toc-item.active .dsh-music-toc-type { background: var(--dsh-music-accent, #2f9e6e); color: #fff; }\n' +
1860
- '.dsh-music-toc-heading { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n';
2246
+ '.dsh-music-toc-item.active .dsh-music-toc-type { background: var(--dsh-music-accent, #2f9e6e); color: var(--dsh-music-accent-fg, #fff); }\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: var(--dsh-music-accent-fg, #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: var(--dsh-music-accent-fg, #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';
1861
2283
 
1862
2284
  return module.exports;
1863
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 则播放第一首音乐,没有音乐则播放第一本小说);也可用 action 执行暂停/继续/停止/下一首·下一章/上一首·上一章。',
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(或设置的目录)里的音乐,或按小说名播放本地 .txt 小说(AI 讲书,需配置 xiaomi/MiMo TTS);并支持 action 暂停/继续/停止/下一首·下一章/上一首·上一章。',
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.2.2",
3
+ "version": "0.3.1",
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
  }