← Back to home@2034126171

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,但实测就是拒绝了, 所以本插件按「会被拒绝」来设计,而不是赌它不拒绝。
  • 一旦这个页面有过用户手势(哪怕只是之前随便点过一下),非静音播放就会被放行。

于是分三种情况,插件三种都实现了:

  1. 平台放行 → 直接就有声音,一次都不用点。
  2. 平台拦下 → 画面照常播,右下角出现一个「🔊 点这里开声(不用全屏)」的小按钮; 同时你在窗口里随便点一下或按一下键盘也会开声。同一个窗口,不进全屏。
  3. 设置里关掉声音 → 连试都不试,静音播放,也不出现任何开声提示。

想彻底免点击,只能改平台策略本身(例如给 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

方式三:手动

  1. 在 profile 目录的 package.json 里,dependencies 加 "dsh-boot-animation-sound": "github:2034126171/dsh-boot-animation-sound";
  2. 把 "dsh-boot-animation-sound" 加进同一个文件的 dsh.profile.bundles 数组;
  3. 在该目录里用 pnpm 执行 install(桌面版自带 pnpm,在 <DSH_HOME>/.desktop-bin/pnpm.cmd);
  4. 重启 DSH。

通过 GitHub 安装需要本机装有 git。

开发机上的现状(与使用者无关,仅供本仓库作者参考):源码放在工作区,profile 用两个 junction 指过来(profiles/node_modules/… 与 profiles/desktop/node_modules/…;前者给依赖 spec 用,因为它没有空格), 并在 dsh.profile.bundles 里列了包名;改 profile package.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

设置页底部也会显示一行摘要。关键字段:

字段含义
audioon 有声播放 / 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 里的版权声明。