在界面右下角拖入音频或粘贴歌单链接即可播放
30.6/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/25 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add lilcandi/dsh-music-on该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · vision
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 11 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🟢 扫描:未发现敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描未发现敏感能力:最多只发网络请求、读取自身元数据或只读文件,未发现执行命令、文件写入或敏感信息读取。
发起外部网络请求
查看扫描证据(共 1 处,展示前 1 条)
- 发起网络请求client.js:6200
const res = await fetch(target, { credentials: "same-origin", headers: { accept: "application/json" } });
扫描于 2026/11/2(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/dsh给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
README
由 DeepSeek 最新模型翻译生成dsh-music-on
DSH Web 界面右下角的音乐台。一枚可拖动的胶囊常驻在那里,点开是播放卡片:拖入本地音频即播、粘贴 B 站 / 网易云音乐 / QQ 音乐的歌单链接自动解析入队、页面底部跟着一条蓝白频谱。
- 不修改宿主任何文件:只走宿主公开的扩展点(槽位 / 服务 / 路由)。
- 浏览器半边零转译:宿主对插件客户端代码是零转译静态投递,所以全部用 react.createElement,不许 JSX 语法。
- 诚实降级:拿不到直链的曲目(会员 / 版权限制)直接跳过,不伪造一个播不了的条目。
功能
- 播放:队列、上一首 / 下一首、进度条拖动、音量旋钮(悬停滚轮可调)、三种循环模式、切歌淡入淡出。
- 拖入本地音频:文件拖到卡片上即入队播放,支持 mp3 / wav / ogg / m4a / flac / aac / opus / webm。
- 粘贴链接:B 站视频、网易云歌单 / 单曲、QQ 音乐歌单 / 单曲、直链音频;一次可粘多条,混着也行。
- 频谱:圆点 / 粗柱 / 方块阵列 / 对称声波 / 折线 / 时域波形六种画法,可调宽度、柱数、灵敏度、光晕、高度、帧率、层级、贴底通栏或嵌在卡片里。
- 七套配色主题:鲸鱼蓝白(默认)、极光青紫、薄荷绿、落日橙、玫瑰粉、霓虹赛博、极简灰白。
展开完整文档(README 剩余 1 节)
dsh-music-on
DSH Web 界面右下角的音乐台。一枚可拖动的胶囊常驻在那里,点开是播放卡片:拖入本地音频即播、粘贴 B 站 / 网易云音乐 / QQ 音乐的歌单链接自动解析入队、页面底部跟着一条蓝白频谱。
- 不修改宿主任何文件:只走宿主公开的扩展点(槽位 / 服务 / 路由)。
- 浏览器半边零转译:宿主对插件客户端代码是零转译静态投递,所以全部用 react.createElement,不许 JSX 语法。
- 诚实降级:拿不到直链的曲目(会员 / 版权限制)直接跳过,不伪造一个播不了的条目。
功能
- 播放:队列、上一首 / 下一首、进度条拖动、音量旋钮(悬停滚轮可调)、三种循环模式、切歌淡入淡出。
- 拖入本地音频:文件拖到卡片上即入队播放,支持 mp3 / wav / ogg / m4a / flac / aac / opus / webm。
- 粘贴链接:B 站视频、网易云歌单 / 单曲、QQ 音乐歌单 / 单曲、直链音频;一次可粘多条,混着也行。
- 频谱:圆点 / 粗柱 / 方块阵列 / 对称声波 / 折线 / 时域波形六种画法,可调宽度、柱数、灵敏度、光晕、高度、帧率、层级、贴底通栏或嵌在卡片里。
- 七套配色主题:鲸鱼蓝白(默认)、极光青紫、薄荷绿、落日橙、玫瑰粉、霓虹赛博、极简灰白。
- 封面:平台曲目读该曲自己的专辑封面,B 站曲目读视频封面。
- 设置页:设置 → 音乐台,界面 / 外观主题 / 播放 / 链接与歌单 / 频谱五组。
- 收藏与历史:设置页里两张表,收藏与最近播放的链接可一键回放。
安装
从 GitHub 装(首次会要求授权构建,按提示在 profile 的 pnpm-workspace.yaml 里加 allowBuilds)
dsh plugin --profile web add github:lilcandi/dsh-music-on
装上后刷新浏览器(Ctrl+F5)即可,右下角应出现音乐胶囊。
从本地路径装
dsh plugin --profile web add file:J:/plugin/dsh-music-on
手工挂载
1. 编辑 profile 的 package.json(例如 C:\Users\candi\.dsh\profiles\web\package.json):
{
"dependencies": {
"dsh-music-on": "file:J:/plugin/dsh-music-on"
},
"dsh": {
"profile": {
"bundles": ["dsh-music-on"]
}
}
}
2. 在 profile 目录下执行 pnpm install。
⚠️ 只写 cordis.patch.yml 而不把包名加进 dsh.profile.bundles,插件不会被挂载。
这是本机踩过的坑:patch 一个从未被 insert 的 entry 不会让插件启动,日志里连一行报错都没有。
验证装载
宿主半边是否被 Loader 认到(能看到 index.js 被读走) Get-Content C:\Users\candi\.dsh\logs\.log -Tail 50 -ErrorAction SilentlyContinue | Select-String 'dsh-music-on'
浏览器半边是否被拼进 combo(换掉 rev 也行,看 200 / 内容) Invoke-WebRequest 'http://127.0.0.1:3080/plugins/??dsh-music-on/client.js&rev=0' | Select-Object StatusCode
装载成功后,右下角会出现音乐胶囊;设置页左侧导航会多一项「音乐台」。
加载链路(谁在什么时候读哪个文件)
宿主启动 └─ Loader 读 profile 的 bundles ├─ import dsh-music-on → index.js (宿主半边, ESM) │ └─ src/index.js 提供 /api/dsh-music-on/ 路由 └─ 扫描 package.json 的 dsh.client └─ 服务端 readFileSync(exports["./client"]) → client.js (browser 半边) └─ window.__ModuleLoader__.load({ id, factory }) └─ 浏览器首次 require 时执行 factory └─ require('react') → 内联实现(esbuild 已把 src/client/** 全打包进来) └─ apply(ctx) ├─ styles.ensureStyle() 注入 ├─ registerShellDock() 挂 shell.overlay 槽位(右下角音乐台) ├─ registerSettingsSection() 挂 settings.section 槽位 └─ registerTabType()/Seats() 右侧栏标签页形态(保留)
关键事实(均已在本机宿主包里核实,非推测):
| 事实 | 证据位置 |
| dsh.client 只接受 platform / inject / external / immediately 四个字段,其余键被忽略 | ...\dsh\node_modules\@deepseek-ai\dsh-client-modules\lib\index.js:62(parseDshClient) |
| 客户端 bundle 路径来自 exports["./client"],缺了直接报错 | 同文件 :172(clientExportOf) |
| 服务端只做 readFileSync 后拼 combo,全程无 esbuild/babel | 同文件 :274 / :631 / :834(PLUGIN_ROUTE 一线) |
| 浏览器模块表只种 10 个包(react 系 + cordis + zod + react-virtual + 4 个 dsh-client-* ) | ...\dsh-web-frontend\dist\assets\index-.js 的 staticModules(函数 SZ) |
| require() 只认平台种子词与已注册工厂,相对路径不解析 | ...\dsh-client-modules\lib\client.js:833-841 |
后果之一:client.js 里出现相对 require → require("…") missed the module table → 整插件不加载。
所以 src/client/ 全部经 esbuild 内联进单文件 client.js。
后果之二:真正的 JSX 语法在浏览器里会 SyntaxError → 面板白屏。
所以 src/client/ 里即使是 .jsx 文件也只写 createElement as h,构建期才做转译。
目录结构
dsh-music-on/
├── package.json # dsh.bundle / dsh.client / exports 三处是装载的关键 ├── index.js # 宿主半边入口,只做 re-export ├── client.js # 浏览器半边构建产物(__ModuleLoader__ 协议),由 scripts/build-client.mjs 生成 ├── cordis.patch.yml # bundle patch(顶层必须是 YAML 数组,见下) ├── LICENSE # MIT ├── README.md / README.en.md ├── scripts/ # 构建与自检(不进安装位置) │ ├── build-client.mjs # esbuild 打 client.js │ ├── check-install.mjs # 安装位置 20 项自检 │ ├── check-spectrum.mjs # 频谱绘制 27 项自检 │ ├── simulate-browser.mjs # vm 里真跑 client.js,验证槽位注册与首渲染 │ ├── simulate-drag.mjs # 拖动通道(window 监听)行为验证 │ ├── submit-marketplace.ps1 # 市场投稿体检 + 开 PR(见下) │ ├── sync-install.mjs # 逐文件哈希比对并同步安装副本(--apply 才写) │ ├── shoot-screenshots.py # Playwright 拍商店截图,顺带当视觉回归 │ ├── probe-live.py # 真浏览器验收:频谱几何 / 七套主题 / 设置分区 │ └── verify-.mjs # 实网端到端自检(B站 / 平台直链) └── src/ ├── index.js # 宿主半边入口:建票据单例、挂两条路由 ├── shared.js # 跨半边共享常量(PLUGIN_ID / 两条路径 / 槽位 id) ├── host/ # 只在 Node 里跑,浏览器拿不到的能力 │ ├── resolve.js # 平台链接解析(B站 / 网易云 / QQ / YouTube) │ ├── stream.js # 音频流转发 + 平台票据通道 │ ├── mediatoken.js # HMAC 票据签发 / 验签(不透明句柄) └── client/ # 全部内联进 client.js,禁止相对 require ├── register.js # 槽位注册 + React 桥 + 运行时单例 ├── engine.js # 播放引擎(AudioContext 单例、队列、双轨音源) ├── sources.js # Track 工厂(url / embed / bilibili / platform) ├── linkintake.js # 粘贴文本 → 分流 → 入队 ├── library.js # 收藏与历史的存储层 ├── parser.js # 平台链接识别与 id 提取 ├── settings.js # 设置 schema / 默认值 / 归一化 / store ├── locale.js # zh / en 字典 ├── i18n.js # 安全翻译层(宿主 t 的插值语义补丁) ├── styles.js # 全部 CSS(类名统一 dmo- 前缀) └── ui/ # 组件(纯 createElement,无 JSX 语法糖) ├── ShellDock.js # 右下角音乐台(胶囊 / 卡片 / 隐藏三态) ├── Spectrum.jsx # 频谱动画(dots / bars / line / wave / blocks / mirror) ├── Settings.jsx # 设置页分区 ├── LibrarySection.jsx # 设置页里的收藏 / 历史表 └── PlayerPanel.jsx # 右侧栏标签页形态(保留)
截图
| 收起态胶囊 | 展开态卡片 |
| 胶囊 | 卡片 |
| 底部频谱特写 | 设置页 |
| 频谱 | 设置 |
图都列在 screenshots.json 里(商店按它决定展示顺序)。它们由 scripts/shoot-screenshots.py(Playwright)从真实 GUI 拍下,所以同时是视觉回归:改完 CSS 重拍一遍,哪里漂了一眼就能看出来。
脚本不是空跑截图:它会往卡片上真的派发一次 drop 事件,把页面内现场合成的 24 秒 WAV 交给插件,等它自动播放并跑起频谱,再依次拍胶囊、卡片、频谱带、设置页。所以一次跑通本身就是「拖入音频即自动播放」的端到端验证;如果拖放链路坏了,频谱那张会拍成一条平底噪。
两条 HTTP 路由与平台票据通道
| 路径 | 挂法 | 存在理由 |
| /api/dsh-music-on/resolve | connection.fetch.register | 浏览器直连 B站 / 网易云 / QQ 必被同源策略与 Referer 校验拦下,必须宿主代取 |
| /api/dsh-music-on/stream | connection.fetch.register | 让外部音频能进 AudioContext,从而拿到真频谱 |
网易云 / QQ 音乐:为什么不透明票据
这两个平台的 CDN(music.126.net / tc.qq.com)不在 B 站 CDN 白名单里。若让客户端把解析出来的明文直链交给 stream?src=…,那条路由立刻退化成开放代理 —— 任何人都能拿它当跳板。
所以走票据:宿主解析出直链后,用 HMAC 签发一个不透明句柄(/api/dsh-music-on/stream?mt=<token>),客户端永远拿不到真实直链,也无法伪造 URL。票据里只带 {url, mime, platform, id, title, pageUrl, 签发时间, 过期时间}。
- 实现:src/host/mediatoken.js。形态 <base64url(payload)>.<base64url(hmac)>,字符集不含 % —— 硬要求,因为 stream 路由拒绝任何百分号编码。
- 域名白名单分成两张表:ALLOWED_CDN_DOMAINS(B站,供 src= 通道)与 PLATFORM_CDN_DOMAINS(平台,只供 token 通道)。合并它们 = src= 变开放代理。
- verify() 先比签名再解 JSON,用 timingSafeEqual,永不抛错。
- TTL 10 分钟;宿主重启即换随机密钥 → 旧票据全失效 → 客户端重新解析(有意为之)。
- 拿不到直链的曲目(网易云 VIP / QQ 版权限制)仍然入队,带 unavailableReason,队列里标「无直链」、诊断行说明原因,并可跳原平台。绝不伪造一个 url 让它假装能播。
每批上限必须两端相等
MAX_STREAM_TRACKS(宿主,src/host/resolve.js)= MAX_PLATFORM_TRACKS(客户端,src/client/linkintake.js)= 60。
这不是随手取的数,是两边必须对齐的耦合常量:
- 宿主只给前 60 首取直链并签票据。客户端截得比 60 小,等于把已签好的票据白扔(能播的曲目没进队列);截得比 60 大也没意义,多出来的必然 streamOnly。
- 两者由 test/host-route.test.mjs 的「宿主与客户端的每批上限必须相等」一条断言锁死。
网易云歌单必须按 trackIds 补齐
api/v6/playlist/detail 返回的 playlist.tracks 只内联前 10 首,而 trackCount 与 trackIds 是完整的。直接吃 tracks 的后果是:73 首的歌单只解析出 10 首,界面上却显示「解析成功」—— 比报错更难发现。
补齐走 api/song/detail?ids=[...],实测(本机直连):200 首 → 200/612KB/228ms;500 首 → 200/616KB/181ms;1000 首 → code 400(URL 长 11399 超限)。所以按 200 分块。
两个额外约束:
- 字段形状两套。内联曲目是 ar / dt,song/detail 是 artists / duration。只认一套会把补齐来的曲目歌手与时长全部弄丢(neteaseArtists / neteaseDuration 两吃)。
- 补齐失败绝不抛错:分块异常就跳过这一块,拿到几首算几首。
链接识别只有一份实现
src/client/parser.js 是纯函数模块(零 import、零浏览器全局),宿主侧直接 import 它。
这条是踩出来的:宿主曾自带一份 neteaseRouteOf,比客户端的 neteaseRoute 弱,只认 hash 路由和纯 path 段,不认 music.163.com/playlist?id=xxx 这种「path 路由 + search query」写法 → 客户端认得出、宿主返回 null → 用户看到「网易云音乐链接解析失败」。路由解析一旦分叉,两边的单测都会绿,故障只在真实链接上暴露。 现在 test/host-route.test.mjs 用「宿主路由与客户端 parser 必须逐字一致」逐条比对两组结果。
宿主 API 核实清单
写这个插件时逐条核实过的宿主接口(路径 + 导出符号),括号内是「怎么用的」。
1. 槽位服务 ctx.slots
// 声明依赖 const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs']; // keyed 槽位:注册 / 卸载
ctx.effect(
() => ctx.slots.inject(SLOT, () =>
ctx.slots.register({ name: SLOT, key: TAB_ID, locale: PLUGIN_ID }, Component)),
'label',
);
- 定义在 ...\dsh-web-frontend\dist\assets\index-.js(SlotCore 类与 Object.freeze 命名空间 SI)。
- SlotCore 上有 records / factories / mutateListeners / entryErrorListeners,槽域为 singleton|keyed|chain|list。
- dsh-client-ui-slots 不在磁盘上(node_modules 里没有这个目录),它只作为浏览器模块表的种子导出一份冻结命名空间,导出符号:SlotCore / SlotOwnershipError / StaleAuthorizationError / resolveSlotLabel / standardHookPropName。 → 不要在 Node 侧 import '@deepseek-ai/dsh-client-ui-slots',也不要在浏览器侧 value-import 它,只用 ctx.slots。
2. 右侧栏标签页(三处,缺一不可)
| 用途 | 调用 | 证据 |
| 类型表服务 | ctx.sidebarRightTabs.register(definition) | ...\dsh-client-ui-sidebar-right\lib\types\client\index.d.ts 的 declare module '@deepseek-ai/cordis' 段 |
| 槽位常量 | 'sidebar.right.pane.tab'(body)/ 'sidebar.right.pane.tab.title'(标题) | ...\dsh-client-ui-sidebar-right\lib\types\client\contract\slots.d.ts |
| 运行时范例 | ctx.effect(() => ctx.sidebarRightTabs.register({ id, kind, multiple, priority, title, guide }), …) | ...\dsh-client-ui-sidebar-terminal\lib\client.js:438 |
SidebarRightTabDefinition 必填只有 id / kind / title:
{
id: 'dsh-music-on', // 本实现的稳定身份;keyed 槽位的 key 必须等于它 kind: 'music-on', // 类型判别符,openTab('music-on') 用它 multiple: false, // 每种会话一个页面 keepMounted: true, // 隐藏/切会话时不被卸载(播放不能断) priority: 'extension', // 外部扩展优先级最高;另有 'builtin' / 'fallback'
title: () => '音乐台',
guide: [{ id: 'open', order: 40, title: () => '…', description: () => '…' }],
}
打开:
ctx.sidebarRight.openTab('music-on', { params: { source: 'command' } });
priority 是字面量字符串,不是常量导入 —— 宿主就是这么设计的,跨包类型不需要运行时依赖。
3. @deepseek-ai/dsh-client-store
导出 createSnapshotStore / defineStore / notifySubscribers / shallowEqual。
defineStore 返回 { actions, getSnapshot, subscribe, store, clearPersisted },可带 localStorage 持久化 key。
本插件的设置存储(src/client/settings.js)按这个形态实现;浏览器侧不 value-import 该包,只用同形态自实现,避免种子表之外的依赖。
4. @deepseek-ai/dsh-client-ui-primitives
可用组件(节选,全集是约 130 个 Icon + 下列):Button / Checkbox / Switch / Input / Menu / Modal / Pill / Tag / Tooltip / Toast / SegmentedControl / SegmentedTabs / DisclosureRow / HoverCard / MarkdownText / Modal / SettingsForm / SettingsSecretField / PluginArtworkDefault / PluginArtworkLoop / PluginArtworkSearch / PluginArtworkSubagent / PluginArtworkTerminal,以及工具函数 relativeTime / fileSizeText / writeClipboard / useAnchoredPosition / useDismissOnOutsidePointer。
→ 本插件用原生 button + 自写样式,只用它们取图标(可选)。
5. 宿主 HTTP 路由(engine 的活,这里留接口说明)
// 必须走 connection 的共享 /api 前缀,才有浏览器登录态与 Host/Origin 防护
ctx.inject(['connection'], (c) =>
c.effect(() => registerResolveRoute(c), 'dsh-music-on: resolve route'));
// registerResolveRoute 内部:
c.connection.fetch.register({
path: '/api/dsh-music-on/resolve',
methods: ['GET'],
requestBody: 'buffered',
fetch: (request) => serveResolve(request),
});
- 范例:...\dsh-api-session-controller\lib\index.js:2482(/api/file)。
- 段名校验 /^[A-Za-z0-9_$.-]+$/:...\dsh-client-connection\lib\index.js:690(ENDPOINT_SEGMENT_PATTERN)。
- 强制 peer.principal.kind === 'owner',非 owner 一律 403:同文件 :830。
engine(播放引擎)
src/client/engine.js —— 单例,挂在 window.__dsh_music_on_runtime__,由 register.js 的 getRuntime() 惰性创建。
音频图: → MediaElementAudioSourceNode → gain → analyser → destination。
绝不能带 crossorigin 属性。带上以后元素进入 CORS 模式,而流代理若没回 access-control-allow-origin,规范要求 MediaElementAudioSourceNode 静音输出 —— 但 play() 仍然 resolve、进度条照样走,症状是「在播放但没声音、频谱是一条直线」。stream.js 的 baseStreamHeaders() 里那条 access-control-allow-origin: '' 是配套的另一半,两处不可只改一边。
频谱出口三条,语义严格区分:
- getAnalyser(): AnalyserNode | null —— 内部模式返回真节点;外部 iframe 模式必须返回 null,不许返回一个静音 analyser 假装有频谱。
- readSpectrum(Uint8Array): boolean —— 读不到返回 false。
- get spectrumAvailable —— 布尔。
事件订阅(各自返回取消函数):onTrackChange / onStateChange / onTimeUpdate(250ms 频控)/ onQueueChange / onError。
入队时 playFirst 的语义:引擎只在 opts.playFirst === true && current === null 时自动开播(engine.js 的 add)。所以批量入队必须只在首条传 playFirst,否则会反复抢占当前曲目。
parser(链接识别)
src/client/parser.js —— 纯函数,宿主 resolve.js 与客户端 linkintake.js 共用同一套判定。
- PLATFORMS:bilibili-video / bilibili-favlist / netease-playlist / netease-track / qq-playlist / qq-track / youtube-video / youtube-playlist / direct-audio / unknown。
- ALLOWED_DOMAINS:bilibili.com、b23.tv、music.163.com、y.qq.com、youtube.com、youtu.be。判定是 host === domain || host.endsWith('.' + domain),不是子串包含 —— 否则 evilbilibili.com、bilibili.com.evil.tld 都会漏进来。
- ERROR_CODES:E_UNSUPPORTED_URL / E_RESOLVE_TIMEOUT / E_RESOLVE_BLOCKED / E_NO_STREAM / E_DECODE_FAILED / E_BAD_RESPONSE。
- normalizeUrl() 对非法输入是抛错,不是返回 null。调用方一律 try/catch —— 粘一段乱码就炸掉整个入队流程是真实踩过的坑。
spectrum(频谱)
src/client/ui/Spectrum.jsx —— 从 engine.getAnalyser() 拿 AnalyserNode,getByteFrequencyData 取数,不是 OfflineAudioContext。
- fftSize 2048;analyser 上不是这个值时由组件显式设定(只读实现上退化为用现有 bin 数)。
- 对数频段分桶,柱数由设置 spectrumBars 控制(8 对齐,16–160)。
- 宽度由 spectrumSpan 控制(20–100%,按 5 对齐)。外壳把它算成像素注入 --dmo-span,.dmo-barspectrum 用 left: calc((100% - var(--dmo-span)) / 2) 居中 —— left 与 width 必须成对写,同时留 right 会让 width 被忽略、带子永远满宽。
- analyser 是懒绑定的:音频图要到首次播放才建,首次渲染时 getAnalyser() 必然返回 null。组件每 500ms 重试一次(ANALYSER_PROBE_MS),绑上就不再探。早期版本只在挂载时取一次,于是频谱永远是呼吸基线 —— 这就是「频谱消失」的真因。
- 六种绘制,全部有真实实现(不存在「选项里有、代码里没画」):dots(默认,蓝白细柱 + 底噪圆点 + 白色峰值点)/ bars(粗柱)/ blocks(方块阵列)/ mirror(对称声波)/ line(折线)/ wave(时域波形);off 直接 return null,完全不创建 canvas 与动画循环。
- dots 的几何契约:n 根柱 + (n−1) 个缝精确等于容器宽(total === width,误差 < 0.01px),柱宽夹在 2–16px,圆角恒为柱宽的一半。dotLayout() 是纯函数,check-spectrum.mjs 用 7 组宽度锁死这条。
- 峰值每帧回落 -0.6(以 60fps 为基准按实际 dt 缩放),永不低于当前值。
- 帧率由 spectrumFps 控制;prefers-reduced-motion: reduce 时降到 10fps 而不是让频谱消失。
- 双通道暂停:IntersectionObserver + visibilitychange。
- 配色从 CSS 变量读取(getComputedStyle(canvas) 继承):--dmo-spec-base/low/high/glow,定义在 .dmo-dock-stack 上,每套主题各一份。
- 几何自检:node scripts/check-spectrum.mjs(27 项,含 7 组容器宽度下的柱宽/间距/圆角/整排宽度)。
视觉语言与主题
配色令牌全部落在 .dmo-dock-stack 与 .dmo-settings 两个作用域里,不在 :root,避免污染宿主。锚点必须挂在 .dmo-dock-stack 上,因为胶囊是 .dmo-card 的兄弟节点 —— 只挂 card 上继承不到,静默回落兜底色。
七套主题由 :where([data-dmo-theme='…']) 块切换,每套覆盖四组变量:
| 主题 | id | 主色 |
| 鲸鱼蓝白(默认) | whale | #4d6bfe |
| 极光青紫 | aurora | #5c8cff / 柔 #9f7bff |
| 薄荷绿 | mint | #23b98a |
| 落日橙 | sunset | #f5863c |
| 玫瑰粉 | rose | #e85a8c |
| 霓虹赛博 | neon | #00d2be |
| 极简灰白 | mono | #6b7280 |
每组都含 --dmo-rgb(RGB 三元组,供 rgba(var(--dmo-rgb), α) 复用)、--dmo-brand 三档、--dmo-glass 两档、--dmo-spec-base/low/high/glow 四档。--dmo-rgb 是拆出来的:只给一个 --dmo-brand 没法做 --dmo-line / --dmo-halo 这类半透明派生色。
铁律:
- 卡片与设置分区必须自带前景与底色锚点(--dmo-fg / --dmo-glass)。直接用宿主的 --dsw-alias-label-primary 会在浅色主题下变成白字白底 —— 因为宿主的 label 随主题翻转,而我们的玻璃底不翻转。
- 深色反转必须写两块:@media (prefers-color-scheme: dark) 兜底 + :where([data-theme='dark'], …),且后者排在后面靠同权重后出现者胜出。
- 主题作用域必须覆盖到设置分区(data-dmo-theme 同时挂在 .dmo-dock-stack 与 .dmo-settings 上),否则切了主题卡片变色、设置页还是旧的蓝白。
- test/theme-contrast.test.mjs 锁住以上几条(含「卡片子树不得直接消费宿主 label 族变量」)。
走带与音量(长条是进度,音量是旋钮)
卡片里最长的那根横条是音乐进度,不是音量。走带行只剩「上一首 / 播放 / 下一首 + 旋钮 + 循环芯片」,进度条独占它下面一整行(.dmo-progress 里 .dmo-seek + 两个时间戳)。原先进度条挤在走带行里与三个按钮、两个时间戳共处 340px 宽,谁都看不清。
音量是 34px 圆旋钮(.dmo-knob,role="slider" + aria-valuenow),三条操作通道:
- 拖动:130px 竖向行程对应 0→1(向上变响)。拖动途中每帧只写引擎与 ref、不落盘,松手才 commit —— 逐帧 patch 会把设置 store 打成筛子。
- 滚轮:一格 5%。监听必须手挂真实节点并 { passive: false };React 的 onWheel 走 passive 委托,preventDefault() 是空操作,音量与卡片滚动会同时发生。
- 单击 / 键盘:单击是静音开关(记住静音前的值,恢复不回到 0.8);方向键 1 格、PageUp/PageDown 4 格、Home/End 到底。
两条踩过的坑,都有测试锁:
1. 旋钮拖动必须走 window 通道。旋钮直径只有 30px,指针往外甩几像素就离开元素,React 的 onPointerMove 随即不再派发 —— 拖动当场断掉。这与胶囊拖动是同一个坑(见 test/shell-dock-render.test.mjs 的「旋钮拖动必须走 window 通道」)。
2. 绝不能加 setPointerCapture。捕获会把该指针的 compatibility mouse events 目标重定向到捕获元素,合成出的 click 落在容器而不是子按钮上,症状是「能拖了,但所有按钮点了都没反应」。check-install.mjs 有一条断言直接扫 bundle 里不许出现 setPointerCapture。
旋钮的显示值走自己的 knobValue state,而不是 settings.volume:拖动时连续 applyVolume(commit=false) 不落盘,就不会有新的 settings 快照回流,指针会停在按下那一刻而旋钮纹丝不动。
settings(设置)
src/client/settings.js —— 持久化键 STORAGE_KEY = 'dsh-music-on:settings',另有 dsh-music-on:queue.v1(播放列表)、dsh-music-on:favorites.v1、dsh-music-on:history.v1。
通道优先级:localStorage > configForms(ctx) > memory-only。localStorage 排第一是因为它是唯一「刷新后必然还在」的通道;configForms 在后台补写,失败无所谓,localStorage 已经是真相。
normalizeSettings() 对每一项做夹取而不是丢弃:数值按范围夹、8 对齐的按 8 对齐、枚举非法值回落到默认。幂等是硬要求 —— 不幂等会让「读出来 → 归一化 → 写回去」在多轮之后漂移(dockX: null 被 Number(null) === 0 吃成 0,把胶囊钉到左上角,就是这条踩出来的)。
播放列表持久化:绝不能存票据地址
队列落盘在 dsh-music-on:queue.v1,单条只留一个「能重建播放的地址」。平台曲目的 track.url 是 /api/dsh-music-on/stream?mt=…,票据 TTL 只有 10 分钟,宿主重启还换密钥。存它等于存一条必然失效的死链。
所以 serializeTrack() 对平台曲目改存两样东西:sourceUrl(用户粘贴的那个歌单链接)与 pageUrl(单曲页)。恢复时按 sourceUrl 分组,每个歌单只重新解析一次,再按 pageUrl 把存下来的条目对回新解析出的曲目,拿新票据。一条都对不上时明确报「需要重新粘贴歌单链接」—— 静默失败会让用户以为持久化根本没生效。
封面(纯 https 图床外链,不带凭据、不会过期)可以落盘;但绝不序列化 Blob / objectURL(本地拖入的文件刷新后必然是死地址)。
发布到插件市场
dsh-market 不是投稿对象。它的 README 写得很直白:目录只有一个来源——awesome-dsh-plugin 这个精选清单,站点与市场都从它派生(市场每次打开时实时拉 awesome-dsh-plugin.com/plugins.json)。所以只往上游提一个 PR,加一个文件:
data/plugins/lilcandi__dsh-music-on.yml
条目源文件在 docs/marketplace-entry.yml,四件事都必须对上:
- url 与 name 必须逐字指向本仓库(name 是链接文字,写错别的仓库名会被判「label 与链接不符」直接打回)。
- category: ui。不要填 theme —— theme 类会被自动塞进 dsh-market 的主题 Tab(一键安装/切换/卸载),本插件不是皮肤。
- description.en 必填且只能一行;zh 可选但缺失要维护者补。含 : (冒号+空格)的整行必须加引号,否则 YAML 会解析成嵌套映射——这是上游最常见的坏法,校验器会专门提示。
- tarball 用不带版本号的资产名 + latest/download/:.../releases/latest/download/dsh-music-on.tgz。latest 在请求时解析,但文件名是照字面取的,资产名一旦带版本号,下次发版就静默 404。因此每个 release 都必须上传一个名字字面等于 dsh-music-on.tgz 的资产。
硬门槛(CI 按序查,scripts/check-submission.mjs):每个 PR ≤3 条 → 目标仓库 package.json 声明 dsh.bundle(只声明 dsh.client 必失败)→ 仓库创建满 1 天(无提交数门槛)→ awesome-lint 与站点构建。
两次一模一样的 README 不要手工改,它们是生成产物,PR 里动了会撞到别人。
体检与提交:
pwsh -File scripts\submit-marketplace.ps1 # 只体检 pwsh -File scripts\submit-marketplace.ps1 -Submit # 体检通过就开 PR
这个脚本编码了三件踩过的事:
1. 本机 hosts 把所有 GitHub 域名指向 127.0.0.1,所以 curl 必须 --resolve 钉真实 IP;Invoke-RestMethod 走 127.0.0.1:7897 代理。
2. 分支内容用 Git Data API(tree → commit → ref) 直接建,不用 git push。原因是「基于上游最新 main 建分支」必然带上上游改过的 .github/workflows/,而本机 PAT 只有 repo scope,push 会被 refusing to allow a Personal Access Token to create or update workflow 顶回来。走 API 建提交不产生 workflow 变更,权限问题自然不存在。
3. 仓库年龄必须从原始 JSON 字面值取:ConvertFrom-Json 会把 created_at 转成 Kind=Local 的 DateTime,再 ToUniversalTime() 就二次换算、整体偏移 8 小时——真实 3 小时会算成 11 小时,「还差 21 小时」会显示成「还差 13 小时」。脚本用正则取 ISO 串再交给 DateTimeOffset。
自检
cd J:\plugin\dsh-music-on
1) 构建(client.js 是产物,改 src 后必须重建)
node scripts\build-client.mjs
2) 全部单测(注意:node --test 传目录会 MODULE_NOT_FOUND,必须逐个给文件名)别手写文件清单 —— 会漏掉新加的测试文件。用 glob 取: $files = Get-ChildItem test -Filter .test. | ForEach-Object { $_.FullName }
node --test @files
3) 运行期模拟(vm 里真跑 client.js,验槽位注册与首渲染)
node scripts\simulate-browser.mjs
node scripts\simulate-drag.mjs
4) 实网端到端(需要能直连,先清空 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY)
node scripts\verify-bilibili.mjs
node scripts\verify-platform.mjs
5) 同步到安装位置(先看差异,--apply 才写)
node scripts\sync-install.mjs
node scripts\sync-install.mjs --apply
6) 安装位置自检
node scripts\check-install.mjs
7) 频谱几何自检(27 项)
node scripts\check-spectrum.mjs
8) 真浏览器验收(需要 GUI 在 127.0.0.1:3080 起着)
python scripts\probe-live.py
同步到安装位置:file: 依赖在 node_modules 下是实体目录副本(不是 junction/symlink),改源码不会自动生效。用 node scripts\sync-install.mjs 先看差异,加 --apply 才写入。它按 sha256 逐文件比对,并报告安装副本里的多余文件。绝不要用 Copy-Item -Recurse —— 会造出 src\src 这类嵌套重复。改完纯客户端只需 Ctrl+F5;改了 src/host/* 必须重启 DSH。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
lilcandi/dsh-music-on
在界面右下角拖入音频或粘贴歌单链接即可播放
相关插件 · 视觉能力
智能体工作台Devin-AXIS/iPolloWork
企业级、本地优先的智能体工作台,面向个人和智能体团队——一个工作区,容纳多个智能体引擎;一套统一体系,管理插件和技能;支…
★ 7,364已汉化DSH
72
开源设计工作台nexu-io/open-design
OpenDesign:首个协作式 AI 设计智能体工作区
★ 105,709已汉化DSH
71.8
GPT 图像提示词库freestylefly/awesome-gpt-image-2
Prompt as Code | GPT Image 2 / 2.5 提示词与案例库,530+ 个案例、20+…
★ 34,534已汉化DSH
71.5
多智能体编码工作区spacering-net/codeg
多智能体协作的 AI 编程工作区:汇总 Claude Code、Codex、OpenCode、Pi、Grok Build 等来源的会话。支持桌面应用、自托管服务器或 Docker。
★ 4,319已汉化DSH
71.5
AI 用量桌面小部件Javis603/token-monitor
EN | 简 | 繁 | KO | JA | PT
★ 3,533已汉化DSH
71.1
架构图生成器tt-a1i/archify
把你想理解、规划或分享的事,变成可以互动的可视化作品。
★ 93,105已汉化DSH
71.1
这是你的仓库?认领条目