dsh-pv-home
将自定义 PV 视频设为循环播放的主页背景 适合想要个性化主页背景的用户,支持视频全屏循环播放、音量控制及标题栏配色跟随。
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:SOH4C4759/dsh-pv-home说明文档
阅读完整 README ↗出处:Bilibili BV1xCai6aE9g(上传者 西西弗斯的风车)。请按上方 NOTICE 的条款使用。
yt-dlp -f "bv*+ba/b" -o "assets/video/film.mp4" "https://www.bilibili.com/video/BV1xCai6aE9g/"
放好之后**不用改代码、不用重新构建**。清单与 URL 里的版本号按 `mtime-字节数` 生成,同名覆盖会得到新 URL,不会被旧缓存糊弄。影片缺失时插件有兜底:主页照常挂载、注入照常发生,只是没有影片可放,控制台与清单里会说明原因(`verify-injection.mjs` 有一条断言盯着这条兜底路径)。
换成你自己拍的片子也一样。真片子请守这几条:
| 项 | 建议 | 原因 |
|---|---|---|
| 容器 | **`moov` 必须在文件最前** | 否则浏览器要整段下载完才出画面,主页会先黑一会儿。原片已实测 moov 在偏移 32;`ffmpeg -movflags +faststart` 可以补齐 |
| 轨道 | 带音轨 | 静音起播,点按钮出声 |
| 画幅 | 16:9 | 默认 `object-fit: contain` 会完整呈现,比例不符时留黑边而不是裁画面;想铺满裁切在设置里切 |
| 时长 | 任意(会循环) | 主页常驻,按 Esc 随时走 |
设置卡片会自己探「浏览器播不了 / 未优化」并给你原因。
## 装法
**路一:免重启(当前已用)** —— 建一个 `node_modules` 目录链接 + 往 profile 的 `cordis.patch.yml` 追加一行 insert。
```powershell
sh tools/install-live.sh # macOS / Linux
powershell -File tools\install-live.ps1 # Windows
跑完刷新浏览器即可。
路二:正式依赖(推荐最终状态) —— 路一不写依赖声明,profile 里以后任何一次 pnpm install(例如用 dsh plugin add 装别的插件)都可能把这个未声明的链接剪掉,而 patch 行还在,那时 DSH 会因为「行解析不到包」起不来。
dsh plugin --profile desktop add link:F:/CodeProj/dsh-pv-home
装完完全退出并重开 DSH。撤销用 remove,或把 patch 里那行 insert 删掉。
⚠️
link:安装时,包内需要一个node_modules/@deepseek-ai/schemastery。entry.js是静态导入它(Config必须是模块导出对象的属性),而 ESM 解析不认NODE_PATH,只从导入文件所在目录逐级往上找node_modules。link:装法下本包的真实路径在 profile 之外,上一级没有node_modules—— 少了包内那份,整个模块求值就失败,路由和主页一起没了。两种装法的区别,按你的情况挑:
link:装法(本机当前用法):自己放一份,或者从内核那支复制过来。必须是内核在用的 3.18.4+(.volatile()存在于该版本)。仓库没有提交这份副本(.gitignore里排除了node_modules/),因为提交包管理器目录是仓库腐烂的开始。- 装进 profile 的
node_modules(普通的npm install):profile 自己那层就有@deepseek-ai/schemastery,解析得到,包内不需要副本。
tools/verify-asset.mjs有一条断言盯着这件事;tools/verify-all.mjs在解析不到它时会跳过需要它的两套并打印原因,而不是假装通过。
连带的一个后果:如果把本包打包成 tarball 分发,
package.json的files不含node_modules,那份副本会丢。link:安装不受影响(整目录链接);真要打包分发,得先解决这个依赖怎么随包走。
右上角那三个窗口按钮
它们不是这个页面画的:桌面壳用的是 titleBarStyle: 'hidden' + titleBarOverlay,颜色属于 Electron 主进程。但页面握着一条官方接缝 —— 壳的 preload 一直在测量页面自己的 --dsw-specific-sidebar-fill 与 --dsw-alias-label-primary,经 dsh-desktop:windows-appearance 把结果送给主进程,主进程校验后调 setTitleBarOverlay。所以主页播放期间,把这两个变量写在 document.body 上(内联样式在那个子树里赢过任何样式表规则),右上角就变成影片的深色;返回时删掉,应用配色立刻回来。顺带还免费触发了那个 MutationObserver —— 因为这两次写入本身就是 body 的 style。
它做不到的:改字形。那三个符号由系统绘制,只有颜色和高度是可寻址的;换形状必须动 Electron 主进程,那不是插件该做的事。所以这个功能是「让它们融进画面」,不是「换一套按钮」。
「与 dsh-boot-animation 的关系」见下一节;主页以外的地方它们本来就跟主题一致,不需要动。
画面适配:为什么默认不铺满
原来写的是 object-fit: cover(铺满),这是个错的默认值。cover 会按比例放大到填满窗口,超出部分直接裁掉,而裁掉多少取决于窗口比例差多少 —— 不是一点点。
实测:一个 1280×820 的窗口(比例 1.561)放 16:9 的片子,要填满高度就得横向放大到 1458px,于是左右各裁掉 89px。片子里左边那个 dsh 聊天窗口、右边一半的 TUI 世界,就这么没了。窗口越扁,裁得越狠。
现在默认 object-fit: contain:整幅画面完整呈现,代价是上下各 50px 黑边 —— 而这片子本身的底色就接近纯黑,黑边基本看不出来。想铺满裁切的话,设置里能切回去。
("把视频本身裁成窗口的比例"是另一条路,但那等于永久丢掉画面内容,和"完整呈现"正好相反,所以没做。)
锁屏时也播放它
先说清楚 Windows 的限制:锁屏界面只接受静态图(或图片幻灯片),不支持视频,第三方程序也没有任何办法在安全桌面上画东西。真正会「播放」的只有屏保 —— 所以这一半做成了屏保:空闲触发、全屏循环播放这支 PV,配合 ScreenSaverIsSecure=1(恢复时要求登录)实际效果就是「回来一看,刚才在放它」。
从侧栏那颗锁屏按钮点进来(dsh-lock-button 插件):那边默认就是「先播放 PV」,做法是带 /lock 启动这个屏保 —— 放完片、一动鼠标,屏保退出时自己调 LockWorkStation() 落到登录界面。所以那条链路是:按钮 → 屏保放 PV → 退出时锁屏,而不是「按钮 → 立刻锁屏」。
screensaver/src/PvScreensaver.cs WPF 屏保源码(MediaElement + 每显示器一个窗口)
screensaver/build.ps1 用系统自带的 csc.exe 编译,并跑两项探针
screensaver/install.ps1 复制到 %LOCALAPPDATA%\dsh-pv-home + 设置注册表 + 立即生效
screensaver/uninstall.ps1 还原安装前的四项注册表值并删文件
powershell -ExecutionPolicy Bypass -File screensaver\build.ps1 # 编译 + 自检
powershell -ExecutionPolicy Bypass -File screensaver\install.ps1 # 安装(默认 10 分钟、恢复需登录)
cmd /c "%LOCALAPPDATA%\dsh-pv-home\PvScreensaver.scr" /s # 立刻看一次
install.ps1 的参数:-TimeoutMinutes 5、-Secure 0(恢复不要密码)、-Muted 0(开声音)、-Volume 0.4、-Screens primary(只在一台显示器上放)、-Video 。它只写 HKCU\Control Panel\Desktop 下的四项(SCRNSAVE.EXE / ScreenSaveActive / ScreenSaveTimeOut / ScreenSaverIsSecure),先把旧值备份成 JSON,再用 SystemParametersInfo 推进当前会话,所以不用注销。
默认静音,以及那个「双重播放」
默认静音(muted=1)。屏保是在没人在场时触发的,第一版会自己唱起来 —— 那是我做错了,不是你的设置问题。
更要紧的是它背后那个结构性 bug,也是「双重播放」的来源:这台机器有两块显示器,而 /s 给每块屏幕各建一个窗口、各有一个 MediaElement。同一条音轨被解码两遍,相差几毫秒叠加在一起 —— 听感上不是回声,是一条发闷、加倍的音轨。所以现在加了音频归属规则:
无论开几个窗口,只有一个窗口能出声(主显示器那个)。其余的一律
IsMuted,且永远不会被取消静音。
「双重播放」如果是说两块屏都看到画面,那是 screens=all 的预期行为(声音仍然只有一路)。想只在一块屏上放:
powershell -ExecutionPolicy Bypass -File screensaver\install.ps1 -Screens primary
多窗口之间还会各自漂移(每个窗口独立解码),所以主窗口每 20 秒会被当作时钟对一次,偏差超过 400 ms 才 seek —— 阈值不是容差:seek 一次有肉眼可见的卡顿,比这点偏差更难看。
三个实测细节留在这里,因为它们都是「不踩一次不会知道」:
.scr在注册表里的关联是"%1" /S:用 ShellExecute 启动(Start-Process、双击)会强行追加/S,你传的参数会被丢掉。所以构建脚本用cmd /c(CreateProcess)。反过来,这也意味着双击这个 .scr 就是运行屏保,正是想要的行为。- 没有屏保能配的参数:
/c只弹一个说明框,告诉你改哪个 ini。片名、静音、音量、放哪几块屏都在pv-screensaver.ini里。 - 多显示器:每台显示器一个窗口、各自循环,但只有一路声音。
/geom探针会报告窗口落点和音频归属数(本机实测windows=2 scale=1 audioWindows=0(静音)/audioWindows=1(有声),两块 2560×1440,DIP 落点0,0与2560,0)。 - DSH 主页如果还开着,屏保启动时它不会自己停 —— 屏保已静音,所以你只会听到主页那一路。想彻底安静就先按 Esc 离开主页。
/lock参数:给屏保加这个参数,它退出时会调LockWorkStation()。空闲触发的路径不需要它(ScreenSaverIsSecure=1已经保证恢复时要登录),只有"手动启动"才需要 —— 否则退出屏保就等于把桌面还给你,那不叫锁屏。参数位置无关,/geom /lock这种调用也认。- 抢焦点:被别的进程启动的屏保不会自动成为前台窗口,所以它
Loaded时会调SetForegroundWindow—— 否则第一次按键会打到它背后的窗口上,什么都不发生(鼠标移动仍然有效,因为鼠标消息跟着指针走 —— 正是那种"半能用"最值得提前修掉)。
与 dsh-boot-animation 的关系
本机 desktop profile 里原本装着 dsh-boot-animation(启动动画:播一段片子再溶解进界面)。它和「主页」是两回事:启动动画会先播 8–15 秒再交接,如果两个都开着,你会先看一段启动动画、再看到这个主页。
因此安装时把它那一行改成了 enabled: false(它自己的开关,不是禁用插件)。想恢复:把 profiles/desktop/cordis.patch.yml 里
- id: boot-animation
name: dsh-boot-animation
config:
fadeMs: 2000
加上 enabled: true 即可。两者共存是能跑的,只是会连播两段影片。
已经验证到哪一步(2026-10-11,desktop profile)
在真实运行进程里验证过的(http://127.0.0.1:19387,插件路由不需要令牌):
| 事实 | 怎么验的 |
|---|---|
patch 行解析到了包、模块求值成功、Config 被投影 | cordis_inspect_query 的 Config 条目:patchId: pv-home、status: schema、packageDir 指向 profile 的 node_modules,9 个字段都带 x-cordis.volatile: true |
| 路由注册进了真实的 web server | GET /plugins/dsh-pv-home/film.json → 200,cache-control: no-store,faststart: true,字节数与磁盘一致 |
| 配置读取走的是真实的 volatile 引用 | 同一份清单里的 settings 就是 readField() 读出来的当前值 |
| Range 语义正确 | bytes=0-63 → 206 / content-range: bytes 0-63/40111552;bytes=-64 → 206 后缀;越界 → 416 带 bytes */40111552 |
| HEAD 与不可缓存 | 200 + content-length,no-store |
本包 client.js 没被自己的路由截胡 | 该请求回的是框架的 404(无 content-type),不是本插件的 JSON 404 |
| 字体路由 | 200,98232 字节,font/ttf |
| 影片在本机真的能解码 | 屏保的 /selftest 走 Media Foundation 打开同一个文件:result=ok width=1920 height=1080 seconds=211 audio=True |
| 屏保的多显示器几何与音频归属 | /geom:windows=2 scale=1,两块 2560×1440,DIP 落点 0,0 / 2560,0;muted=1 → audioWindows=0、muted=0 → audioWindows=1(两个方向都断言过,这就是「双重播放」的回归测试) |
/lock 参数真的被解析 | /geom 探针:不带参数 lockOnExit=False,带 /lock(放在第三个位置)lockOnExit=True |
| 画面不再被裁 | 桩 DOM:默认样式表里是 object-fit:contain,显式给 cover 时才变成 cover;实机量到 1280×820 窗口下 cover 会左右各裁 89px |
| 屏保已装成当前用户的屏保 | 注册表四项:SCRNSAVE.EXE 指向安装副本、ScreenSaveActive=1、ScreenSaveTimeOut=600、ScreenSaverIsSecure=1;旧值已备份成 JSON |
没有端到端验证的那一步,说清楚:把两行 head 注入真的拼进服务端返回的那个 index HTML,我没抓到——启动令牌是进程内生成的(BrowserAuth 的 launchToken 不落盘),拿不到就没有令牌去换 cookie,首页只会回 401。这一步的依据是读源码(renderIndexInjections 把 placement:'head' 的行拼在 head 最前)+ 桌面宿主确实调用了 collectIndexInjections() + 同机制的 dsh-boot-animation 在本机有既成事实。
另外,「在真浏览器里看起来怎么样」不在任何自动化覆盖里:桩 DOM 能证明「Esc 会移除节点」,证明不了「面板在真主题下好不好看」;/selftest 能证明 Media Foundation 解得开这个文件,证明不了窗口铺满屏幕好不好看。视觉与听感只能靠刷新页面看一眼,屏保只能靠跑一次 /s。
目录
entry.js Host 半侧:影片/字体路由 + head 注入 + Config
src/home-screen.js 注入进 的主页脚本(无框架,必须在 shell 之前跑)
src/client.js 浏览器半侧:「设置 → 插件」里的卡片
lib/client.js 构建产物(window.__ModuleLoader__.load 闭包工厂)
build-client.mjs 产物构建:仓库外的包拿不到共享 preset,自己复现那五行外壳
cordis.patch.yml bundle 层:一条 `- insert: id: pv-home`
assets/video/film.mp4 影片 —— **不随仓库发布**,自己放一份(见「换片」)
assets/video/film.source.json 影片出处与抓取记录
assets/fonts/SpaceMono-Bold.ttf + OFL_spacemono.txt 面板标题用的字体与其许可
node_modules/@deepseek-ai/schemastery 内核那支 3.18.4 的副本(静态导入必需)
tools/stub-dom.mjs 桩 DOM:让注入脚本真跑起来
tools/verify-*.mjs 六套离线自检
tools/install-live.{ps1,sh} / uninstall-live.{ps1,sh} 免重启接线与撤销
screensaver/ 屏保:C# 源码 + 编译/安装/卸载脚本(见上一节)
离线自检
node build-client.mjs # 改了 src/client.js 之后重新出产物
node tools/verify-all.mjs # 跑全部六套
| 检查 | 覆盖的契约 |
|---|---|
verify-docs.mjs | 文档与磁盘不许漂移:files[] 每项在盘上、且含文档让人去跑的 tools/screensaver、没有 private: true、exports 每项存在;README 目录清单里每个路径都存在(node_modules/ 与影片条目改断言「被 .gitignore 排除而非提交」,而不是断言它存在);verify-all 声明的套件集合与盘上一致;AGENTS.md 的规则编号从 1 连续无洞;README/MANUAL/NOTICE/AGENTS/package.json 里不许出现本机绝对路径、用户名或凭据 |
verify-injection.mjs | 恰好两行 head 注入且配置行在脚本行之前、总开关关掉时一行都不注入、影片缺失时不注入但留告警、路由形态(清单/字体是 exact,影片是 /clip 前缀,没有任何前缀压在 ROUTE 上)、volatile 引用与普通值两种配置形态都能读 |
verify-routes.mjs | 挂真 HTTP 服务器打真请求:清单、整片逐字节、Range 206(首段/中段/后缀)、416、HEAD、404、目录穿越、伪造扩展名、本包 client.js 不被自己的路由截胡、字体逐字节 |
verify-home-screen.mjs | 把注入脚本加载进桩 DOM 推真事件:挂在 documentElement 而非 body、重复执行不会挂出第二个覆盖层、清理恢复点且只动那一个键、存储不可用时不炸、Esc 从第一帧起有效(含播放被拒、解码失败两种情形)、音量按钮的点击/滚轮/键盘三条路径与记住的选择、电平表按音量取整 |
verify-client-bundle.mjs | 产物外壳契约、只向加载器要 react、不许出现 ctx.get((必须先剥注释)、summary 视图回字符串、卡片真渲染并驱动开关断言写出的 op 与 revision、被拒时走远端复核并显示原因、四种残缺上下文都不抛 |
verify-asset.mjs | 影片盒子链 ftyp→moov→…→mdat、moov 在文件前 1 MiB、H.264+AAC 双轨、从 mvhd 读出的片长、字体是 TrueType 且随附 OFL、出处记录与署名链完整、包内 schemastery 在位 |
它证明不了的那一步必须说清楚:这些都不包含「在真浏览器里看起来怎么样」。桩 DOM 能证明「Esc 会移除节点」,证明不了「面板在真主题下好不好看」,也证明不了影片在 Chromium 里真的解码出画面。视觉与听感只能靠你看一眼 —— 这是本插件唯一没有自动化覆盖的边界。
诊断:地址加 ?dshpvdiag=1,左上角会显示 session=cleared dur=211.9 muted=true vol=1 state=showing 这样的实时读数,控制台另有一份配置与清理结果。