dsh-theme-mediascape
媒体景观主题 —— DeepSeek Harness(DSH)Web UI 主题插件:立绘/视频壁纸 × 基底配色 × 开屏动画 × 氛围粒子 × 背景音乐 × 打字音效。
界面预览:theme-studio/ 独立预览环境(真实前后端,见「theme-studio/ 预览环境」)
功能总览
| mht 快照还原预览 | preview.html 直接解析 DSH 界面 .mht 快照还原为静态预览(聊天框+侧边栏+壁纸背景+dock),叠加假数据(操作日志→模拟用户消息、任务列表「预览页加载」自动完成、工作区一个文件夹一个会话)、侧边栏收起按 DSH 真实行为(会话列表 DOM 移除)、操作日志/顶部提示默认隐藏+「日志」开关 | theme-studio/preview.html |
| 宿主元素配色 | 胶囊配方外置 JSON(选择器/背景源/透明度/圆角/padding)+ 层级注入(按 parent 拓扑排序:父先子后、子级自然覆盖父级)+ 品牌区 sidebar-brand 独立键(lPcGpa_brand 背景+文字一体),取色器胶囊 tab 可编辑保存并自动 build | theme-studio/capsules.json |
| 启动播放策略 | 视频壁纸/背景音乐启动时是否自动播放独立配置 | theme-studio/playback.json |
| 开屏动画 | 启动页 gif/图片/视频多画面轮换 + 渐显式无黑屏窗口(浮层立即盖界面,等待期氛围粒子+呼吸光圈、媒体首帧就绪后渐变淡入)+ 可配置标题副标题 + 视频 Range 流式 + HTTP 缓存(ETag+If-Range 重播省 80% 流量);file:"auto" 时开屏即当前视频壁纸(与壁纸层同一份流,播完移交、不重复加载) | boot/boot.json 配置 |
| 字号调节 | 会话区与状态文字四档缩放(小/标准/大/特大),点击「字」循环切换、按钮不显示大小(大小自行感知) | dock「字」按钮 |
| 壁纸系统 | 图片/视频壁纸铺底、上传持久化、本地在线共存。背景氛围粒子 | dock「景」按钮 |
| 上传体系 | 中途不落盘(上传数据先攒内存缓冲,上限=可用内存 20% 动态,超限转 .tmp 保底)、暂停落盘 .part + 继续从 .part 续传(?offset= 追加续写,取消 DELETE /upload/part 清理快照)、上传前「文件名+大小」去重预检(重复的文件在二级面板提示「已跳过」,不发送请求)、dock「传」统一上传入口(按文件后缀天然识别壁纸/音乐,一次多选统一分发)、上传进度二级面板(多行对齐/进度条/大小/速度 EMA/暂停/继续/取消)、上传开始前与结束后各刷新一次列表(内置在公共函数)、上传完释放连接(串行上传不占满浏览器同源 6 连接) | dock「传」按钮 |
| 打字音效 | 输入框打字按键音 + 壁纸音量调节 | dock「声」按钮 |
| 背景音乐 | 播放器(旋转唱片/封面/进度条可拖动)、歌单选择/随机、本机添加 | dock「乐」按钮 |
一、壁纸系统
- 全屏
cover 铺底;中间画面全透明(壁纸区域不铺底色:--dsw-alias-bg-base: transparent、html 深色兜底 #0a0c12、无蒙雾遮罩层;主题色只作用于侧边栏/对话框/按钮等组件)
- 双层结构(图片常驻 + 视频可选覆盖):图片壁纸一直存在(无类型切换按钮);「视频:开/关」开关决定是否加载视频,开则视频覆盖在图片之上显示(图片仍在,仅被完全挡住),关则只显示图片。图片层与视频层各自独立记忆当前壁纸
- 选择:弹出缩略图网格(正方形预览、固定三行、可滚动),点卡片直接应用——按视频开关只显示一层:关=图片列表,开=视频列表
- 模式切换(面板「模式」一行):按钮标题跟随状态——亮=「视频」/ 暗=「图片」(标题实时切换,不再固定显示「视频壁纸」);dock「景」按钮悬停标题也只显示当前层(视频模式→视频名 / 图片模式→图片名),不再并列两个标题
- 移除:勾选后移除 = 真实删除服务器文件(壁纸/音乐统一同一份删除逻辑:开始前/结束后各刷新一次列表,音乐封面随歌曲一并删除)
- 随机:随机模式走面板模式单按钮(循环/顺序/随机单按钮切换;选择器已去掉随机按钮)——进入随机后按当前层轮换,随机间隔自定义分钟(默认 5 分钟);随机/顺序作用于最上层(视频开→视频层内切换,视频播完自动切下一张;关→图片层分钟定时切换);随机池跨层兼容——池在图片模式勾选而当前是视频层(或反之)时自动回退当前层全部,视频播完仍会切换、不会停住
- 切换日志:每次壁纸切换(视频 ended 切下一张 / 图片定时随机 / 手动切壁纸 / 模式切换)自动上报一行到运行态
$DSH_HOME/theme-mediascape/logs/wallpaper.log(GET /theme-mediascape-assets/wallpaper/log?lines=50 可回读最近 N 行,自检/排查切换是否发生用)
- 视频无缝切换预加载(2026-09-29):顺序/随机模式多视频轮换时,当前视频播放剩余 3s(
timeupdate 阈值)即预创建并预缓冲下一视频(隐藏元素、preload=auto、未挂 DOM);ended 切换时若预载目标命中且已就绪(readyState≥2)直接复用该元素(已缓冲数据,播放即出画面)——切换无加载间隙黑屏;未命中/未就绪照常新建不阻塞;关闭视频层/切换不匹配时自动清理预载(不占用连接)。
- 添加壁纸:dock「传」按钮统一入口(按文件后缀天然识别壁纸/音乐,一次多选统一分发)——上传立即生效并服务器磁盘持久化(
$DSH_HOME/theme-mediascape/wallpaper/,刷新/重启/换浏览器都在)——上传视频(mp4/webm,浏览器原生可播)自动开视频层并加载它;上传图片更新图片层(视频开关状态不变)
- 落盘 = 原始文件名(不做 SHA 重命名;同名同大小视为重复复用,同名不同大小自动加 (1)(2)… 后缀);前端直接显示文件名(去扩展名)
- 中途不落盘:上传数据先攒内存缓冲(上限=可用内存 20% 动态,
os.freemem()),超限自动转写 .upload-<traceId>.tmp 保底(超大文件不 OOM);上传过程中素材目录不出现 .part 中间态,end 后正式文件落定(finalizeUpload 按「文件名+大小」去重);上传完成落定后自动清理同名孤儿 .part 快照(上次中断/暂停未续传的残留,不留盘)
- 暂停落盘
.part + 继续从 .part 续传:点「⏸」abort 当前请求 → 服务端把已收内存缓冲落盘为 <正式名>.part 暂停快照(内存即刻释放);点「▶」带 ?offset=<已传字节> 续传 —— 服务端校验 .part 存在且大小一致(不匹配返回 409,前端从头重传,杜绝残缺落盘),对 .part 追加续写,收完 rename 落定,不重传已传部分;「✕」取消 → DELETE /upload/part?name=<文件> 清理快照
- 上传前「文件名+大小」去重预检:重复的文件在二级面板提示「已跳过」(不请求);去重基准/移除目标只认「真实可用」项(列表 stat 失败的文件已从服务端列表剔除,前端
isItemUsable 只认 file/name + size 数字)
- 上传进度:上传时 dock 二级面板自动弹出(
dock.__mediascapeDshCenter 定位靠 dock,点外自动收起)——每任务一行(文件名 + 进度条 + 叠加「已传/总 · 速度」),多任务动态多行且各列对齐,无任务自动隐藏;速度读数 EMA 平滑(progress 事件间隔不均不跳变)
- 开始前/结束后各刷新一次列表(内置在公共上传/移除函数内):上传前刷新保证去重基准最新,结束后刷新保证新文件即时入列表;移除同理(删除前刷新、删除后刷新)
- 上传完释放连接:串行上传(一次一个请求,不占满浏览器同源 6 连接),每个 XHR 完成即释放引用
- 上传不关闭二级面板:
input.click() 打开系统选择器的 click 冒泡不再触发「点外关闭」(捕获阶段 stopPropagation)
- 跨设备即时可见:打开壁纸面板自动刷新列表,其他设备上传的壁纸无需手动刷新网页
- 上传上限:按服务器剩余磁盘空间动态计算(默认单文件最多占剩余空间 80%,底线 512MB),快满自动收紧
- 默认壁纸:首次安装(无历史记录)时展示真实数据目录第一张壁纸(本地/在线均可)
- 面板点右上角「—」或再点「选择」收起;「确定」收起设置面板,设置实时生效并持久化
二、基底配色
- 覆盖 100+ 个
--dsw-* 设计令牌,随主题即时生效;正式基底 = 左侧「应用」写入真源 json 的当前值(以 lib/client-parts/foundation/tokens.js 与取色器为准,文档不写死)
- 单 json 真源 + 预设包(V2 架构,json 化):应用配色只读一个 json——真源
theme-studio/json/theme-colors.json(label/badge + bg 系层级 bg/bgSoft/bgLayer + colors 全部元素色,每个颜色带不透明度 alpha {hex, alpha},是唯一写盘目标);预设包是 theme-studio/json/preset/*.json(只承载颜色集合 + 显示名,不含 bg 系),用于把真源快速改成预设配色;没有预设也能纯手改(左侧输入颜色值)。apply 自始至终只跟真源 json 相关
- 取色器
theme-studio/theme-swatch.html(双 tab:配色 / 胶囊):配色 tab 顶部 = 背景层级区块(bg→bgSoft→bgLayer 缩进展示,改 bg 系 → dock/面板/胶囊 layer 跟随层联动),左侧 = 当前主题元素 class 控件列表(json 数据驱动,每行 6 列:元素名/类键/色号手输/色块/真实元素样子分片/不透明度滑块,滑块先预览第 5 列、点「应用」才写盘),「重置」还原真源,「应用」真正写入真源 json 并自动 build(POST /api/theme-apply { items:[{key,hex,alpha}] },原子写真源 → identity.js/tokens.js → build);右侧 = 预设包(下拉选择 + 逐行显示该预设颜色,「套到左侧全部」全量套 / 每行「应用」= 单项套该预设该键色到左侧,键缺失→无色透明行且不可应用;「存为新预设」POST /api/theme-export;「改名」写回预设 json 的 label,改/导出只作用预设包)。键 = 元素 class:15 键各自唯一(btn-primary/text-primary/gold-glow/status-capsule/sidebar-left/sidebar-brand…),无独立语义键,应用/写回按同名键直取;不透明度滑块读取真源 json 的 alpha 现值(非默认 1),拖滑先预览第 5 列真实元素样子、点「应用」才写盘
- 品牌区单独控制:
sidebar-brand 键驱动左侧边栏品牌区(lPcGpa_brand,CSS Modules 尾缀 _brand/_brandName 稳定不随 hash 变)——背景 + 品牌名文字一体(--dsw-specific-sidebar-brand / --dsw-specific-sidebar-brand-text 令牌);层级注入:sidebar-left(整体,--dsw-specific-sidebar-fill)先注入 → 品牌区后注入,同特异性自然覆盖整体底
- 胶囊层级注入:
capsuleCSS() 渲染时按 parent 关系拓扑排序(深度优先、父先子后)——外层胶囊先注入、内层后注入,同特异性选择器后写覆盖先写(子级自然覆盖父级);同层保持原数组顺序(稳定)。实测注入顺序:chatColumn→flowItem/older、tabStrip→guide→guideEntry
- 胶囊背景「构建期展开字面 rgb」:layer 型胶囊背景不再引用运行期 CSS 变量
var(--mediascape-dsh-theme-bg-layer),改为 build 注入 CAPSULE_BG_LAYER_RGB 常量(值 = theme-colors.json 的 bgLayer.hex 转 rgb,与身份层 --mediascape-dsh-theme-bg-layer 同源),capsuleCSS() 直接拼 rgba(字面, α)。原因:平板息屏过段时间后会话区胶囊全消失、刷新恢复——运行期变量在渲染层重建时未就绪 → rgba(var(...), α) 声明整体 invalid → 背景透明。展开字面后渲染层重建也能画出胶囊,不依赖变量存活(改 bgLayer 后需 build 生效,配色盘 apply 自动 build 同步)
- 宿主元素配色(胶囊 tab):胶囊配方外置
theme-studio/capsules.json(markdown / thinkBody / summary / todoPanel / queueDock / status / chatColumn 七类宿主元素的选择器、背景源、透明度、圆角、padding、额外声明),build.cjs 构建时注入 CAPSULES_DATA、identity.js capsuleCSS() 渲染(与旧写死值逐字一致);取色器「🧩 宿主元素配色」区每类一行解释卡片(数据源 theme-studio/json/theme-capsules.json,含宿主组件与避坑说明 + 该胶囊不透明度 alpha)+ 控件(底色源下拉 layer/颜色集合/solid、RGB 输入、透明度滑杆、圆角/padding 文本、额外声明;颜色集合 = 真源 + 全预设色聚合去重),「保存并 build 生效」→ POST /api/capsules/save(只写固定文件防路径穿越、原子写、自动 build);胶囊参数改后刷新页面生效
chatColumn 胶囊:给聊天消息流主列容器加整列胶囊面板——选择器 [data-conversation-scroll] [class$='_column'](锚点 data-conversation-scroll 为全局属性不随 hash 变、类名尾缀 _column 稳定;hash_column 中的 hash 前缀随构建变化不影响匹配),半透明主题底(layer 源 alpha 0.32)+ 圆角 16px + 内边距 10px 14px,消息流整体呈一块胶囊面板
- 启动播放策略:
theme-studio/playback.json(构建时注入 PLAYBACK_CONFIG)——videoAutoPlay(默认 true)控制视频壁纸挂载后是否自动播放(false 时仅显示首帧,点「视频」按钮才播);musicAutoPlay(默认 false)控制背景音乐启动时是否自动播第一首(默认不自动播,点「乐」/选曲才播;上传/选曲等用户操作后仍即时播放)
设计理念:配色盘可独立于主题单独存在
「键 = 元素 class」重构后,取色器本质是**「元素 → 色值」的键集合**(15 个 --swatch-<元素class> +
元数据键),加上 theme-studio/comps.json(元素清单)、theme-studio/capsules.json(胶囊配方)、
theme-studio/json/preset/(预设包)——这套东西与主题本体仅剩两个接触点:
其余能力(theme-current / theme-presets / theme-export / theme-rename / 胶囊 / 预设 / UI)本就自洽,
只是物理上住在主题仓库的 theme-studio/。若将来要把配色盘独立成单独项目,契约只有一份:
--swatch-<元素class> 键名清单 + 元数据键规则(以 theme-studio/comps.json 为准),两边按此对接即可,
无需改动取色器任何配色逻辑。当前不拆,保留在主题仓库内(单仓库便于一体化 build/自测/预览)。
三、开屏动画
- 启动页动画(运行态
$DSH_HOME/theme-mediascape/boot/boot.json 配置指定文件,读运行态目录——仓库根 boot/ 是分发模板,改配置/换素材直接放运行态目录即生效,无需重新 build)居中淡入,带绿色辉光边框
- 无黑框直接出现(渐显式设计):开屏浮层在页面启动时同步立即插入盖住 DSH 界面(防界面先出、无黑屏窗口)——
浮层背景为完全不透明深色
#03070f(透不出下方界面);等待期呈现氛围粒子(中央呼吸光圈 + 24 粒星光缓缓上升,
均为纯装饰、不阻挡交互);并行取回 boot.json 配置与壁纸清单后建媒体元素,等首帧就绪(video readyState≥2 / img loaded)
后浮层加 .media-ready(星尘/光圈 0.5s 淡出——「星光聚成画面」)且媒体经 transition 0.6s 渐变淡入(opacity 0→1、scale 0.95→1);
无素材 / 配置失败 / 媒体超时 → 浮层保持深色背景展示 durationMs 后自动淡出(界面呈现,无黑框先行)。
- 媒体首帧就绪判定(修复 2026-09-26):视频就绪判定(
waitBootMediaReady)三处加固——①就绪超时后最后复查真实 readyState(反代/主实例慢路径下视频首帧约 2s、可能晚于原 2s 超时,视频实际已就绪(readyState 4、4K 播放)不再误判为超时黑屏);②补 loadeddata 监听(防 canplay 事件漏捕竞态);③就绪超时放宽至 5s(BOOT_MEDIA_READY_MS)。修复前症状:实机路径视频流正常读取播放但画面黑(ready 类未加、opacity 0)后淡出,预览直连路径不受影响。
点画面跳过 / 自动结束前:媒体的结束态尺寸 = 壁纸背景同规格(absolute 全屏 + object-fit: cover,与 .mediascape-dsh-bg video 一致),
配合全屏渐变闪光(accent 光 → 深色)再整体淡出——开屏与壁纸交界视觉无缝。
不再 build 注入预置首图(GIF_DATA 恒 null),开屏内容完全按运行时 boot.json 的 file/files 决定(改配置刷新即生效,无需 build)
- 支持 gif/图片/视频与多画面:
.gif/.png/.webp 用 <img>,.mp4 用 <video muted loop autoplay>(无声音);files 数组按每段 durationMs 顺序轮换(video 复用元素 load()+play() 保持流式),播完整个集合自动淡出,loop:true 则循环整个集合直到点画面跳过(2026-09-23 删「点击跳过」按钮,点画面即跳);默认 file:"auto" 联动当前启用的视频壁纸做开屏(同一份流式数据移交,不重复加载)
- 视频流式加载(Range/206):开屏视频经静态服务 Range 分片流式播放(
lib/index.js + theme-studio/start-preview.mjs 的 serveStream,仅 media 且带 Range 才 206 分片、非法 Range 416、其余 200 全量),不再整段下载
- 视频 HTTP 缓存(ETag + If-Range,已实现):媒体响应带 HTTP 缓存头,播放过的视频分片由浏览器缓存、重播零网络请求——
- 206 分片响应返回
Cache-Control: public, max-age=86400 + ETag: "<mtime>-<size>"(文件校验指纹);重播同一 URL 时浏览器发 If-Range: <etag> 条件请求
- 服务端校验:ETag 匹配 → 继续 206 分片(浏览器直接用缓存分片,不重新传输);ETag 不匹配(文件被替换/上传新版本)→ 回 200 全量,缓存自动失效、绝不给旧内容
- 生效点:切回看过的壁纸、开屏重播、多画面轮换回到已播片段——均不再重新下载(
file:"auto" 开屏=壁纸同一份流本就一次请求,缓存进一步覆盖「跨会话重播」)
- 实现位置:
lib/index.js 与 theme-studio/start-preview.mjs 的 serveStream 同步(两处保持一致,与 Range 分片同一函数);非媒体(js/css/json)保持 no-cache 即时生效不受影响
- 自动验证:
node test/e2e/video-cache-compare.mjs(零依赖,临时 2MB 视频 + 无缓存/有缓存双服务对比)——实测 5 轮播放:无缓存 10MB、有缓存 2MB(省 80%);重播 4 次零请求零传输;文件被替换后 If-Range 不匹配回 200 全量拿新内容(ETag 自动失效,绝不给旧缓存)
file: "auto" 联动视频壁纸(开屏=当前壁纸,同一份流):boot.json 的 file 填 auto 时,开屏自动取当前启用的视频壁纸做开屏动画——
开屏素材与壁纸素材合一,全程只发一次 Range 请求(开屏与壁纸层共用同一 video 元素、同一个流式数据);
开屏播完淡出时把该 video 元素移交给壁纸层继续播放(按 id 匹配接管、保留 src 不重载),无缝过渡、无第二次加载。
机制:boot 解析阶段挂 loading 占位 → 壁纸层在 auto 解析完成前不自建视频 → 解析出目标 id 后同 id 则维持等待、不同 id 则恢复正常自建 → 开屏 finish 时挂出 window.__mediascapeDshBootVideo 移交对象 → 壁纸层 renderLayers() 按 id 接管同一元素。
- 壁纸视频关闭时关闭守卫(2026-09-29):视频开关(
LS_BG_VIDEO)非 "1"(视频壁纸未开/已关)时,file:"auto" 不再兜底首个视频——auto 解析返回空、开屏守卫关闭、无视频按 durationMs 正常淡出(不卡等待),避免「开屏播视频但壁纸层不消费移交」的错配。
- 配置(运行态
boot/boot.json):
{
"files": ["demo-6s.mp4"],
"file": "auto",
"title": "",
"sub": "",
"durationMs": 3000,
"loop": false
}
- 标题/副标题可配置:
boot.json 的 title/sub(空字符串=不显示,默认只显示媒体本身);files 优先于旧 file 字段,file:"auto" 再优先于 files 与 file(联动视频壁纸时不参与集合轮换,单画面=当前壁纸)
- 每次刷新播放,单画面/每段时长取配置
durationMs(默认 3000;2026-09-23 起代码读取钳制最小 3000——配置可写更低但不生效,防闪屏);点击任意处或点画面可跳过(auto 视频首帧未就绪前不可跳,避免壁纸丢失)
- 尊重系统
prefers-reduced-motion,自动跳过
已实现
- 开屏
file: "auto" 联动视频壁纸:boot.json 的 file 填 auto 时,开屏自动取当前启用的视频壁纸做开屏动画(走既有 Range 流式通道),开屏播完把同一 video 元素移交给壁纸层继续消费(同一份流式数据、同一元素,不重复加载)——开屏素材与壁纸素材合一,全程仅一次 Range 请求;详情见本章「file: "auto" 联动视频壁纸」条目。
四、氛围粒子与字号调节
- 氛围粒子(嵌在「景」面板内一行):单循环按钮——点击循环 关→一档→二档→三档,按钮显示当前档位、关档暗其余档亮;数量切换带 0.9s 淡入淡出过渡;每粒星光独立尺寸、亮度、漂浮轨迹与呼吸式闪烁
- 字号调节(dock「字」按钮):点击循环切换四档——小 0.85 / 标准 / 大 1.15 / 特大 1.3(按钮文字保持「字」,不显示大小,高亮暗=标准档、亮=非标准档;大小由会话区文字自行感知);直接写宿主
--dsh-content-font-size 变量,正文随档位缩放,localStorage 记忆、重启保持
五、背景音乐
-
「乐」按钮:亮 = 播放中,暗 = 暂停;点击按状态机联动播放与二级面板:
-
旋转唱片 + 封面(同名封面图或 music/music.json 指定;无封面自动读 MP3/FLAC 内嵌封面)+ 进度条拖动跳转
-
上一首 / 播放暂停 / 下一首 / 循环模式(单曲循环 → 列表循环 → 随机播放)
-
选择:弹出歌单,勾选后移除(内置隐藏、导入删除)或随机(以勾选歌曲为随机池)
-
+ 添加歌曲:dock「传」统一入口(按文件后缀识别音乐,一次多选)——服务器磁盘持久化($DSH_HOME/theme-mediascape/music/ + music.json 清单,刷新/重启/换浏览器都在),无封面自动读内嵌封面;封面可为当前歌曲指定,落盘同名封面图并写入 music.json(封面保留原设计:自动按音乐名重命名)
-
当前曲目与循环模式持久化
六、打字音效
- 聊天输入框打字时播放「咔哒 + 低频咚」的星光质感按键音
- Web Audio 实时合成,零音频文件;空格/回车音色更低;「声」按钮开关;另有壁纸音量调节
七、界面操作(dock 工具条)
四个按钮收在可拖动的半透明竖条面板(右下角,圆角;无毛玻璃,纯深色底):
按钮顺序(上→下):景 乐 声 字 传(2026-09-23 定稿,apply.js 启动段统一重排)。
🖱️ 按住面板任意位置拖动即可移动,位置自动记忆(重启 dsh web 后仍生效)。
面板/选择器弹层相对 dock 垂直对齐并自动保持在屏幕内。
按 ESC:关闭壁纸/歌单等浮层;音乐面板展开时收起。
如何新增 dock 按钮 / 二级面板
dock 是 toolbar/dock.js 创建的固定容器(startDock()),各按钮是独立 start* 函数在 lib/client.js 启动段按序挂载(const stopX = startX(dock);),返回 dispose 供卸载。新增一个按钮 + 二级面板照此模式:
// lib/client-parts/<模块>/<文件>.js
function startMyBtn(dock) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mediascape-dsh-mybtn mediascape-dsh-dock-btn";
btn.textContent = "钮"; // 单字按钮文字
btn.title = "我的功能";
dock.appendChild(btn); // dock 参数传入,直接 append
const panel = document.createElement("div");
panel.className = "mediascape-dsh-my-panel"; // CSS 对齐 bg-panel(absolute + .open 显示)
dock.appendChild(panel);
// 点击 toggle 面板 + 用 dock 的定位函数让面板贴 dock 侧边弹出(左右自动判断、视口内钳制)
btn.addEventListener("click", (e) => {
e.stopPropagation(); // 防冒泡触发外层「点外关闭」
panel.classList.toggle("open");
if (panel.classList.contains("open")) dock.__mediascapeDshCenter(panel);
});
// 点外自动收起(对齐 onBgDocClick 模式;排除自身按钮)
const onDocClick = (e) => {
if (panel.contains(e.target) || e.target === btn) return;
panel.classList.remove("open");
};
document.addEventListener("click", onDocClick);
return () => { // dispose:清理监听 + 移除元素
document.removeEventListener("click", onDocClick);
btn.remove();
panel.remove();
};
}
要点:
- 挂载:在
lib/client.js 启动段调用 const stopMyBtn = startMyBtn(dock);,并把 stopMyBtn() 加进整体清理函数(return 的 dispose 列表)
- CSS:按钮用
mediascape-dsh-dock-btn(dock 内统一圆形按钮样式),面板样式对齐 mediascape-dsh-bg-panel(position:absolute; z-index; display:none; flex-direction:column; .open{display:flex})
- 定位:面板位置不要自己写 fixed/left/top——调用
dock.__mediascapeDshCenter(panel) 由 dock 统一处理(dock 偏左弹右侧、偏右弹左侧、贴底上移钳制),窗口 resize 时 dock 会 repositionPopups() 自动重摆
- 构建:
lib/client-parts/ 下新建文件后,把文件加入 build.cjs 的 PART_ORDER(按依赖顺序);跑 node build.cjs 拼回 lib/client.js
- 按钮高亮:
.on 类由各按钮自己维护(如上传按钮上传中 btn.classList.toggle("on", 有活动行)),CSS 在 scenes/identity.js 统一定义
八、在线资源与自定义素材
{
"sources": {
"<sha1-40位hex>": {
"name": "示例视频.mp4",
"url": "https://example.com/video.mp4",
"kind": "wallpaper"
},
"<sha1-40位hex>": {
"name": "示例歌曲.mp3",
"url": "https://example.com/song.mp3",
"kind": "music"
}
}
}
-
hash:下载内容 SHA-1(40 位 hex),仅作完整性校验(下载后校验一致才落定),不做磁盘文件名
-
kind 分流:wallpaper → wallpaper/online/;music → music/
-
落盘 = 原始文件名(name 字段即落盘名,与上传/内置素材一致);已存在同名文件即跳过(幂等)
-
断点续传:中断保留 <name>.part,下次启动从断点继续(Range 请求),校验通过才重命名落定
-
本地/在线共存:同一列表展示,同内容以本地上传优先
-
以后加资源只改配置、不改代码
-
目录复制(插件启动把仓库自带素材目录复制一份到真实数据目录,仓库根保留不删源):
{ "dirs": { "music": "music", "wallpaper": "wallpaper", "boot": "boot" } }
整目录递归复制、已存在文件跳过、只复制一次(目标已存在非空即跳过);以后加目录只改 json 的 dirs
- 开屏素材:
boot/boot.json 配置 files(多画面数组,优先)/ file(单素材,兼容)/ title / sub / durationMs(每段时长)/ loop(循环整个集合),改配置刷新即生效(运行时 fetch,无需 build)
媒体加载与鉴权代理(视频/开屏加载不出来时看这里)
现象
首次打开时,视频壁纸或开屏动画长时间黑屏、甚至一直不出画面;刷新或第二次打开又恢复正常。
为什么视频加载不了
当 DSH 前面部署了鉴权代理(要求 ?token= 的访问链接)时,代理会把没有携带 token 的媒体请求重定向(302)。
浏览器的媒体元素(<video>)本来用 Range 请求分段拉流(206),但跟随 302 重定向会丢失 Range 语义——
请求退化成「整文件下载」:上百 MB 的壁纸视频要全部下完才有首帧,表现就是卡住 / 加载不出来。
之所以只在首次出现:第一次访问会完成一次 token 认证并建立会话 cookie,之后的请求都带 cookie、不再被重定向,
所以「只有第一次慢」。
解决
- 服务端捕获 token:DSH 启动时会打印带 token 的访问地址;插件在启动阶段包装
process.stdout.write,
在打印的瞬间抓取 token(只读取输出分片、原样透传,不改变既有日志行为);若插件加载晚于该打印,
回读 dsh-proxy.log 兜底(日志路径按 DSH_PROXY_LOG / DSH_HOME 推导,不硬编码)。
- 下发给前端:
GET /theme-mediascape-assets/media-token 返回当前 token(取不到返回空串)。
- 媒体 URL 拼 token:开屏图片/视频、壁纸视频、壁纸图片背景、预载元素、音乐封面——
所有媒体赋值点统一经
withMediaToken,Range 请求即被代理直接放行,恢复 206 分段流式,首帧不再等待整文件下载。
- 晚到修正:token 就绪晚于媒体元素创建时,修正已挂载元素的
src(仅在确实需要时重设,避免无谓重新加载)。
边界
没有鉴权代理的环境(本地直连、预览服务)行为不变:取不到 token 时媒体 URL 原样使用,不影响加载。
九、HTTP API 一览
运行时素材/上传 API 由插件挂载在 DSH 主实例 /theme-mediascape-assets(lib/index.js);预览服务(theme-studio/start-preview.mjs,默认端口见 start.sh)对同一前缀本地实现同源 API(不依赖主实例),配色盘 API 为预览服务专属。
9.1 素材与上传(运行时 + 预览同源)
9.2 配色盘(theme-swatch.html,预览服务专属)
9.3 页面与静态资源(预览服务)
待办(TODO)
- 配色盘功能基本完善,页面颜色区需要重新设计层级排布(2026-09-23 记录):颜色区当前为平铺元素行列表;后续按宿主元素层级重新排布(右侧边栏→品牌区/文件树/指南页,会话区→正文/胶囊/状态提示,等),层级关系与胶囊区 parent 树对齐。
目录结构
完整文件树见 docs/目录结构.md(git-push doc-tree 自动维护)
真实数据目录(插件启动时由 dirs 配置驱动复制,仓库根保留;运行态文件统一放运行态目录——素材、配置、日志、pid 等):
$DSH_HOME/theme-mediascape/:wallpaper/(上传落盘原始文件名 + online/ 在线子目录)、music/(音乐文件原始文件名 + 封面 + music.json 清单)、boot/(开屏动画:boot.json 配置 + 素材文件,改配置/换素材直接放这里即生效)、logs/(运行日志:壁纸切换日志 wallpaper.log)
安装与要求
要求:DeepSeek Harness(dsh)+ Node.js;纯客户端主题,无额外依赖。
支持 DSH 版本:>=0.1.6-0 <0.3.0-0(0.1.6 开发验证 + 0.2.0-rc.2 实测可用;防 0.3.0 破坏性变更)——package.json peerDependencies 声明 @deepseek-ai/dsh。
方式一:从 GitHub 源安装(公开仓库,匿名可装)
⚠️ dsh plugin --profile <名> add … 当前实测会挂死且零输出(两次都以 timeout 结束、日志为空)⇒
安装改在该 profile 目录里直跑 pnpm(dsh plugin 的设计就是把参数转发给 profile 的 pnpm,两者本应等价):
cd <DSH_HOME>/profiles/<profile>
pnpm add "github:EIGHTfs/dsh-theme-mediascape#<commit>" # 用 commit 固定版本(远端暂无 tag)
两条前置条件(缺一即失败;本仓库有 prepack 构建脚本,两条都需要):
-
本机要有 npm —— pnpm 构建 git 来源的包时会在临时目录执行 npm install;
缺 npm 时报 ERR_PNPM_PREPARE_PACKAGE + spawn ENOENT: npm: command not found。
-
pnpm 11 默认禁止 git 源依赖执行构建脚本(供应链策略)⇒ 先在 profile 的
pnpm-workspace.yaml 里放行,否则报 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED:
allowBuilds:
dsh-theme-mediascape@https://codeload.github.com/EIGHTfs/dsh-theme-mediascape/tar.gz/<commit>: true
⚠️ key 里带 commit 哈希 ⇒ 换版本/换提交要同步改这一行。
装完重启 dsh web 生效。本仓库是 monorepo(主题在 packages/<主题包目录>),需要指定子目录时用
github:EIGHTfs/dsh-theme-mediascape#path:/packages/<主题包目录>(换行内联写法见下文「配色盘/主题分开装」一节)。
方式二:打包安装(本地 tgz)
cd <DSH_HOME>/profiles/<profile>
pnpm add ./dsh-theme-mediascape{tag}.tgz
# 装完重启 dsh web 生效
卸载
cd <DSH_HOME>/profiles/<profile>
pnpm remove dsh-theme-mediascape
# 重启 dsh web 生效
⚠️ 与其它主题互斥:多个主题都会调用 ctx.theme.setTheme 并注入 !important 令牌样式,同时只能启用一个主题(多主题时 patch 行注释掉即可,可随时恢复)。
健壮性自测
针对「手动移动/删除素材文件」场景的自动化回归测试:验证服务端在文件与清单不一致时
不崩溃、列表正确、清单自动同步、孤儿记录被清洗。测试使用独立数据目录(/tmp/ms-*-test-home),
不污染真实 $DSH_HOME/theme-mediascape/。
node theme-studio/tests/ms-split-behavior-check.mjs # 路由/上传/删除/白名单基础链路
node theme-studio/tests/ms-file-move-robustness.mjs # 移动壁纸/音乐/封面文件健壮性(12 项断言)
- 移动壁纸文件(json 未同步)→ 列表不崩、不含被移项
- json 孤儿记录 + 磁盘缺失 → 再次上传同内容重新落盘,不误判「重复复用不存在文件」
- 移动音乐文件 → 列表不崩、
music.json 自动同步移除
- 静态 GET 已移动文件 → 404 不崩
- 封面判定与「填空」一致:json 记录的 cover 文件不存在 → 自动找同名图片 → 再无则无封面;
music.json 自动清洗孤儿 cover
- 移回文件 → 列表恢复
debug 模式(配置分层:仓库默认 + 运行态覆盖;多开关:配色服务 / 预览页 / 日志)
配置分两层(2026-10-09 起):默认值随仓库下发(插件根 debug.default.json,入库、随版本升级),
运行态只需在数据目录写想覆盖的键($DSH_HOME/theme-mediascape/debug.json,$DSH_HOME 缺省 ~/.dsh):
// $DSH_HOME/theme-mediascape/debug.json —— 只写要覆盖的键即可,其余回落 debug.default.json
{
"log": { "wallpaper": true, "api": false }, // 按类别开关(也可写 true=全开 / false=全关)
"logging": {
"mediaDetail": true, // 切换级全字段:预载就绪 / 是否复用预载 / 出画间隔 / 是否带 token 等(false=只留事件主干)
"maxBytes": 1048576, // 单文件轮转阈值(字节)
"keep": 3 // 轮转保留份数(.1/.2/.3)
},
"theme-swatch": true, // 配色服务(仓库默认 false,开发用)
"preview": true // 预览页(仓库默认 false,开发用)
}
-
任意新键自动透传:debug.json 里加任意未知键(如 "performance": true)无需改代码,readDebugConfig() 自动带出、isDebug('performance') 可直接判断——以后加 debug 开关只改配置文件
-
日志统一入口:所有运行态日志(含 build 期胶囊诊断 build-capsules.log)都经 lib/debug.js 的 writeLog(name, entry) 单点写入,受 log 类别开关控制——关闭时任何日志零落盘,无绕过开关的散落直写(lib/log.js 为 re-export 兼容层)
-
轮转可配:logging.maxBytes / logging.keep 控制单文件阈值与保留份数(缺失/非法回落 1MB / 3 份,与旧行为一致)
-
任一开关开 → 每次 node build.cjs 自动执行 theme-studio/tests/ 下的自测脚本(任一失败即标红)
-
log 开启 → 运行态 $DSH_HOME/theme-mediascape/logs/ 按类生成日志文件,每条带日期时间(YYYY-MM-DD HH:mm:ss.SSS)+ 自动轮转:
wallpaper.log:壁纸切换链路(前端上报)。除 switch / video-ended 外,2026-10-09 起增加三个排查用事件:
mount(