afawfawffaf/dsh-plugin-lx-music ↗★ 0
dsh-plugin-lx-music
落雪音乐(LX Music)内嵌歌词 + 播放控制:歌词内嵌到 DSH 右侧壁纸带(当前行 + 前后各两句),歌词正上方的胶囊放上一首/播放暂停/下一首;输入框按钮临时收起/展开歌词,设置页可关掉改回落雪桌面歌词窗。不接管播放。 仅限Windows,适合使用落雪音乐且想在DSH界面边聊边看歌词的用户。
Install
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:afawfawffaf/dsh-plugin-lx-musicREADME
Read the full README ↗dsh-plugin-lx-music(落雪音乐 · 内嵌歌词 + 播放控制)
github.com/afawfawffaf/dsh-plugin-lx-music · MIT · 仅 Windows
把落雪音乐(LX Music)正在播放的歌词,内嵌到 DSH 对话区右侧的壁纸带里:当前行 + 前后各两句, 默认常显。歌词正上方贴着一颗胶囊,放着「上一首 / 播放暂停 / 下一首」。输入框右侧的「歌词」按钮 用来临时收起/展开歌词(收起时只剩胶囊,位置不动),设置页里可以关掉内嵌、改回落雪的桌面歌词窗。
不接管播放、不打断播放、不弹播放条 —— 歌和声音始终由落雪自己负责;本插件只读它开放的 API
(127.0.0.1:23330)做三件事:读当前歌词、发 /play /pause /skip-next /skip-prev,
外加一点窗口显隐操作。
装(任选其一):
dsh plugin add afawfawffaf/dsh-plugin-lx-music
桌面端也可以在 DSH 的「插件 → 添加插件」里粘贴仓库地址
https://github.com/afawfawffaf/dsh-plugin-lx-music(桌面 profile 由 Electron 管理,
CLI 的 --profile desktop 会被拒,这是 DSH 的既定行为,不是本插件的问题)。
为什么有这个东西
桌面歌词是落雪自己的置顶透明浮窗,浮在所有窗口之上,写代码时挡视线、抢位置。 内嵌版不占桌面、不置顶、不抢焦点,只活在 DSH 的右侧空带里(实测视口 1707 宽时右侧有 401px)。 内嵌开着时,落雪的桌面歌词窗会被自动藏起来,两边不会重复。
设计文档(含 v1 被推翻的完整证据、窗口枚举、asar 里的 isExistWindow 代码)见
docs/superpowers/specs/2026-10-17-lx-music-button-design.md。
结构
lib/index.js 宿主半边(cordis 插件,inject ['webServer']),代理落雪 API + 桌面歌词窗显隐 + 播放控制
lib/lx-api.js 落雪开放 API 客户端:LRC 解析、行号二分、±2 句切片、700ms 缓存、播放控制、错误降级
lib/lx-music.ps1 Windows 窗口操作:找 exe / 启进程 / 枚举窗口 / show|hide|toggle|state
lib/client.js 浏览器半边,手写 __ModuleLoader__ factory 包,无构建步骤
cordis.patch.yml 让 profile 的 cordis.yml 层插入本插件
test/lrc.mjs 离线:LRC 解析 / 行号 / 切片 / 缓存 / 换歌 / 播放控制 / 降级
test/control-route.mjs 离线:起假落雪 API,真 HTTP 打 /lx-music/control(不碰真播放器)
test/host-routes.mjs 离线:假 ctx + 真 HTTP,含真的把桌面歌词藏起来再放回去
test/client-bundle.mjs 离线:桩 __ModuleLoader__ 执行 bundle,验 4 个座位与组件形状
四个座位
| 座位 | id | 干什么 |
|---|---|---|
conversation.input.right | lx-music | 输入框右侧的「歌词」按钮:临时收起/展开内嵌歌词 |
shell.overlay | lx-music-stage | 右侧壁纸带的一条竖列:控制胶囊在上、歌词在下(当前行 + 前后各两句) |
shell.overlay | lx-music-toast | 失败提示卡(成功一律不弹) |
settings.section | lx-music | 设置页:内嵌开关 + 开放 API / 桌面歌词窗状态 |
舞台和控制条是同一个 overlay(LxMusicStage):胶囊贴在歌词正上方,两者共用同一条右边界,
歌词收起时只剩胶囊。它们共用同一个轮询器(player 总线,500ms,按签名去重),所以落雪那边每轮只被问一次;
控制条点的瞬间先本地翻转图标(乐观更新),真状态回来后自动对齐。
路由
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /lx-music/lyric | 一帧歌词:{ ok, api, song, play, lyric:{ index, total, line, slots[5] } } |
| POST | /lx-music/control | body { "action": "prev" | "next" | "toggle", "desired"?: "playing" | "paused" },走落雪 API 放上一首/下一首/播放暂停;播放器没开时先把播放器拉起来再重放这次点击 |
| POST | /lx-music/lyrics | body { "visible": true|false },设置桌面歌词窗显隐(幂等) |
| GET | /lx-music/lyrics | 只读状态:{ ok, installed, running, exe, pid, windows, visible, title } |
| POST | /lx-music/lyrics/toggle | 兼容旧页面:翻转桌面歌词窗 |
| GET | /lx-music/debug | 诊断:本插件在宿主里的 loader 条目、客户端图、bundle 路径 |
| GET/POST | /lx-music/client-report | 浏览器半边把「我跑到哪一步」记在这里 |
成功 200;NOT_FOUND 404;NO_LYRIC_WINDOW / LAUNCH_FAILED / SCRIPT_FAILED 409;
落雪 API 掉线/超时 503;坏 body 400;方法不对 405。失败体统一带 code + hint。
落雪开放 API(v2.7.0+,默认端口 23330,需要在落雪里手动打开)
GET /status?filter=status,name,singer,albumName,duration,progress,lyricLineTextGET /lyric—— 整首原始 LRC 文本(含[ti:][ar:][offset:]与[mm:ss.xxx]时间标签)- 控制:
GET /play/pause/skip-next/skip-prev(toggle先读状态再决定打哪个)
页面永远只跟同源的 /lx-music/* 说话,不跨域直连落雪:避开 CORS,也不把 23330 暴露给页面。
播放器没开时,点一下播放键就自己拉起来
点播放/上一首/下一首时,POST /lx-music/control 先照常打落雪 API;如果连不上(API_OFFLINE),
宿主会做三件事:
- 跑
lx-music.ps1 -Mode launch:找到lx-music-desktop.exe就带-hidden启动 —— 窗口不弹出来,直接以系统托盘常驻(托盘图标若本来是关的,落雪会自己把它打开,所以随时能点回来),已经在跑则只回报already-running; - 每 250ms 探一次开放 API,最多等 15 秒(实测启动到 API 可用 歌词
当第三参传进去,它就变成了 key,children永远是 undefined,于是按钮只剩 padding 加边框。修法:children放进props`。修好后:
{"stage":"button-rect","rect":{"x":958,"y":999,"w":65,"h":28},"kids":[{"tag":"span","text":"歌词","w":44}]}
配套的两条经验:
- 手写
__ModuleLoader__bundle 时,children只能放在 props 里;test/client-bundle.mjs里 「按钮带了子节点」「按钮上写着『歌词』」两条回归断言,正是为了拦住这种静默失败。 conversation.input.right位于一个display:flex的行里,flex 项默认会被压缩。按钮要flex: 0 0 auto+min-width: max-content,否则内容会被挤没。
舞台几何自检(stage-rect)
舞台(控制胶囊 + 歌词)挂载后会上报一次实测矩形,用来在看不到屏幕时证明布局对:
{"stage":"stage-rect","controls":{"x":1532,"y":435,"w":107,"h":39},
"lyrics":{"x":1405,"y":488,"w":234,"h":143},"viewport":{"w":1707,"h":1066}}
读法:controls.y + controls.h(474)比 lyrics.y(488)小 → 胶囊在歌词上面;
controls.x + controls.w 与 lyrics.x + lyrics.w 都是 1639 → 共用同一条右边界;
整块 435…631 的中点是 533,正好是视口高度的一半 → 垂直居中。
换行动效自检(stage-shift / lyric-style)
动效是「代码对、页面没跑」的重灾区(keyframes 没注入、key 没换、translateY(-50%) 不参与过渡),
所以这里也留了两个一次性自报,隔一层浏览器自己说出来:
{"stage":"stage-shift","motion":true,"shift":-112.6}
{"stage":"lyric-style","index":56,"block":172,"rows":[
{"animation":"none","opacity":0.5,"minHeight":"auto","height":17},
{"animation":"lx-lyric-far","opacity":0.14,"minHeight":"auto","height":20},
{"animation":"lx-lyric-near","opacity":0.3,"minHeight":"auto","height":21},
{"animation":"lx-lyric-in","opacity":0.35,"minHeight":"58px","height":58},
{"animation":"lx-lyric-near","opacity":0.3,"minHeight":"auto","height":21},
{"animation":"lx-lyric-far","opacity":0.14,"minHeight":"auto","height":20}]}
读法:
stage-shift.motion=true且shift是负的像素值 → 新 bundle 确实在页面里求值了, 居中走的是「量高度 → 写像素位移」这条路,不是translateY(-50%);lyric-style.rows里animation不是none→ 三张关键帧都真的被浏览器认下来了 (getComputedStyle(node).animationName读的是生效值,不是源码里的字符串);- 当前句
minHeight: 58px且height: 58px(20px × 1.45 × 2)→ 定高两行生效; - 各行的
opacity报的是动画第一帧的值(.35 / .3 / .14),不是 0 —— 整块不会随换行暗一下; block在多次换行之间不变(本机实测一直是 172)→ 当前句换行不会带动整块重排。