dsh-boot-animation-sound
DSH 开机动画:声音不需要全屏。触发时机(启动应用/页面刷新/新对话/任意会话)与播放频率(每次/每天一次/只播一次/限播 N 次)可自选。
- Stars
- 1
- Language
- JavaScript
- Created
- Oct 6, 2026
- Updated
- Oct 6, 2026
Introduction
dsh-boot-animation-sound
给 DeepSeek Harness(DSH)加一段开机动画:启动时铺满窗口播放一段视频。
和别的开机动画比,这一版只改一件事:声音不需要全屏。
纯 JavaScript,没有构建步骤。
作者:LSY(个人创作) · MIT · 版权归 LSY 所有,见 LICENSE。
为什么需要它
参考社区里几个开机动画,它们的做法是:
浏览器禁止带声音自动播放 → 动画一律静音起播 → 想听声音就点一下画面 → 但那一按同时还会
requestFullscreen(),于是「开声音」和「进全屏」被绑成了同一个动作。
(dsh-boot-animation-pro 的 README 自己就是这么写的:点一下画面即可开启声音并进入全屏。)
本插件把这两件事拆开:
| 动作 | 常见的绑定式做法 | 本插件 |
|---|---|---|
| 起播 | 一律静音 | sound: true 时先试带声音起播 |
| 被系统拦下 | 静音播放,等点击 | 静音播放,等点击(一样) |
| 点一下画面 | 开声 + 进全屏 | 只开声,窗口大小一动不动 |
| 第一次点击/按键 | 必须点在画面上 | 窗口内任意位置的第一次点击/按键就开声 |
| 进全屏 | 开声的副作用 | 只有那个默认关闭的「全屏」按钮才会进 |
代码层面:client.js 里 requestFullscreen() 只出现一次,就在全屏按钮的事件处理函数里;
开声那条路径(unlock)只做两件事:video.muted = false 和 video.play()。
这条约束由验证脚本静态断言,改坏了跑测试就会红。
声音到底能不能自动响
先说规则。这个结论是在真实环境里实测出来的,不是照抄文档:
- 在一个没有任何用户手势的页面里,非静音的
play()会被 Chromium 拒绝(NotAllowedError)。 Electron 的默认自动播放策略本应是no-user-gesture-required,但实测就是拒绝了, 所以本插件按「会被拒绝」来设计,而不是赌它不拒绝。 - 一旦这个页面有过用户手势(哪怕只是之前随便点过一下),非静音播放就会被放行。
于是分三种情况,插件三种都实现了:
- 平台放行 → 直接就有声音,一次都不用点。
- 平台拦下 → 画面照常播,右下角出现一个「🔊 点这里开声(不用全屏)」的小按钮; 同时你在窗口里随便点一下或按一下键盘也会开声。同一个窗口,不进全屏。
- 设置里关掉声音 → 连试都不试,静音播放,也不出现任何开声提示。
想彻底免点击,只能改平台策略本身(例如给 Electron 传
--autoplay-policy=no-user-gesture-required),那不是插件能做的事。 本插件能做的是:不让你用全屏去换声音,并把「要点的那一下」缩到最小——点哪都行。
实测记录
真实窗口里跑过一次之后,插件自己写下的 last-boot.json(原文摘录):
{
"audio": "on", // 有声播放
"muted": false, // 元素没被静音
"audioDecodedBytes": 115835, // 真的解码出 115 KB 音频,声音确实出来了
"fullscreen": false, // 没有全屏
"fullscreenEverRequested": false, // 全程没请求过全屏
"duration": 7.05, "videoWidth": 1280, "videoHeight": 720,
"ua": "… @deepseek-ai/dsh-desktop/0.2.0-rc.2 Chrome/152.0.7977.54 Electron/44.0.0 …"
}
audioDecodedBytes 是关键:它说明音频真的被解码并送进了输出管线,而不是「元素说自己没静音」而已。
触发时机与播放频率
触发时机(什么时刻播)
| 选项 | 含义 |
|---|---|
| 启动应用 | 一次 DSH 运行里只算第一次页面加载 |
| 页面刷新 | 每次页面加载都算(默认;也就是这个插件最早的行为) |
| 新对话 | 点「新建对话」时 |
| 任意会话 | 打开任意对话时,新建的也算 |
「启动应用」与「页面刷新」的区别由宿主判定,不是浏览器猜的:宿主半在每次 DSH 运行时只挂载一次, 它数自己服务过多少次首页渲染——第 1 次就是「启动应用」,之后都是「页面刷新」。 这样「触发时机」和「要不要盖住首帧」用的是同一个计数器,不可能互相矛盾。
后两个是会话触发:它们不在页面加载时播,而是在你新建/打开对话时把动画盖在界面上(Esc 随时可退)。
会话触发没有现成的事件可用——DSH 客户端的事件表里只有
connection/reset、locale/change、slots/changed、theme/change,没有会话事件。所以插件包了一层界面用来导航的那个服务 (uiWorkspace的startSession/openSession/connectWorkspace)。 这层包装写得很保守:先调用原方法并原样返回它的结果,通知失败绝不影响导航;赋值不成功就干脆不包; 插件卸载时逐个还原。宁可这个触发不生效,也不让导航出问题。
播放频率(一共能播几次)
| 选项 | 含义 |
|---|---|
| 每次 | 不限(默认) |
| 每天一次 | 每个自然日最多一次(按本机本地日期,不是 24 小时窗口) |
| 只播一次 | 一辈子最多一次 |
| 限播 N 次 | 最多 N 次,N 可填 1–1000 |
计数存在宿主这边(<DSH_HOME>/dsh-boot-animation-sound/play-state.json),不是浏览器里:
只存浏览器的话,刷新一下就忘了、开第二个窗口就各算各的。所以:
- 播放权由宿主唯一判定并在判定通过时当场扣一次(
POST /claim);被拒绝的请求不扣。 - 只有真的开始播才计数;被拒绝的那些页面加载不算。
- 次数用完后,那些页面加载连首帧遮罩都不会注入——不会先黑屏再放出来。
- 设置页有「重置计数」,否则「只播一次」就是一扇只能改文件才能打开的门。
只在「一次页面加载」时播放
页面加载类的触发还有一道额外的判定:开机动画属于一次页面加载,不属于「插件被加载的那一刻」。 DSH 的 client 模块图是活的——把插件当场启用、或 HMR 把模块塞进一个开着很久的页面,都会加载这个文件。 所以浏览器半会先判断这是不是属于本次加载:
- 宿主在服务端 HTML 里注入的首帧遮罩在
<head>里——它在,就是本次加载(精确信号); - 遮罩关掉时(
coverApplication: false)退回用页面年龄判断:模块在页面开了几分钟后才到,那一定是热加载,不播。
这样正常启动照常播,而不会突然盖住你正在干活的窗口。
安装
profile 目录是 <DSH_HOME>/profiles/<profile 名>(<DSH_HOME> 未设置时默认 ~/.dsh)。
方式一:让 agent 用 plugin_manager 装(桌面版首选)
plugin_manager: action=install_bundle, target=github:2034126171/dsh-boot-animation-sound
方式二:命令行
npm install -g @deepseek-ai/dsh
dsh plugin --profile web add github:2034126171/dsh-boot-animation-sound
方式三:手动
- 在 profile 目录的
package.json里,dependencies加"dsh-boot-animation-sound": "github:2034126171/dsh-boot-animation-sound"; - 把
"dsh-boot-animation-sound"加进同一个文件的dsh.profile.bundles数组; - 在该目录里用 pnpm 执行
install(桌面版自带 pnpm,在<DSH_HOME>/.desktop-bin/pnpm.cmd); - 重启 DSH。
通过 GitHub 安装需要本机装有
git。
开发机上的现状(与使用者无关,仅供本仓库作者参考):源码放在工作区,profile 用两个 junction 指过来(
profiles/node_modules/…与profiles/desktop/node_modules/…;前者给依赖 spec 用,因为它没有空格), 并在dsh.profile.bundles里列了包名;改 profilepackage.json之前已备份为package.json.before-dsh-boot-animation-sound.bak。
使用
设置页在 设置 → 插件 → 开机动画:
| 项 | 说明 |
|---|---|
| 触发时机 | 启动应用 / 页面刷新 / 新对话 / 任意会话 |
| 播放频率 | 每次 / 每天一次 / 只播一次 / 限播 N 次(N 可填 1–1000) |
| 播放影片声音 | 这个插件的主角。默认开。关掉=整段动画静音,也不再出现开声提示 |
| 音量 | 0–100% |
| 首次点击/按键自动开声 | 默认开。关掉后只能点右下角那个开声按钮 |
| 启动时显示动画 | 临时关掉动画,但保留已选路径 |
| 显示全屏按钮 | 默认关。和声音毫无关系:开声永远不会触发它 |
| 退出方式 | 右下角按钮 / 点任意位置 / 自动退出 / 不能退出 |
| 视频文件 | 路径输入框 + 「选择文件…」原生对话框 + 保存 / 清除 |
| 播放账本 | 已播次数 + 上次日期,旁边有「重置计数」 |
「清除」=不再播放,DSH 启动起来和没装这个插件时一模一样。
保命键
Esc 永远能退出动画,任何退出方式下都生效。全屏层吞得掉鼠标点击,吞不掉键盘。
一个铺满屏幕、又关不掉的面板离「软件没法用」只差一个 bug,所以这个键是无条件保留的。
出问题怎么看
每次真实启动的结果都会写进:
<DSH_HOME>/dsh-boot-animation-sound/last-boot.json
设置页底部也会显示一行摘要。关键字段:
| 字段 | 含义 |
|---|---|
audio | on 有声播放 / off 按设置静音 / blocked 被系统拦下(点一下就能开)/ error 播放失败 |
muted | 那一刻元素是否静音 |
audioDecodedBytes | 音频真的被解码出来的字节数。> 0 说明视频有音轨、音频管线真的跑了;0 说明这段视频根本没声音 |
fullscreen | 那一刻是否处于全屏 |
fullscreenEverRequested | 本次启动是否请求过全屏(正常情况下永远是 false) |
userActivation | 当时的用户激活状态 |
排查顺序:先看 audioDecodedBytes 是不是 0(是 0 → 换一个带音轨的视频),
再看 audio 是不是 blocked(是 → 点一下窗口任意位置,或确认「首次点击/按键自动开声」是开的),
最后看 muted 和音量。
配置(可选)
也可以直接改 profile 的 cordis.patch.yml:
- id: dsh-boot-animation-sound
config:
src: D:/videos/boot.mp4 # 留空字符串 = 不播;不写 = 用自带视频
trigger: pageRefresh # appStart / pageRefresh / newConversation / anySession
frequency: every # every / daily / once / times
maxPlays: 3 # 仅 frequency: times 时有效,1–1000
sound: true
volume: 0.9
fit: cover # cover 铺满裁切 / contain 完整留边 / fill 拉伸
skip: button # button / click / auto / never
fadeOutMs: 360
showFullscreenButton: false
设置页里改过的项会盖在 patch 上面(存在 <DSH_HOME>/dsh-boot-animation-sound/settings.json;
播放计数另存 play-state.json)。字段写错只会退回默认值,不会导致启动失败。完整字段见 index.js 里的 DEFAULTS。
验证
npm run verify
252 项检查,全部不依赖 DSH、不依赖浏览器,也不需要安装任何依赖:
verify/check-media.mjs(6 项):media/里的片段是否真的是视频容器、是否带音轨。 自带视频没声音的话这个插件就没意义了,所以这一条是硬检查。verify/host-verify.mjs(168 项):跑宿主半的真实代码——配置归一化、媒体解析、 七个 HTTP 路由(媒体流含Range取字节、设置保存、开机报告落盘、播放权claim与计数reset)、 首帧遮罩注入,以及静态断言:「requestFullscreen()只有一个调用点且在按钮里」、「结束时什么都不画」。 触发与频率单独一节逐条验:四种触发×四种频率的判定矩阵、每天一次按本地日期而不是 24 小时、 被拒绝的请求不扣次数、次数用尽的页面加载不注入遮罩、 以及/claim真的把账记到play-state.json上。verify/client-smoke.mjs(78 项):用一个迷你 React + 假 DOM + 假<video>+ 可切换的自动播放策略(复现真实环境那个NotAllowedError)把浏览器半真跑一遍: 带声起播 → 被拒 → 静音兜底 → 出现开声提示 → 第一次点击开声 → 全程零次requestFullscreen(); 断言页面加载时 claim 的是宿主给的那个 occasion、会话触发在页面加载时一次都不 claim、 被拒绝的 claim 什么都不画;会话包装原样返回导航结果并且能被还原; 热加载进老页面时不播;以及收场回归测试(跳过 / 播完 /Esc都要让出屏幕)。 这个迷你 React 会真的调用上一次的 effect 清理函数——清理函数要是被丢掉, 组件漏掉监听器也能「通过」,那这些测试就没有意义了。
变异验证(一次性做过):把
if (!running) return null换回if (!active) return null,client-smoke.mjs立刻 2 项失败,失败详情里正是那个position: fixed; inset: 0; z-index: 2147483000的盒子和「正在加载视频…」。 测试对着它命名的缺陷会红,才叫回归测试。
已修过的严重缺陷
动画结束后整个界面点不动。 覆盖层是铺满视口的固定定位盒子,而槽位注册表会让组件在整个页面
生命周期里保持挂载;原来的渲染判断只看了「该不该播」,没看「还在不在播」,于是播完之后那层
透明但仍在最上面的盒子继续吃掉每一次点击。修法是把「该不该播」和「现在还在不在播」分开,
并让所有 effect 跟着它走,另加三道独立保险(pointerEvents 随淡出放行、10 分钟硬上限、
Esc 无条件可退出)。完整来龙去脉见 CHANGELOG.md。
素材与权利
media/视频测试.mp4 是示例片头,不在下面的 MIT 授权范围内,其权利归属请自行确认;
要公开分发请先换成你拥有权利的素材(替换该文件即可,不需要改代码)。
verify/check-media.mjs 会检查它带不带音轨——视频必须自带音轨,否则声音那一栏没有任何东西可放。
许可
MIT,覆盖插件代码(见 LICENSE)。示例素材不在该授权范围内,详见 NOTICE。
作者:LSY(个人创作) —— 版权归 LSY 所有。MIT 授权下你可以自由使用、修改、再分发,但请保留 LICENSE 里的版权声明。