afawfawffaf/dsh-plugin-lx-music ↗★ 0

dsh-plugin-lx-music

落雪音乐(LX Music)内嵌歌词 + 播放控制:歌词内嵌到 DSH 右侧壁纸带(当前行 + 前后各两句),歌词正上方的胶囊放上一首/播放暂停/下一首;输入框按钮临时收起/展开歌词,设置页可关掉改回落雪桌面歌词窗。不接管播放。 仅限Windows,适合使用落雪音乐且想在DSH界面边聊边看歌词的用户。

パッケージ
dsh-plugin-lx-music
互換性
未検証
Harness ピア範囲
>=0.1.5-rc.1
Cordis ピア範囲
^4.0.1
バージョン
0.4.0
ライセンス
MIT
最終更新
2026/10/18

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:afawfawffaf/dsh-plugin-lx-music

ドキュメント

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.rightlx-music输入框右侧的「歌词」按钮:临时收起/展开内嵌歌词
shell.overlaylx-music-stage右侧壁纸带的一条竖列:控制胶囊在上、歌词在下(当前行 + 前后各两句)
shell.overlaylx-music-toast失败提示卡(成功一律不弹)
settings.sectionlx-music设置页:内嵌开关 + 开放 API / 桌面歌词窗状态

舞台和控制条是同一个 overlay(LxMusicStage):胶囊贴在歌词正上方,两者共用同一条右边界, 歌词收起时只剩胶囊。它们共用同一个轮询器(player 总线,500ms,按签名去重),所以落雪那边每轮只被问一次; 控制条点的瞬间先本地翻转图标(乐观更新),真状态回来后自动对齐。

路由

方法路径说明
GET/lx-music/lyric一帧歌词:{ ok, api, song, play, lyric:{ index, total, line, slots[5] } }
POST/lx-music/controlbody { "action": "prev" | "next" | "toggle", "desired"?: "playing" | "paused" },走落雪 API 放上一首/下一首/播放暂停;播放器没开时先把播放器拉起来再重放这次点击
POST/lx-music/lyricsbody { "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,lyricLineText
  • GET /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), 宿主会做三件事:

  1. 跑 lx-music.ps1 -Mode launch:找到 lx-music-desktop.exe 就带 -hidden 启动 —— 窗口不弹出来,直接以系统托盘常驻(托盘图标若本来是关的,落雪会自己把它打开,所以随时能点回来),已经在跑则只回报 already-running;
  2. 每 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)→ 当前句换行不会带动整块重排。