dsh-music-player 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/lib/client.js +82 -7
- package/lib/index.js +90 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
# dsh-music-player
|
|
2
2
|
|
|
3
|
+
[](https://awesome-dsh-plugin.com)
|
|
4
|
+
|
|
3
5
|
DeepSeek Harness 本地音乐库播放器插件(bundle)。
|
|
4
6
|
|
|
5
|
-
在 Host 进程里扫描本地音乐目录(默认 `~/Music`,可在面板里改),以 HTTP
|
|
7
|
+
在 Host 进程里扫描本地音乐目录(默认 `~/Music`,可在面板里改),以 HTTP Range 流式(仅显示播放时间,暂无拖动跳转)给浏览器提供音频;浏览器侧给聊天输入区注入**正在播放条**(曲目信息、上一首/播放暂停/下一首/停止、顺序/单曲/乱序循环模式、音量、实时频谱),并提供一个浮动的**播放面板**(曲目列表 / 音乐目录选择与格式提示)。同时注册 `music_play` 模型工具,让 agent 可以直接按关键词播放本地音乐。
|
|
6
8
|
|
|
7
9
|
## 特性
|
|
8
10
|
|
|
9
|
-
-
|
|
11
|
+
- 本地音频流式播放(HTTP Range),刷新后断点续播
|
|
10
12
|
- 顺序播放、单曲循环、乱序播放三种模式
|
|
11
13
|
- 实时 7 段频谱可视化(解码音频包络驱动)
|
|
14
|
+
- 播放列表面板可自由拖动,位置跨刷新记忆
|
|
12
15
|
- `music_play` 模型工具:agent 可按关键词让浏览器播放
|
|
13
16
|
- 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
|
|
14
17
|
|
|
18
|
+
## 截图
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
15
26
|
## 安装
|
|
16
27
|
|
|
17
28
|
需要已安装 `dsh` CLI。
|
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', {
|
|
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),
|
|
@@ -677,6 +739,7 @@ window.__ModuleLoader__.load({
|
|
|
677
739
|
const [dirs, setDirs] = useState([]);
|
|
678
740
|
const [curPath, setCurPath] = useState('');
|
|
679
741
|
const [curName, setCurName] = useState('');
|
|
742
|
+
const [curUp, setCurUp] = useState(null);
|
|
680
743
|
const [dirError, setDirError] = useState(null);
|
|
681
744
|
return React.createElement('div', { className: 'dsh-music-settings' },
|
|
682
745
|
React.createElement('div', { className: 'dsh-music-settings-row' },
|
|
@@ -724,20 +787,30 @@ window.__ModuleLoader__.load({
|
|
|
724
787
|
if (data && data.error) { setDirError(data.error); return; }
|
|
725
788
|
setCurPath(data.path || '');
|
|
726
789
|
setCurName(data.name || '');
|
|
790
|
+
setCurUp(data.up || null);
|
|
727
791
|
setDirs(data.dirs || []);
|
|
728
792
|
} catch (err) {
|
|
729
793
|
setDirError('读取目录失败:' + String((err && err.message) || err));
|
|
730
794
|
}
|
|
731
795
|
}
|
|
732
796
|
function goUp() {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
797
|
+
// Prefer the parent path computed by the host (correct separators per OS).
|
|
798
|
+
// At a drive root the host reports the "__drives__" sentinel, so "up"
|
|
799
|
+
// jumps to the drive list and lets the user switch disks.
|
|
800
|
+
if (curUp === '__drives__') { browse('__drives__'); return; }
|
|
801
|
+
if (curUp !== null && curUp !== undefined && curUp !== '') { browse(curUp); return; }
|
|
802
|
+
// fallback: derive the parent locally when the host omitted `up`.
|
|
803
|
+
// Handle both "\" and "/" so Windows paths never dead-end (the old
|
|
804
|
+
// POSIX-only parse did nothing on backslash paths like C:\Users\x).
|
|
805
|
+
if (curPath === '' || curPath === '/' || /^[A-Za-z]:[\\/]?$/.test(curPath)) return;
|
|
806
|
+
const idx = Math.max(curPath.lastIndexOf('/'), curPath.lastIndexOf('\\'));
|
|
807
|
+
if (idx <= 0) return;
|
|
808
|
+
browse(curPath.slice(0, idx));
|
|
737
809
|
}
|
|
738
810
|
function pickCurrent() {
|
|
739
811
|
const p = curPath;
|
|
740
|
-
|
|
812
|
+
// The drive-list view ("__drives__") is not a real directory.
|
|
813
|
+
if (p === '' || p === '__drives__') return;
|
|
741
814
|
setPickerOpen(false);
|
|
742
815
|
saveRoot(p);
|
|
743
816
|
}
|
|
@@ -824,6 +897,8 @@ window.__ModuleLoader__.load({
|
|
|
824
897
|
'.dsh-music-bar .dsh-music-mode-menu { align-self: center; }\n' +
|
|
825
898
|
'.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
899
|
'.dsh-music-panel-head { display: flex; align-items: center; gap: 6px; }\n' +
|
|
900
|
+
'.dsh-music-panel-drag { cursor: move; touch-action: none; user-select: none; }\n' +
|
|
901
|
+
'.dsh-music-panel-grip { color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 12px; letter-spacing: -1px; opacity: 0.7; }\n' +
|
|
827
902
|
'.dsh-music-panel-title { font-weight: 600; margin-right: auto; }\n' +
|
|
828
903
|
'.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
904
|
'.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
|
@@ -28,8 +28,9 @@ function audioType(name) {
|
|
|
28
28
|
return i > 0 ? (AUDIO_TYPES[name.slice(i + 1).toLowerCase()] || 'application/octet-stream') : 'application/octet-stream'
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
32
|
-
import { dirname } from 'node:path'
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
32
|
+
import { dirname, basename, parse as pathParse, join as pathJoin } from 'node:path'
|
|
33
|
+
import * as os from 'node:os'
|
|
33
34
|
|
|
34
35
|
export const name = 'dsh-music-player'
|
|
35
36
|
export const inject = ['webServer', 'fs', 'shell', 'tools', 'systemPrompt']
|
|
@@ -43,6 +44,14 @@ export function apply(ctx) {
|
|
|
43
44
|
|
|
44
45
|
const getHome = async () => {
|
|
45
46
|
if (home !== null) return home
|
|
47
|
+
try {
|
|
48
|
+
// os.homedir() resolves the user's home cross-platform (Windows uses
|
|
49
|
+
// C:\Users\<name>; POSIX /Users/<name> or /home/<name>). The $HOME shell
|
|
50
|
+
// variable does not exist under cmd/powershell on Windows, so fall back
|
|
51
|
+
// to the shell only when os.homedir() is unusable.
|
|
52
|
+
const osHome = (typeof os !== 'undefined' && os.homedir) ? os.homedir() : ''
|
|
53
|
+
if (osHome !== '') { home = osHome; return home }
|
|
54
|
+
} catch { /* fall through to shell */ }
|
|
46
55
|
try {
|
|
47
56
|
const result = await ctx.shell.run(ctx.shell.resolve({ command: 'printf %s "$HOME"' }))
|
|
48
57
|
const value = String((result.stdout && result.stdout.text) || '').trim()
|
|
@@ -100,21 +109,29 @@ export function apply(ctx) {
|
|
|
100
109
|
const found = []
|
|
101
110
|
const walk = async (dir, depth) => {
|
|
102
111
|
if (depth > 4 || found.length >= 500) return
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
// Tolerant listing (all entries, see listEntries): dsh-fs-local's listDir
|
|
113
|
+
// aborts on the first unreadable child, so scanning a drive root (or any
|
|
114
|
+
// dir with protected entries) would silently yield zero tracks.
|
|
115
|
+
const entries = listEntries(dir)
|
|
105
116
|
for (const entry of entries) {
|
|
106
117
|
if (found.length >= 500) return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
const abs = pathJoin(dir, entry.name)
|
|
119
|
+
try {
|
|
120
|
+
if (entry.isDir) { await walk(abs, depth + 1); continue }
|
|
121
|
+
if (!isAudioName(entry.name)) continue
|
|
122
|
+
const st = statSync(abs)
|
|
123
|
+
if (!st.isFile()) continue
|
|
124
|
+
const rel = abs.startsWith(rootStr) ? abs.slice(rootStr.length + 1) : entry.name
|
|
125
|
+
found.push({
|
|
126
|
+
name: rel, path: abs, size: st.size || 0,
|
|
127
|
+
ext: entry.name.slice(entry.name.lastIndexOf('.') + 1).toLowerCase(),
|
|
128
|
+
})
|
|
129
|
+
} catch {
|
|
130
|
+
// unreadable entry: skip it, keep walking the rest
|
|
131
|
+
}
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
|
-
await walk(
|
|
134
|
+
await walk(rootStr, 0)
|
|
118
135
|
found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
119
136
|
return { rootPath: rootStr, found }
|
|
120
137
|
}
|
|
@@ -129,7 +146,9 @@ export function apply(ctx) {
|
|
|
129
146
|
}
|
|
130
147
|
const init = async () => {
|
|
131
148
|
const h = await getHome()
|
|
132
|
-
|
|
149
|
+
// Use path.join so the default root uses the platform separator; on Windows
|
|
150
|
+
// a bare h + '/Music' produced a mixed "C:\Users\x/Music" root.
|
|
151
|
+
let root = h === null ? null : pathJoin(h, 'Music')
|
|
133
152
|
// Restore the user's last chosen directory; validate it so a deleted or
|
|
134
153
|
// renamed directory falls back to the default instead of erroring.
|
|
135
154
|
const stored = await loadStoredRoot()
|
|
@@ -153,6 +172,29 @@ export function apply(ctx) {
|
|
|
153
172
|
}
|
|
154
173
|
const ensureStarted = () => { if (startupPromise === null) startupPromise = init(); return startupPromise }
|
|
155
174
|
|
|
175
|
+
// Tolerant directory listing for the picker and the scan. dsh-fs-local's
|
|
176
|
+
// listDir is all-or-nothing: one unreadable child (pagefile.sys, System
|
|
177
|
+
// Volume Information, ...) aborts the entire listing, which made drive roots
|
|
178
|
+
// (and any dir containing protected entries) show up empty. Enumerate with
|
|
179
|
+
// node:fs instead, skip entries that cannot be stat'd, and report every
|
|
180
|
+
// entry with an isDir flag so callers can filter (picker: dirs only;
|
|
181
|
+
// scan: dirs to recurse + audio files to collect).
|
|
182
|
+
const listEntries = (dirPath) => {
|
|
183
|
+
let dirents = []
|
|
184
|
+
try { dirents = readdirSync(dirPath, { withFileTypes: true, encoding: 'utf8' }) } catch { return [] }
|
|
185
|
+
const out = []
|
|
186
|
+
for (const ent of dirents) {
|
|
187
|
+
try {
|
|
188
|
+
const isDir = ent.isDirectory() || (ent.isSymbolicLink() && statSync(pathJoin(dirPath, ent.name)).isDirectory())
|
|
189
|
+
out.push({ name: ent.name, isDir })
|
|
190
|
+
} catch {
|
|
191
|
+
// unreadable entry (EPERM/EBUSY/...): skip it, keep listing the rest
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
// ---- shared HTTP helpers ----
|
|
157
199
|
const writeJson = (res, value, status) => {
|
|
158
200
|
res.writeHead(status || 200, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -203,6 +245,24 @@ export function apply(ctx) {
|
|
|
203
245
|
await ensureStarted()
|
|
204
246
|
const raw = url.searchParams.get('path') || ''
|
|
205
247
|
try {
|
|
248
|
+
// Windows has no single root that lists every drive, so expose a
|
|
249
|
+
// sentinel ("__drives__") that enumerates the available drive roots.
|
|
250
|
+
// Browsing "up" from a drive root (e.g. C:\) lands here so users can
|
|
251
|
+
// switch to another drive.
|
|
252
|
+
if (raw === '__drives__') {
|
|
253
|
+
const isWin = typeof process !== 'undefined' && process.platform === 'win32'
|
|
254
|
+
if (isWin) {
|
|
255
|
+
const roots = []
|
|
256
|
+
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
257
|
+
const root = letter + ':\\'
|
|
258
|
+
try { if (existsSync(root)) roots.push({ name: root, path: root }) } catch {}
|
|
259
|
+
}
|
|
260
|
+
writeJson(res, { path: '__drives__', name: '\u672c\u673a\u78c1\u76d8', up: null, dirs: roots })
|
|
261
|
+
} else {
|
|
262
|
+
writeJson(res, { path: '/', name: '/', up: null, dirs: [] })
|
|
263
|
+
}
|
|
264
|
+
return
|
|
265
|
+
}
|
|
206
266
|
const base = raw === '' ? ((await getHome()) || '/') : raw
|
|
207
267
|
const expanded = base.startsWith('~/') ? ((await getHome()) || '') + '/' + base.slice(2) : base
|
|
208
268
|
const target = await ctx.fs.resolve(expanded)
|
|
@@ -212,13 +272,22 @@ export function apply(ctx) {
|
|
|
212
272
|
return
|
|
213
273
|
}
|
|
214
274
|
const abs = ctx.fs.processPath(target)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
275
|
+
// Parent / name computation must use the host filesystem's separators
|
|
276
|
+
// (Windows uses "\" and drive roots like C:\, POSIX uses "/"), so do it
|
|
277
|
+
// with node:path rather than guessing a separator in the browser.
|
|
278
|
+
const atRoot = pathParse(abs).dir === abs
|
|
279
|
+
// On Windows, "up" from a drive root goes to the drive-list sentinel so
|
|
280
|
+
// users can switch drives; at the POSIX root there is nowhere to go.
|
|
281
|
+
const up = atRoot
|
|
282
|
+
? (process.platform === 'win32' ? '__drives__' : null)
|
|
283
|
+
: dirname(abs)
|
|
284
|
+
// Tolerant listing (see listEntries): skip unreadable entries so
|
|
285
|
+
// drive roots like C:\ still show their normal folders instead of an
|
|
286
|
+
// empty list. Only directories are offered by the picker.
|
|
287
|
+
const dirs = listEntries(abs)
|
|
288
|
+
.filter((e) => e.isDir)
|
|
289
|
+
.map((e) => ({ name: e.name, path: pathJoin(abs, e.name) }))
|
|
290
|
+
writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs })
|
|
222
291
|
} catch (err) {
|
|
223
292
|
writeJson(res, { error: String((err && err.message) || err) }, 500)
|
|
224
293
|
}
|