dsh-music-panel 0.3.10 → 0.3.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.12
4
+
5
+ ### 变更
6
+
7
+ - 收藏爱心改为红色(`--dsw-alias-state-error-primary`)
8
+ - 多选「添加到播放列表…」改为「添加到歌单」下拉菜单:显示歌单名与曲目数、支持新建歌单并添加,外观与交互重做
9
+ - 歌曲右键菜单:添加到歌单、添加到播放列表、复制文件地址、在文件夹中显示、详细信息
10
+ - 歌单视图的「从歌单移除」移入右键菜单,行内 × 按钮移除
11
+ - 新增 Host 接口 `POST /music/reveal`(仅限已配置音乐目录内的文件):Windows `explorer /select`、macOS `open -R`、Linux `xdg-open`
12
+
13
+ ## 0.3.11
14
+
15
+ ### 变更
16
+
17
+ - 播放列表面板支持拖动移动位置:拖动顶部正在播放条/标题区即可,位置记在偏好里,下次打开恢复
18
+ - 播放列表面板内禁用文本框选(`user-select: none`)
19
+
3
20
  ## 0.3.10
4
21
 
5
22
  ### 变更
package/README.md CHANGED
@@ -30,7 +30,9 @@ DeepSeek Harness 的本地音乐插件:左侧栏一个音符入口,点开是
30
30
  - **外观设置**:输入框播放列表按钮(发送按钮左侧,可关闭)、播放页圆形封面 / 黑胶底盘 / 封面旋转。
31
31
  - **歌词设置**:歌词来源顺序(内嵌 / LRC,可调)、对齐方式、字体大小与粗细、模糊非当前歌词、翻译开关与翻译字号缩放、状态栏歌词(输入框内或输入框上方显示当前唱词)。
32
32
  - **歌词校准**:播放页 → 歌词设置里按 ±0.5s 调整偏移;「写回文件」把校准后的 LRC 写回 MP3(ID3 USLT)/ FLAC(Vorbis LYRICS)内嵌歌词,写前自动备份为 `<文件>.lyrics.bak`。
33
- - **收藏与最近播放**:底栏 / 歌曲行爱心加入「我喜欢」tab;统计页可切换「收听排行 / 最近播放」。
33
+ - **收藏与最近播放**:底栏 / 歌曲行爱心(红色)加入「我喜欢」tab;统计页可切换「收听排行 / 最近播放」。
34
+ - **右键菜单**:歌曲行右键可添加到歌单 / 添加到播放列表 / 复制文件地址 / 在文件夹中显示 / 查看详细信息;歌单视图里还可从歌单移除。
35
+ - **添加到歌单**:多选后从下拉菜单选择目标歌单(显示曲目数),或新建歌单并添加。
34
36
  - **队列编辑**:播放队列支持拖动排序与移除单曲。
35
37
  - **搜索**:标题 / 歌手 / 专辑 / 文件夹 / 路径 + 全拼 / 首字母(如 `zhoujielun`、`zjl`),并记住最近 8 条搜索。
36
38
  - **多标签页互斥**:用 Web Locks 选主,只有一个标签页发声与计时;其他标签页底栏提示「另一个标签页正在播放」。
@@ -82,6 +84,7 @@ plugin_manager install_bundle D:/SystemFiles/Documents/Project/DSH/plugins/dsh-m
82
84
  | GET | `/music/favorites` | 收藏列表(按收藏时间倒序) |
83
85
  | POST | `/music/favorites` | 切换收藏,body `{ path }` |
84
86
  | POST | `/music/lyrics/calibrate` | 保存歌词偏移 `{ path, offsetMs, write? }`;`write: true` 写回内嵌并清零偏移 |
87
+ | POST | `/music/reveal` | 在系统文件管理器中定位文件(仅已配置目录内),body `{ path }` |
85
88
  | GET | `/music/stats?range=week\|month\|all` | 按范围聚合的播放次数 / 时长 / 最近播放 |
86
89
  | GET | `/music/cover?path=…` | 内嵌封面;无封面返回 404 |
87
90
 
package/client.js CHANGED
@@ -33,6 +33,7 @@ window.__ModuleLoader__.load({
33
33
  /** Defaults for every appearance / lyric preference. */
34
34
  var PREF_DEFAULTS = {
35
35
  miniPlayer: true,
36
+ queuePos: null,
36
37
  composerQueueButton: true,
37
38
  circularCover: true,
38
39
  vinylRing: false,
@@ -121,6 +122,11 @@ window.__ModuleLoader__.load({
121
122
  'page.format': '格式',
122
123
  'page.bitrate': '码率',
123
124
  'page.sampleRate': '采样率',
125
+ 'page.artist': '歌手',
126
+ 'page.album': '专辑',
127
+ 'page.duration': '时长',
128
+ 'page.size': '文件大小',
129
+ 'page.plays': '播放次数',
124
130
  'page.none': '—',
125
131
  'page.selectedCount': '已选 {n} 首',
126
132
  'page.noResults': '没有匹配的歌曲',
@@ -171,7 +177,14 @@ window.__ModuleLoader__.load({
171
177
  'page.deleteConfirm': '删除「{name}」?此操作不可撤销。',
172
178
  'page.back': '返回',
173
179
  'page.playAll': '播放全部',
174
- 'page.addTo': '添加到播放列表…',
180
+ 'page.addTo': '添加到歌单',
181
+ 'page.addToPlaylist': '添加到播放列表',
182
+ 'page.addedToPlaylist': '已添加到播放列表',
183
+ 'page.reveal': '在文件夹中显示',
184
+ 'page.revealed': '已在文件夹中打开',
185
+ 'page.details': '详细信息',
186
+ 'page.noPlaylists': '还没有歌单',
187
+ 'page.newPlaylistAdd': '新建歌单并添加',
175
188
  'page.addedCount': '已添加 {n} 首',
176
189
  'page.trackCount': '{n} 首',
177
190
  'page.removeTrack': '从歌单移除',
@@ -297,6 +310,11 @@ window.__ModuleLoader__.load({
297
310
  'page.format': 'Format',
298
311
  'page.bitrate': 'Bitrate',
299
312
  'page.sampleRate': 'Sample rate',
313
+ 'page.artist': 'Artist',
314
+ 'page.album': 'Album',
315
+ 'page.duration': 'Duration',
316
+ 'page.size': 'File size',
317
+ 'page.plays': 'Plays',
300
318
  'page.none': '—',
301
319
  'page.selectedCount': '{n} selected',
302
320
  'page.noResults': 'No matching songs',
@@ -347,7 +365,14 @@ window.__ModuleLoader__.load({
347
365
  'page.deleteConfirm': 'Delete “{name}”? This cannot be undone.',
348
366
  'page.back': 'Back',
349
367
  'page.playAll': 'Play all',
350
- 'page.addTo': 'Add to playlist…',
368
+ 'page.addTo': 'Add to playlist',
369
+ 'page.addToPlaylist': 'Add to play queue',
370
+ 'page.addedToPlaylist': 'Added to play queue',
371
+ 'page.reveal': 'Show in folder',
372
+ 'page.revealed': 'Opened in folder',
373
+ 'page.details': 'Details',
374
+ 'page.noPlaylists': 'No playlists yet',
375
+ 'page.newPlaylistAdd': 'New playlist and add',
351
376
  'page.addedCount': '{n} added',
352
377
  'page.trackCount': '{n} tracks',
353
378
  'page.removeTrack': 'Remove from playlist',
@@ -773,6 +798,19 @@ window.__ModuleLoader__.load({
773
798
  folder: {
774
799
  parts: [['path', { d: 'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z' }]],
775
800
  },
801
+ copy: {
802
+ parts: [
803
+ ['rect', { x: 9, y: 9, width: 13, height: 13, rx: 2, ry: 2 }],
804
+ ['path', { d: 'M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1' }],
805
+ ],
806
+ },
807
+ info: {
808
+ parts: [
809
+ ['circle', { cx: 12, cy: 12, r: 10 }],
810
+ ['path', { d: 'M12 16v-4' }],
811
+ ['path', { d: 'M12 8h.01' }],
812
+ ],
813
+ },
776
814
  }
777
815
 
778
816
  function merge(base, extra) {
@@ -1660,6 +1698,21 @@ window.__ModuleLoader__.load({
1660
1698
  randomOrder = randomOrderOf(list.length)
1661
1699
  load(at, true)
1662
1700
  },
1701
+ /** Append one track to the queue without interrupting playback. */
1702
+ enqueue: function (track) {
1703
+ if (track === null || track === undefined || typeof track.path !== 'string' || track.path === '') return
1704
+ if (state.queue.length === 0) {
1705
+ api.playList([track], 0)
1706
+ return
1707
+ }
1708
+ state = Object.assign({}, state, {
1709
+ queue: state.queue.concat([track]),
1710
+ index: state.index < 0 ? 0 : state.index,
1711
+ })
1712
+ randomOrder = randomOrderOf(state.queue.length)
1713
+ emit()
1714
+ savePlaybackState()
1715
+ },
1663
1716
  select: function (list, at) {
1664
1717
  if (!list || list.length === 0 || at < 0) return
1665
1718
  if (at === state.index && sameQueue(list, state.queue)) {
@@ -2124,6 +2177,12 @@ window.__ModuleLoader__.load({
2124
2177
  function QueueOverlay() {
2125
2178
  var [open, setOpen] = useState(queueOverlaySnapshot())
2126
2179
  var [snap, setSnap] = useState(player.getSnapshot())
2180
+ var [pos, setPos] = useState(function () {
2181
+ var saved = readPrefs().queuePos
2182
+ if (saved === null || typeof saved !== 'object') return null
2183
+ if (!isFinite(saved.x) || !isFinite(saved.y)) return null
2184
+ return { x: Number(saved.x), y: Number(saved.y) }
2185
+ })
2127
2186
  // Drag state lives entirely in refs/DOM classes so React never
2128
2187
  // re-renders the dragged row mid-drag (Chromium cancels that).
2129
2188
  var dragFromRef = useRef(null)
@@ -2245,6 +2304,73 @@ window.__ModuleLoader__.load({
2245
2304
  document.addEventListener('pointercancel', onUp)
2246
2305
  }
2247
2306
 
2307
+ // Drag the whole panel by its header strip (buttons and queue rows keep
2308
+ // their own gestures). Position is remembered in prefs.
2309
+ function startPanelDrag(event) {
2310
+ if (event.button !== undefined && event.button !== 0) return
2311
+ var target = event.target
2312
+ if (target === null || target === undefined || typeof target.closest !== 'function') return
2313
+ if (target.closest('button, .mu-queue-row, .mu-queue-grip') !== null) return
2314
+ if (target.closest('.mu-qoverlay-now, .mu-qoverlay-head') === null) return
2315
+ var el = ref.current
2316
+ if (el === null) return
2317
+ var rect = el.getBoundingClientRect()
2318
+ var start = {
2319
+ x: event.clientX,
2320
+ y: event.clientY,
2321
+ left: rect.left,
2322
+ top: rect.top,
2323
+ width: rect.width,
2324
+ height: rect.height,
2325
+ moved: false,
2326
+ }
2327
+ event.preventDefault()
2328
+ document.body.style.userSelect = 'none'
2329
+ document.body.style.cursor = 'grabbing'
2330
+ el.classList.add('is-dragging-panel')
2331
+
2332
+ function clamp(value, min, max) {
2333
+ return Math.max(min, Math.min(max, value))
2334
+ }
2335
+
2336
+ function onMove(moveEvent) {
2337
+ start.moved = true
2338
+ var x = clamp(
2339
+ start.left + (moveEvent.clientX - start.x),
2340
+ 8,
2341
+ Math.max(8, window.innerWidth - start.width - 8),
2342
+ )
2343
+ var y = clamp(
2344
+ start.top + (moveEvent.clientY - start.y),
2345
+ 8,
2346
+ Math.max(8, window.innerHeight - start.height - 8),
2347
+ )
2348
+ el.style.left = x + 'px'
2349
+ el.style.top = y + 'px'
2350
+ el.style.right = 'auto'
2351
+ el.style.bottom = 'auto'
2352
+ }
2353
+
2354
+ function onUp() {
2355
+ document.removeEventListener('pointermove', onMove)
2356
+ document.removeEventListener('pointerup', onUp)
2357
+ document.removeEventListener('pointercancel', onUp)
2358
+ document.body.style.userSelect = ''
2359
+ document.body.style.cursor = ''
2360
+ el.classList.remove('is-dragging-panel')
2361
+ if (start.moved) {
2362
+ var next = el.getBoundingClientRect()
2363
+ var position = { x: Math.round(next.left), y: Math.round(next.top) }
2364
+ setPos(position)
2365
+ savePrefs({ queuePos: position })
2366
+ }
2367
+ }
2368
+
2369
+ document.addEventListener('pointermove', onMove)
2370
+ document.addEventListener('pointerup', onUp)
2371
+ document.addEventListener('pointercancel', onUp)
2372
+ }
2373
+
2248
2374
  var rows = queueView.map(function (entry, position) {
2249
2375
  var track = entry.track
2250
2376
  var originalIndex = entry.originalIndex
@@ -2299,7 +2425,17 @@ window.__ModuleLoader__.load({
2299
2425
 
2300
2426
  return h(
2301
2427
  'div',
2302
- { className: 'mu-qoverlay', ref: ref },
2428
+ {
2429
+ className: 'mu-qoverlay',
2430
+ ref: ref,
2431
+ style:
2432
+ pos !== null
2433
+ ? { left: pos.x + 'px', top: pos.y + 'px', right: 'auto', bottom: 'auto' }
2434
+ : undefined,
2435
+ onPointerDown: function (event) {
2436
+ startPanelDrag(event)
2437
+ },
2438
+ },
2303
2439
  h(QueueNowPlaying, null),
2304
2440
  h(
2305
2441
  'div',
@@ -3490,6 +3626,22 @@ window.__ModuleLoader__.load({
3490
3626
  setQueueOpen(queueOverlaySnapshot())
3491
3627
  })
3492
3628
  }, [])
3629
+ useEffect(
3630
+ function () {
3631
+ if (ctxMenu === null) return undefined
3632
+ function onKey(event) {
3633
+ if (event.key === 'Escape') setCtxMenu(null)
3634
+ }
3635
+ window.addEventListener('keydown', onKey)
3636
+ return function () {
3637
+ window.removeEventListener('keydown', onKey)
3638
+ }
3639
+ },
3640
+ [ctxMenu],
3641
+ )
3642
+ var [ctxMenu, setCtxMenu] = useState(null)
3643
+ var [addToOpen, setAddToOpen] = useState(false)
3644
+ var [infoPath, setInfoPath] = useState(null)
3493
3645
  var [moreOpen, setMoreOpen] = useState(false)
3494
3646
  var [toast, setToast] = useState('')
3495
3647
  var toastTimerRef = useRef(null)
@@ -3784,22 +3936,51 @@ window.__ModuleLoader__.load({
3784
3936
  setSelected([])
3785
3937
  }
3786
3938
 
3787
- function copyPath() {
3788
- if (!current) return
3939
+ function copyPath(track) {
3940
+ var target = track && track.path ? track : current
3941
+ if (!target) return
3789
3942
  try {
3790
- navigator.clipboard.writeText(current.path).then(
3943
+ navigator.clipboard.writeText(target.path).then(
3791
3944
  function () {
3792
3945
  showToast(t('page.copied'))
3793
3946
  },
3794
3947
  function () {
3795
- showToast(current.path)
3948
+ showToast(target.path)
3796
3949
  },
3797
3950
  )
3798
3951
  } catch (error) {
3799
- showToast(current.path)
3952
+ showToast(target.path)
3800
3953
  }
3801
3954
  }
3802
3955
 
3956
+ function revealTrack(track) {
3957
+ if (!track) return
3958
+ requestJson('POST', API + 'reveal', { path: track.path })
3959
+ .then(function () {
3960
+ showToast(t('page.revealed'))
3961
+ })
3962
+ .catch(function (error) {
3963
+ showToast(String((error && error.message) || error))
3964
+ })
3965
+ }
3966
+
3967
+ function createPlaylistWithSelection() {
3968
+ var paths = selectedTracks.map(function (track) {
3969
+ return track.path
3970
+ })
3971
+ if (paths.length === 0) return
3972
+ requestJson('POST', API + 'playlists', { name: '' })
3973
+ .then(function (body) {
3974
+ return patchPlaylist(body.playlist.id, { add: paths })
3975
+ })
3976
+ .then(function () {
3977
+ showToast(t('page.addedCount', { n: paths.length }))
3978
+ })
3979
+ .catch(function (error) {
3980
+ showToast(String((error && error.message) || error))
3981
+ })
3982
+ }
3983
+
3803
3984
  var trackByPath = useMemo(function () {
3804
3985
  var map = {}
3805
3986
  tracks.forEach(function (track) {
@@ -4004,6 +4185,12 @@ window.__ModuleLoader__.load({
4004
4185
  onDoubleClick: function () {
4005
4186
  activate(track)
4006
4187
  },
4188
+ onContextMenu: function (event) {
4189
+ event.preventDefault()
4190
+ event.stopPropagation()
4191
+ setMoreOpen(false)
4192
+ setCtxMenu({ x: event.clientX, y: event.clientY, path: track.path, mode: 'main' })
4193
+ },
4007
4194
  },
4008
4195
  h(
4009
4196
  'button',
@@ -4064,23 +4251,6 @@ window.__ModuleLoader__.load({
4064
4251
  },
4065
4252
  icon('heart', 14),
4066
4253
  ),
4067
- view === 'playlists' && activePlaylist !== undefined
4068
- ? h(
4069
- 'button',
4070
- {
4071
- type: 'button',
4072
- className: 'mu-icon-btn mu-icon-btn-sm',
4073
- title: t('page.removeTrack'),
4074
- onClick: function (event) {
4075
- event.stopPropagation()
4076
- patchPlaylist(activePlaylist.id, { remove: [track.path] }).catch(function (error) {
4077
- showToast(String((error && error.message) || error))
4078
- })
4079
- },
4080
- },
4081
- icon('x', 14),
4082
- )
4083
- : null,
4084
4254
  h(
4085
4255
  'button',
4086
4256
  {
@@ -4268,6 +4438,14 @@ window.__ModuleLoader__.load({
4268
4438
  },
4269
4439
  })
4270
4440
  : null,
4441
+ addToOpen
4442
+ ? h('div', {
4443
+ className: 'mu-addto-scrim',
4444
+ onClick: function () {
4445
+ setAddToOpen(false)
4446
+ },
4447
+ })
4448
+ : null,
4271
4449
  h(
4272
4450
  'div',
4273
4451
  { className: 'mu-head' },
@@ -4939,21 +5117,60 @@ window.__ModuleLoader__.load({
4939
5117
  selected.length === visible.length ? t('page.selectNone') : t('page.selectAll'),
4940
5118
  ),
4941
5119
  h(
4942
- 'select',
4943
- {
4944
- className: 'mu-select',
4945
- value: '',
4946
- title: t('page.addTo'),
4947
- onChange: function (event) {
4948
- var value = event.target.value
4949
- event.target.value = ''
4950
- if (value !== '') addSelectedTo(value)
5120
+ 'div',
5121
+ { className: 'mu-addto' },
5122
+ h(
5123
+ 'button',
5124
+ {
5125
+ type: 'button',
5126
+ className: 'mu-chip' + (addToOpen ? ' is-on' : ''),
5127
+ onClick: function () {
5128
+ setAddToOpen(!addToOpen)
5129
+ },
4951
5130
  },
4952
- },
4953
- h('option', { value: '' }, t('page.addTo')),
4954
- playlists.map(function (playlist) {
4955
- return h('option', { key: playlist.id, value: playlist.id }, playlist.name)
4956
- }),
5131
+ icon('plus', 13),
5132
+ h('span', null, t('page.addTo')),
5133
+ icon('chevronDown', 12),
5134
+ ),
5135
+ addToOpen
5136
+ ? h(
5137
+ 'div',
5138
+ { className: 'mu-addto-menu' },
5139
+ playlists.length === 0
5140
+ ? h('div', { className: 'mu-addto-empty' }, t('page.noPlaylists'))
5141
+ : playlists.map(function (playlist) {
5142
+ return h(
5143
+ 'button',
5144
+ {
5145
+ key: playlist.id,
5146
+ type: 'button',
5147
+ className: 'mu-addto-item',
5148
+ onClick: function () {
5149
+ addSelectedTo(playlist.id)
5150
+ setAddToOpen(false)
5151
+ },
5152
+ },
5153
+ icon('list', 12),
5154
+ h('span', { className: 'mu-ellipsis' }, playlist.name),
5155
+ h('span', { className: 'mu-addto-count' }, String(playlist.trackPaths.length)),
5156
+ )
5157
+ }),
5158
+ h('div', { className: 'mu-addto-sep' }),
5159
+ h(
5160
+ 'button',
5161
+ {
5162
+ type: 'button',
5163
+ className: 'mu-addto-item',
5164
+ onClick: function () {
5165
+ createPlaylistWithSelection()
5166
+ setAddToOpen(false)
5167
+ },
5168
+ },
5169
+ icon('plus', 12),
5170
+ h('span', null, t('page.newPlaylistAdd')),
5171
+ ),
5172
+ )
5173
+ : null,
4957
5174
  ),
4958
5175
  )
4959
5176
  : null,
@@ -5197,7 +5414,7 @@ window.__ModuleLoader__.load({
5197
5414
  : h('div', { className: 'mu-state-sub' }, t('page.noTrack')),
5198
5415
  h(
5199
5416
  'button',
5200
- { type: 'button', className: 'mu-menu-item', disabled: !current, onClick: copyPath },
5417
+ { type: 'button', className: 'mu-menu-item', disabled: !current, onClick: function () { copyPath() } },
5201
5418
  h('span', null, t('page.copyPath')),
5202
5419
  ),
5203
5420
  )
@@ -5205,6 +5422,206 @@ window.__ModuleLoader__.load({
5205
5422
 
5206
5423
  ),
5207
5424
 
5425
+ ctxMenu !== null
5426
+ ? (function () {
5427
+ var target = trackByPath[ctxMenu.path] || null
5428
+ var winWidth = typeof window !== 'undefined' && window.innerWidth ? window.innerWidth : 1200
5429
+ var winHeight = typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800
5430
+ var left = Math.max(8, Math.min(ctxMenu.x, winWidth - 220))
5431
+ var top = Math.max(8, Math.min(ctxMenu.y, winHeight - (ctxMenu.mode === 'playlists' ? 320 : 280)))
5432
+ function close() {
5433
+ setCtxMenu(null)
5434
+ }
5435
+ var children = []
5436
+ if (ctxMenu.mode === 'playlists') {
5437
+ children.push(
5438
+ h('div', { className: 'mu-ctx-head', key: 'head' }, t('page.addTo')),
5439
+ playlists.length === 0
5440
+ ? h('div', { className: 'mu-addto-empty', key: 'empty' }, t('page.noPlaylists'))
5441
+ : playlists.map(function (playlist) {
5442
+ return h(
5443
+ 'button',
5444
+ {
5445
+ key: playlist.id,
5446
+ type: 'button',
5447
+ className: 'mu-ctx-item',
5448
+ onClick: function () {
5449
+ if (target === null) return
5450
+ patchPlaylist(playlist.id, { add: [target.path] })
5451
+ .then(function () {
5452
+ showToast(t('page.addedCount', { n: 1 }))
5453
+ })
5454
+ .catch(function (error) {
5455
+ showToast(String((error && error.message) || error))
5456
+ })
5457
+ close()
5458
+ },
5459
+ },
5460
+ icon('list', 12),
5461
+ h('span', { className: 'mu-ellipsis' }, playlist.name),
5462
+ h('span', { className: 'mu-addto-count' }, String(playlist.trackPaths.length)),
5463
+ )
5464
+ }),
5465
+ )
5466
+ } else {
5467
+ children.push(
5468
+ h(
5469
+ 'button',
5470
+ {
5471
+ key: 'add-to-playlist',
5472
+ type: 'button',
5473
+ className: 'mu-ctx-item',
5474
+ onClick: function () {
5475
+ setCtxMenu({ x: ctxMenu.x, y: ctxMenu.y, path: ctxMenu.path, mode: 'playlists' })
5476
+ },
5477
+ },
5478
+ icon('list', 12),
5479
+ h('span', { className: 'mu-ellipsis' }, t('page.addTo')),
5480
+ h('span', { className: 'mu-ctx-arrow' }, '›'),
5481
+ ),
5482
+ h(
5483
+ 'button',
5484
+ {
5485
+ key: 'enqueue',
5486
+ type: 'button',
5487
+ className: 'mu-ctx-item',
5488
+ onClick: function () {
5489
+ if (target !== null) {
5490
+ player.enqueue(target)
5491
+ showToast(t('page.addedToPlaylist'))
5492
+ }
5493
+ close()
5494
+ },
5495
+ },
5496
+ icon('plus', 12),
5497
+ h('span', null, t('page.addToPlaylist')),
5498
+ ),
5499
+ h('div', { className: 'mu-ctx-sep', key: 'sep1' }),
5500
+ h(
5501
+ 'button',
5502
+ {
5503
+ key: 'copy',
5504
+ type: 'button',
5505
+ className: 'mu-ctx-item',
5506
+ onClick: function () {
5507
+ copyPath(target)
5508
+ close()
5509
+ },
5510
+ },
5511
+ icon('copy', 12),
5512
+ h('span', null, t('page.copyPath')),
5513
+ ),
5514
+ h(
5515
+ 'button',
5516
+ {
5517
+ key: 'reveal',
5518
+ type: 'button',
5519
+ className: 'mu-ctx-item',
5520
+ onClick: function () {
5521
+ revealTrack(target)
5522
+ close()
5523
+ },
5524
+ },
5525
+ icon('folder', 12),
5526
+ h('span', null, t('page.reveal')),
5527
+ ),
5528
+ h(
5529
+ 'button',
5530
+ {
5531
+ key: 'details',
5532
+ type: 'button',
5533
+ className: 'mu-ctx-item',
5534
+ onClick: function () {
5535
+ setInfoPath(ctxMenu.path)
5536
+ close()
5537
+ },
5538
+ },
5539
+ icon('info', 12),
5540
+ h('span', null, t('page.details')),
5541
+ ),
5542
+ view === 'playlists' && activePlaylist !== undefined
5543
+ ? h(
5544
+ 'button',
5545
+ {
5546
+ key: 'remove',
5547
+ type: 'button',
5548
+ className: 'mu-ctx-item is-danger',
5549
+ onClick: function () {
5550
+ if (target !== null) {
5551
+ patchPlaylist(activePlaylist.id, { remove: [target.path] }).catch(function (error) {
5552
+ showToast(String((error && error.message) || error))
5553
+ })
5554
+ }
5555
+ close()
5556
+ },
5557
+ },
5558
+ icon('x', 12),
5559
+ h('span', null, t('page.removeTrack')),
5560
+ )
5561
+ : null,
5562
+ )
5563
+ }
5564
+ return h(
5565
+ 'div',
5566
+ { key: 'ctx-layer' },
5567
+ h('div', { className: 'mu-ctx-scrim', onClick: close }),
5568
+ h('div', { className: 'mu-ctx', style: { left: left + 'px', top: top + 'px' } }, children),
5569
+ )
5570
+ })()
5571
+ : null,
5572
+
5573
+ infoPath !== null
5574
+ ? (function () {
5575
+ var info = trackByPath[infoPath]
5576
+ if (!info) return null
5577
+ var rows = [
5578
+ [t('page.title'), info.title],
5579
+ [t('page.artist'), info.artist || t('page.unknownArtist')],
5580
+ [t('page.album'), info.album || t('page.none')],
5581
+ [t('page.duration'), fmtTime(info.durationSec)],
5582
+ [t('page.format'), (info.codec || info.ext || '').toUpperCase()],
5583
+ [t('page.bitrate'), fmtKbps(info.bitrate) || t('page.none')],
5584
+ [t('page.sampleRate'), fmtKhz(info.sampleRate) || t('page.none')],
5585
+ [t('page.size'), info.size > 0 ? (info.size / 1024 / 1024).toFixed(2) + ' MB' : t('page.none')],
5586
+ [t('page.plays'), String(info.plays || 0)],
5587
+ [t('page.path'), info.path],
5588
+ ]
5589
+ return h(
5590
+ 'div',
5591
+ { className: 'mu-info-layer' },
5592
+ h('div', { className: 'mu-info-scrim', onClick: function () { setInfoPath(null) } }),
5593
+ h(
5594
+ 'div',
5595
+ { className: 'mu-info' },
5596
+ h(
5597
+ 'div',
5598
+ { className: 'mu-info-head' },
5599
+ h('span', { className: 'mu-info-title' }, t('page.details')),
5600
+ h(
5601
+ 'button',
5602
+ {
5603
+ type: 'button',
5604
+ className: 'mu-icon-btn mu-icon-btn-sm',
5605
+ onClick: function () {
5606
+ setInfoPath(null)
5607
+ },
5608
+ },
5609
+ icon('x', 14),
5610
+ ),
5611
+ ),
5612
+ rows.map(function (row) {
5613
+ return h(
5614
+ 'div',
5615
+ { className: 'mu-more-row', key: row[0] },
5616
+ h('span', { className: 'mu-more-key' }, row[0]),
5617
+ h('span', { className: 'mu-more-val mu-ellipsis', title: row[1] }, row[1]),
5618
+ )
5619
+ }),
5620
+ ),
5621
+ )
5622
+ })()
5623
+ : null,
5624
+
5208
5625
  toast !== '' ? h('div', { className: 'mu-toast' }, toast) : null,
5209
5626
 
5210
5627
  npOpen
@@ -5527,7 +5944,10 @@ window.__ModuleLoader__.load({
5527
5944
  '.mu-bar-right{display:flex;align-items:center;gap:6px;justify-self:end}',
5528
5945
  '.mu-quality{font-size:12px;color:var(--dsw-alias-label-tertiary,#a1a1aa);font-variant-numeric:tabular-nums;white-space:nowrap}',
5529
5946
  '.mu-pop{position:absolute;right:16px;bottom:84px;z-index:40;width:330px;max-height:58%;display:flex;flex-direction:column;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-1,#1a1a1e);border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));box-shadow:var(--dsw-elevation-prominent,0 16px 48px rgba(0,0,0,.5));overflow:hidden}',
5530
- '.mu-qoverlay{position:fixed;right:24px;bottom:96px;z-index:70;width:360px;max-width:calc(100vw - 48px);max-height:62vh;display:flex;flex-direction:column;border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));border-radius:14px;background:var(--dsw-alias-bg-layer-1,#1a1a1e);box-shadow:var(--dsw-elevation-prominent,0 16px 48px rgba(0,0,0,.5));overflow:hidden}',
5947
+ '.mu-qoverlay{position:fixed;right:24px;bottom:96px;z-index:70;width:360px;max-width:calc(100vw - 48px);max-height:62vh;display:flex;flex-direction:column;border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));border-radius:14px;background:var(--dsw-alias-bg-layer-1,#1a1a1e);box-shadow:var(--dsw-elevation-prominent,0 16px 48px rgba(0,0,0,.5));overflow:hidden;user-select:none}',
5948
+ '.mu-qoverlay.is-dragging-panel{cursor:grabbing}',
5949
+ '.mu-qoverlay-now,.mu-qoverlay-head{cursor:grab}',
5950
+ '.mu-qoverlay-now:active,.mu-qoverlay-head:active{cursor:grabbing}',
5531
5951
  '.mu-qoverlay-now{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}',
5532
5952
  '.mu-qoverlay-now.is-empty{padding:14px 12px;color:var(--dsw-alias-label-tertiary,#8a8a93);font-size:12px}',
5533
5953
  '.mu-qoverlay-now-main{flex:1;min-width:0}',
@@ -5552,8 +5972,29 @@ window.__ModuleLoader__.load({
5552
5972
  '.mu-queue-btn{width:20px;height:20px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a8a93);font-size:12px;line-height:20px;cursor:pointer}',
5553
5973
  '.mu-queue-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#f4f4f5)}',
5554
5974
  '.mu-queue-btn:disabled{opacity:.35;cursor:default}',
5555
- '.mu-fav.is-on{color:var(--dsw-alias-brand-primary,#f4f4f5)}',
5975
+ '.mu-fav.is-on{color:var(--dsw-alias-state-error-primary,#e5484d)}',
5556
5976
  '.mu-fav.is-on svg{fill:currentColor}',
5977
+ '.mu-addto{position:relative}',
5978
+ '.mu-addto-menu{position:absolute;right:0;top:calc(100% + 4px);z-index:53;min-width:210px;max-height:280px;overflow-y:auto;padding:4px;border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));border-radius:10px;background:var(--dsw-alias-bg-layer-1,#1a1a1e);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.45))}',
5979
+ '.mu-addto-item{display:flex;align-items:center;gap:8px;width:100%;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary,#f4f4f5);font-size:12px;text-align:left;cursor:pointer}',
5980
+ '.mu-addto-item:hover{background:var(--dsw-alias-bg-layer-2,#2a2a2e)}',
5981
+ '.mu-addto-count{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8a93);font-size:11px;font-variant-numeric:tabular-nums}',
5982
+ '.mu-addto-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,rgba(255,255,255,.08))}',
5983
+ '.mu-addto-empty{padding:10px;font-size:12px;color:var(--dsw-alias-label-tertiary,#8a8a93)}',
5984
+ '.mu-addto-scrim{position:fixed;inset:0;z-index:52}',
5985
+ '.mu-ctx-scrim{position:fixed;inset:0;z-index:89}',
5986
+ '.mu-ctx{position:fixed;z-index:90;min-width:200px;max-height:320px;overflow-y:auto;padding:4px;border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));border-radius:10px;background:var(--dsw-alias-bg-layer-1,#1a1a1e);box-shadow:var(--dsw-elevation-prominent,0 16px 40px rgba(0,0,0,.5))}',
5987
+ '.mu-ctx-head{padding:6px 10px 4px;font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a93)}',
5988
+ '.mu-ctx-item{display:flex;align-items:center;gap:8px;width:100%;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary,#f4f4f5);font-size:12px;text-align:left;cursor:pointer}',
5989
+ '.mu-ctx-item:hover{background:var(--dsw-alias-bg-layer-2,#2a2a2e)}',
5990
+ '.mu-ctx-item.is-danger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 16%,transparent);color:var(--dsw-alias-state-error-primary,#e5484d)}',
5991
+ '.mu-ctx-arrow{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8a93);font-size:14px;line-height:1}',
5992
+ '.mu-ctx-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,rgba(255,255,255,.08))}',
5993
+ '.mu-info-layer{position:fixed;inset:0;z-index:94;pointer-events:none}',
5994
+ '.mu-info-scrim{position:absolute;inset:0;background:rgba(0,0,0,.35);pointer-events:auto}',
5995
+ '.mu-info{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:440px;max-width:calc(100vw - 48px);max-height:70vh;overflow-y:auto;padding:16px;border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));border-radius:14px;background:var(--dsw-alias-bg-layer-1,#1a1a1e);box-shadow:var(--dsw-elevation-prominent,0 20px 60px rgba(0,0,0,.55));pointer-events:auto}',
5996
+ '.mu-info-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}',
5997
+ '.mu-info-title{font-size:14px;font-weight:600}',
5557
5998
  '.mu-chip-sm{height:24px;padding:0 8px;font-size:11px}',
5558
5999
  '.mu-search-history{position:absolute;top:34px;right:0;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:320px;padding:6px;border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,#1a1a1e);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.35))}',
5559
6000
  '.mu-queue-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}',
package/lib/routes.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * @module dsh-music-panel/routes
22
22
  */
23
23
 
24
+ import { spawn } from 'node:child_process'
24
25
  import { createReadStream } from 'node:fs'
25
26
  import { readdir, realpath, stat } from 'node:fs/promises'
26
27
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
@@ -388,6 +389,36 @@ export function createRoutes({ settings, library, playlists, stats, playback, fa
388
389
  return sendJson(res, 200, { ok: true, favorite: result.favorite, favorites: result.favorites })
389
390
  }
390
391
 
392
+ // Reveal a track in the OS file manager. Only files already inside the
393
+ // configured roots reach this point; args are passed to spawn without a
394
+ // shell, so the path cannot inject commands.
395
+ if (pathname === '/reveal' && method === 'POST') {
396
+ let body
397
+ try {
398
+ body = await readJson(req)
399
+ } catch {
400
+ return fail(res, 400, 'BAD_JSON', '请求体不是合法 JSON')
401
+ }
402
+ const config = await settings.read()
403
+ const file = await resolveTrack(body?.path, config.roots)
404
+ if (file === null) return fail(res, 404, 'NOT_FOUND', '文件不在已配置的音乐目录内')
405
+ try {
406
+ const opener =
407
+ process.platform === 'win32'
408
+ ? { name: 'explorer.exe', args: ['/select,' + file] }
409
+ : process.platform === 'darwin'
410
+ ? { name: 'open', args: ['-R', file] }
411
+ : { name: 'xdg-open', args: [dirname(file)] }
412
+ const child = spawn(opener.name, opener.args, { detached: true, stdio: 'ignore' })
413
+ // Without this listener an ENOENT would surface as an unhandled error.
414
+ child.on('error', () => {})
415
+ child.unref()
416
+ return sendJson(res, 200, { ok: true })
417
+ } catch (error) {
418
+ return fail(res, 500, 'REVEAL_FAILED', String(error?.message ?? error))
419
+ }
420
+ }
421
+
391
422
  if (pathname === '/playlists' && method === 'GET') {
392
423
  return sendJson(res, 200, { ok: true, playlists: await playlists.list() })
393
424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-panel",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "type": "module",
5
5
  "description": "Local music library for DeepSeek Harness: scan a folder, browse tracks with cover art and tags, and play through a sidebar-panel player with loop, shuffle and volume. · DSH 本地音乐插件:扫描音乐目录、读取封面与标签、侧栏入口 + 全屏播放器。",
6
6
  "license": "MIT",