dsh-music-player 0.1.0 → 0.1.2

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
@@ -9,24 +9,40 @@ DeepSeek Harness 本地音乐库播放器插件(bundle)。
9
9
  - 本地音频流式播放,支持 seek / 断点续播
10
10
  - 顺序播放、单曲循环、乱序播放三种模式
11
11
  - 实时 7 段频谱可视化(解码音频包络驱动)
12
+ - 播放列表面板可自由拖动,位置跨刷新记忆
12
13
  - `music_play` 模型工具:agent 可按关键词让浏览器播放
13
14
  - 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
14
15
 
16
+ ## 截图
17
+
18
+ ![播放条](assets/screenshot-bar.png)
19
+
20
+ ![播放面板](assets/screenshot-panel.png)
21
+
22
+ ![实时频谱](assets/screenshot-spectrum.png)
23
+
15
24
  ## 安装
16
25
 
17
26
  需要已安装 `dsh` CLI。
18
27
 
19
- 直接从一个 git 托管安装(推荐,无构建步骤):
28
+ ### npm 安装(推荐,已发布到 registry)
29
+
30
+ ```sh
31
+ # 把 <profile> 换成实际 profile 名,如 web
32
+ dsh plugin --profile <profile> add dsh-music-player
33
+ ```
34
+
35
+ ### 从 GitHub 安装(备用来源)
20
36
 
21
37
  ```sh
22
- # 安装到你的 profile(把 <profile> 换成实际 profile 名,如 web
38
+ # <profile> 换成实际 profile 名,如 web
23
39
  dsh plugin --profile <profile> add github:kendu76/dsh-music-player
24
40
  ```
25
41
 
26
- > 项目是手写的纯 JS(`lib/` 直接是发布产物),**没有**需要从源码构建的步骤,因此从 GitHub 直装即可使用,无需像 TypeScript 包那样为构建脚本授权。
42
+ > 项目是手写的纯 JS(`lib/` 直接是发布产物),**没有**需要从源码构建的步骤,因此从 GitHub/npm 直装即可使用,无需像 TypeScript 包那样为构建脚本授权。
27
43
 
28
44
  安装后重启 DSH,打开 Web GUI:
29
- - 聊天输入区下方会出现「本地音乐播放器」播放条
45
+ - 聊天输入区上方会出现「本地音乐播放器」播放条
30
46
  - 点击右侧「列表」按钮打开播放面板
31
47
  - 在面板顶部点击「选择音乐目录」并选定音乐目录(默认 `~/Music`),自动递归扫描
32
48
  - 之后可直接在对话框里让 agent 播放,例如「播放周杰伦的歌」
@@ -61,11 +77,38 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
61
77
 
62
78
  ## 开发
63
79
 
80
+ 需要 Node.js ≥ 20(vitest 建议 20.19+)与 npm。开发依赖仅 `vitest`:
81
+
82
+ ```sh
83
+ npm install
84
+ npm test # 跑 vitest 测试套件
85
+ ```
86
+
87
+ 修改 `lib/` 后,在本机 profile 里用 link 方式本地调试并验证:
88
+
64
89
  ```sh
65
- # 修改 lib/ 后,在本机 profile 里用 link 方式本地调试
66
90
  dsh plugin --profile <profile> add ./ # 或直接改 profile 里的 link 目标
67
91
  ```
68
92
 
93
+ 项目结构、测试策略与发布流程详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
94
+
95
+ ## 常见问题
96
+
97
+ **播放没有声音 / 显示"浏览器拦截了自动播放"?**
98
+ 浏览器安全策略禁止未经交互的音频播放。首次自动播放被拦截是正常的——在播放条上点一次 ▶ 即可解锁,之后恢复播放。
99
+
100
+ **音乐面板显示"暂无音乐"或"不是有效的音乐目录"?**
101
+ 点面板顶部「选择音乐目录」,选一个包含音频文件的实际目录(默认 `~/Music`)。目录路径不可读或不存在时会回退到默认目录而不是报错。
102
+
103
+ **改了音乐目录/新增了歌曲,但列表没更新?**
104
+ 播放器在启动时扫描,并在每次“选择音乐目录”时重扫。想强制刷新当前目录,重新打开一次面板或点一次目录设置即可(扫描上限 500 首、递归子目录深度上限 4 层)。
105
+
106
+ **`music_play` 工具说"音乐库为空"?**
107
+ 说明还没有可用的音乐目录。请先打开播放面板,点「选择音乐目录」配置一次。
108
+
109
+ **想支持更多音频格式?**
110
+ 格式支持由 Host 端 `AUDIO_TYPES` 表驱动,在 `lib/index.js` 里加扩展名与 MIME 即可(播放器本身用浏览器原生 `<audio>` 解码,最终能否播放还取决于浏览器对该编码的支持)。
111
+
69
112
  ## License
70
113
 
71
114
  [MIT](LICENSE) © kendu76
package/lib/client.js CHANGED
@@ -28,9 +28,21 @@ window.__ModuleLoader__.load({
28
28
  const PREF_VOL = 'dsh-music-volume';
29
29
  const PREF_PLAYBACK = 'dsh-music-playback';
30
30
  const PREF_ROOT = 'dsh-music-root';
31
+ const PREF_PANEL_POS = 'dsh-music-panel-pos';
31
32
  const loadPref = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
32
33
  const savePref = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
33
34
  const clearPref = (k) => { try { localStorage.removeItem(k); } catch (e) {} };
35
+ // Restore the playback-panel position ({x,y,h}) previously saved by dragging, if any.
36
+ function loadPanelPos() {
37
+ const raw = loadPref(PREF_PANEL_POS);
38
+ if (raw === null) return null;
39
+ try {
40
+ const p = JSON.parse(raw);
41
+ if (p && typeof p.x === 'number' && typeof p.y === 'number'
42
+ && typeof p.h === 'number' && p.h > 0) return p;
43
+ } catch (e) {}
44
+ return null;
45
+ }
34
46
  const jsonGet = (url) => fetch(url, { cache: 'no-store' }).then((r) => r.json());
35
47
 
36
48
  // ---- engine + shared store (React re-renders on set) ----
@@ -627,6 +639,52 @@ window.__ModuleLoader__.load({
627
639
  const s = useStore();
628
640
  const listRef = useRef(null);
629
641
  const panelRef = useRef(null);
642
+ // Draggable panel position ({x, y, h} left/top/height once dragged; null = default right/bottom)
643
+ const [pos, setPos] = useState(loadPanelPos);
644
+ const dragRef = useRef(null);
645
+
646
+ // Once the panel is dragged we switch from CSS right/bottom anchoring to an
647
+ // explicit left/top/height. Locking the height matters: with only top+left set
648
+ // and no height, a fixed element whose CSS also sets bottom collapses to fit
649
+ // the leftover space, so the panel's height jumps while dragging.
650
+ const style = pos === null ? null : { left: pos.x, top: pos.y, height: pos.h };
651
+
652
+ const onHeadDown = (e) => {
653
+ if (e.button !== undefined && e.button !== 0) return;
654
+ // don't start a drag from the close button
655
+ if (e.target.closest && e.target.closest('.dsh-music-icon-btn')) return;
656
+ const el = panelRef.current;
657
+ if (el === null) return;
658
+ const rect = el.getBoundingClientRect();
659
+ const h = pos !== null ? pos.h : rect.height;
660
+ const next = { x: pos !== null ? pos.x : rect.left, y: pos !== null ? pos.y : rect.top, h };
661
+ dragRef.current = {
662
+ startX: e.clientX, startY: e.clientY,
663
+ originX: next.x, originY: next.y, h,
664
+ };
665
+ setPos(next);
666
+ savePref(PREF_PANEL_POS, JSON.stringify(next));
667
+ e.currentTarget.setPointerCapture(e.pointerId);
668
+ };
669
+ const onHeadMove = (e) => {
670
+ const d = dragRef.current;
671
+ if (d === null) return;
672
+ let x = d.originX + (e.clientX - d.startX);
673
+ let y = d.originY + (e.clientY - d.startY);
674
+ const el = panelRef.current;
675
+ if (el !== null) {
676
+ x = Math.max(0, Math.min(x, window.innerWidth - el.offsetWidth));
677
+ y = Math.max(0, Math.min(y, window.innerHeight - el.offsetHeight));
678
+ }
679
+ const next = { x, y, h: d.h };
680
+ setPos(next);
681
+ savePref(PREF_PANEL_POS, JSON.stringify(next));
682
+ };
683
+ const onHeadUp = (e) => {
684
+ dragRef.current = null;
685
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
686
+ };
687
+
630
688
  useEffect(() => {
631
689
  if (!s.panelOpen) return;
632
690
  // Close the playlist panel when the user clicks outside it
@@ -658,8 +716,12 @@ window.__ModuleLoader__.load({
658
716
  React.createElement('span', { className: 'dsh-music-track-size' }, t.size ? Math.round(t.size / 1024 / 1024 * 10) / 10 + ' MB' : ''),
659
717
  );
660
718
  });
661
- return React.createElement('div', { className: 'dsh-music-panel', ref: panelRef },
662
- React.createElement('div', { className: 'dsh-music-panel-head' },
719
+ return React.createElement('div', { className: 'dsh-music-panel', ref: panelRef, style },
720
+ React.createElement('div', {
721
+ className: 'dsh-music-panel-head dsh-music-panel-drag',
722
+ onPointerDown: onHeadDown, onPointerMove: onHeadMove, onPointerUp: onHeadUp,
723
+ },
724
+ React.createElement('span', { className: 'dsh-music-panel-grip', 'aria-hidden': true }, '\u283f'),
663
725
  React.createElement('span', { className: 'dsh-music-panel-title' }, '\u64ad\u653e\u5217\u8868'),
664
726
  React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: () => set({ panelOpen: false }) }, '\u2715')),
665
727
  React.createElement(DirectorySetting, null),
@@ -824,6 +886,8 @@ window.__ModuleLoader__.load({
824
886
  '.dsh-music-bar .dsh-music-mode-menu { align-self: center; }\n' +
825
887
  '.dsh-music-panel { position: fixed; right: 24px; bottom: 84px; width: 380px; max-height: 72vh; 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; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 1000; pointer-events: auto; overflow: hidden; }\n' +
826
888
  '.dsh-music-panel-head { display: flex; align-items: center; gap: 6px; }\n' +
889
+ '.dsh-music-panel-drag { cursor: move; touch-action: none; user-select: none; }\n' +
890
+ '.dsh-music-panel-grip { color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 12px; letter-spacing: -1px; opacity: 0.7; }\n' +
827
891
  '.dsh-music-panel-title { font-weight: 600; margin-right: auto; }\n' +
828
892
  '.dsh-music-icon-btn { background: transparent; border: none; color: var(--dsw-alias-label-secondary, #8a8f98); cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 6px; }\n' +
829
893
  '.dsh-music-icon-btn:hover { color: var(--dsw-alias-label-primary, #e6e6e6); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); }\n' +
package/lib/index.js CHANGED
@@ -232,6 +232,10 @@ export function apply(ctx) {
232
232
  }
233
233
  // audio streaming
234
234
  if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return }
235
+ // Ensure the library scan completes before resolving a track, so a
236
+ // streaming/HEAD request that arrives before any manifest call (or at
237
+ // startup) still finds its track instead of spuriously 404ing.
238
+ await ensureStarted()
235
239
  const id = pathname.slice('/dsh-music/'.length)
236
240
  const track = tracks.find((t) => t.id === id)
237
241
  if (track === undefined) { res.writeHead(404); res.end(); return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness 本地音乐库播放器:Host 扫描音乐目录并以 HTTP 流式提供音频,浏览器侧提供播放条/播放面板/设置页与实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -28,6 +28,14 @@
28
28
  "url": "git+https://github.com/kendu76/dsh-music-player.git"
29
29
  },
30
30
  "license": "MIT",
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "ci": "vitest run"
38
+ },
31
39
  "dsh": {
32
40
  "bundle": {
33
41
  "patch": "./cordis.patch.yml"
@@ -39,5 +47,8 @@
39
47
  },
40
48
  "peerDependencies": {
41
49
  "react": "^18.2.0"
50
+ },
51
+ "devDependencies": {
52
+ "vitest": "^4.1.10"
42
53
  }
43
54
  }