dsh-plugin-startup-animation 1.12.7 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
  `webServer.register` 提供素材与管理路由。**运行时不依赖任何 npm 包**,浏览器半边只从宿主的
29
29
  模块表 `require('react')`,装好即用、无需构建。
30
30
 
31
- 开箱自带 **7 段视频 / 7 张壁纸 / 9 段语音**,也支持上传你自己的 MP4 / WebM / GIF /
31
+ 开箱自带 **8 段视频 / 7 张壁纸 / 9 段语音**,也支持上传你自己的 MP4 / WebM / GIF /
32
32
  PNG / JPG / MP3。素材在设置页里点卡片即用,展示时长可以在 **3–30 秒**之间调;声音有两个
33
33
  彼此独立的开关——视频自己的音轨,以及单独的开机音频。
34
34
 
@@ -48,7 +48,7 @@ PNG / JPG / MP3。素材在设置页里点卡片即用,展示时长可以在 *
48
48
 
49
49
  **视频素材库** —— 卡片点选即用,来源直接标在卡片上(内置 / 上传),当前在用的那张标「使用中」:
50
50
 
51
- <img src="docs/library.jpg" width="520" height="261" alt="视频素材库">
51
+ <img src="docs/library-videos.jpg" width="520" height="262" alt="视频素材库">
52
52
 
53
53
  **壁纸素材库** —— 同样点卡片即生效,来源标记同上:
54
54
 
@@ -132,7 +132,7 @@ dsh plugin --profile <你的 profile> add /该仓库的绝对路径
132
132
  | 开机音频 | 动画期间另播一段音频文件。 |
133
133
  | 展示时长 | 3–30 秒,视频与壁纸共用。**跳过不受它限制**。 |
134
134
  | 启动画面素材 | 当前素材预览,以及「恢复默认」(回到内置动画)。 |
135
- | MP4 / 壁纸 / 音频 / 更新公告 四个页签 | 前三个是素材分类:点卡片即选用、上传即入库。**更新公告**是只读页,显示最新版 Release 的更新说明。 |
135
+ | MP4 / 壁纸 / 音频 / 宠物 / 更新公告 五个页签 | 前三个是素材分类:点卡片即选用、上传即入库。**宠物**是桌面宠物的开关、大小与动作预览。**更新公告**是只读页,显示最新版 Release 的更新说明。 |
136
136
  | 卸载 / 更新 | 装在当前 profile 里时显示。**卸载插件**会把它移除(设置与上传素材保留在磁盘);有新版时版本号变成一个更新按钮。两者都要重启 DSH 才生效。从源码目录 `link:` 进来的安装不给更新按钮——那条依赖不指向任何版本。 |
137
137
 
138
138
  没选素材时,插件会播放**自己的内置动画**(配色随应用深浅色走的旋转徽标)。
@@ -169,6 +169,23 @@ dsh plugin --profile <你的 profile> add https://github.com/WWFZS/dsh-plugin-st
169
169
  你的配置(`$DSH_HOME/startup-animation.json`)和上传素材
170
170
  (`$DSH_HOME/startup-animation-media/`)都在包**外面**,升级不会动它们。
171
171
 
172
+ ### 桌面宠物
173
+
174
+ 设置页的 **宠物** 页签会让一只透明小动物浮在界面右下角(不影响任何操作,它就是浮在
175
+ 页面上的一层)。
176
+
177
+ - **动作轮播**:按动作列表从第一个到最后一个依次演(待机、点击回应、害羞捂脸、
178
+ 拖拽悬空、原地踏步、吃橘子、挥手打招呼、原地小憩、泡温泉),每个动作演完一遍
179
+ **定格 3 秒**再演下一个,到表尾绕回开头;加新动作会自动排进轮播
180
+ - **右键菜单**:列出所有动作供你点播(演哪个点哪个,演完从它的下一个继续轮播)、
181
+ 归位、隐藏
182
+ - **左键点一下**:插播「点击回应」,播完继续轮播
183
+ - **拖动**换位置(拎起来会演「拖拽悬空」),松手记住位置、继续轮播
184
+ - **大小** 100–320 px
185
+ - **动作列表**在设置页里:点一格就点播那个动作,正在演的会高亮;
186
+ **加动作只要往目录丢文件**(文件名就是动作名,不用改代码、不用重启,刷新页面即见)
187
+ - 素材已经去掉绿幕和脚下的地面投影,所以它是**悬浮**在界面上的
188
+
172
189
  ### 配置文件
173
190
 
174
191
  配置存在 `$DSH_HOME/startup-animation.json`(一般是 `~/.dsh/startup-animation.json`),
@@ -204,10 +221,13 @@ dsh plugin --profile <你的 profile> add https://github.com/WWFZS/dsh-plugin-st
204
221
 
205
222
  以下全部随包分发,在列表里直接可选:
206
223
 
207
- - **MP4** —— 7 段,全部为 H.264 faststart(可直接流式播放,每段 1.6–6.2 MB)。
208
- `dsh-大肥鱼`、`剑一`、`薰衣草花语-等你爱我` 带语音音轨(打开「视频带声播放」即可听到),另外四段无音轨
224
+ - **MP4** —— 8 段,全部为 H.264 faststart(可直接流式播放,每段 1.6–6.2 MB)。
225
+ `dsh-大肥鱼`、`剑一`、`薰衣草花语-等你爱我`、`少司缘` 带语音音轨(打开「视频带声播放」即可听到),另外四段无音轨
209
226
  - **壁纸** —— 7 张,附带预生成的 480px 缩略图供列表使用
210
227
  - **音频** —— 9 段语音
228
+ - **宠物** —— 9 段透明动画(待机 / 点击回应 / 害羞捂脸 / 拖拽悬空 / 原地踏步 /
229
+ 吃橘子 / 挥手打招呼 / 原地小憩 / 泡温泉,VP9-alpha webm)。
230
+ 它们**不进**素材列表,只由宠物浮层取用。
211
231
 
212
232
  > 所有素材均来自网络,感谢各位 UP 主的优秀作品。仅作演示用途,版权归原作者所有;
213
233
  > 商业再分发前请先看 [ASSETS.md](ASSETS.md) 确认每个文件的来源。
package/client.js CHANGED
@@ -3,9 +3,13 @@
3
3
  *
4
4
  * **一个** settings.section 条目(侧栏只显示「启动动画」),页面自上而下:
5
5
  * 1. 设置区(常驻):总开关、声音、展示时长(共用主配置,3–30 秒)、当前素材预览 + 恢复默认;
6
- * 2. 分类切换器 [MP4 | 壁纸 | 音频 | 更新公告];
6
+ * 2. 分类切换器 [MP4 | 壁纸 | 音频 | 宠物 | 更新公告];
7
7
  * 3. 分类区:上传(按文件类型自动归入本分类)+ 该分类素材**卡片网格**(点卡片即用,悬停出删除)。
8
8
  * 「更新公告」不是素材分类:它渲染 `/update` 顺手带回的 Release 正文(parseReleaseNotes)。
9
+ * 「宠物」也不是素材分类:它是宠物开关 / 大小 / 动作预览,真身是页面右下角那只浮层。
10
+ *
11
+ * 宠物浮层(createPetOverlay)在 apply 阶段挂到 document.body 上,与设置页渲染树无关:
12
+ * 关掉设置页它也还在,靠 window 事件(dsa:pet-sync / -play / -reset)和设置页通信。
9
13
  *
10
14
  * 素材**只在** MP4 / 壁纸卡片里选用:上传只入库不生效,点卡片才写入配置。
11
15
  * 删除语义:内置素材 = 从列表隐藏(包内文件保留);上传素材 = 真删文件。
@@ -84,6 +88,19 @@ window.__ModuleLoader__.load({
84
88
  updateChecking: '检查更新中…',
85
89
  updateLatest: '已是最新',
86
90
  updateUnknown: '检查不到更新',
91
+ // ── 宠物(第三个页签;浮层与设置区的文案)──
92
+ catPet: '宠物',
93
+ petEnable: '显示桌面宠物',
94
+ petNote: '宠物浮在界面右下角:拖动换位置,松手会记住;点一下它有反应。',
95
+ petSize: '宠物大小',
96
+ petAction: '动作列表',
97
+ petLoop: '循环',
98
+ petOnce: '单次',
99
+ petReset: '归位',
100
+ petHide: '隐藏',
101
+ petMenuActions: '动作',
102
+ petEmpty: '没有找到宠物素材(builtin/宠物)',
103
+ petSaved: '已保存',
87
104
  // ── 更新公告(第四个页签;正文来自 /update 带回的 Release body)──
88
105
  catRelease: '更新公告',
89
106
  annHead: 'v{version} 更新了什么',
@@ -156,6 +173,19 @@ window.__ModuleLoader__.load({
156
173
  updateChecking: 'Checking for updates…',
157
174
  updateLatest: 'Up to date',
158
175
  updateUnknown: 'Update check unavailable',
176
+ // ── Pet (third tab; overlay + settings copy) ──
177
+ catPet: 'Pet',
178
+ petEnable: 'Show desktop pet',
179
+ petNote: 'The pet floats in the bottom-right corner: drag it to move, it remembers where you left it, click it for a reaction.',
180
+ petSize: 'Pet size',
181
+ petAction: 'Actions',
182
+ petLoop: 'loop',
183
+ petOnce: 'once',
184
+ petReset: 'Reset position',
185
+ petHide: 'Hide',
186
+ petMenuActions: 'Actions',
187
+ petEmpty: 'No pet clips found (builtin/宠物)',
188
+ petSaved: 'Saved',
159
189
  // ── What's new (fourth tab; body comes from the Release notes /update returns) ──
160
190
  catRelease: "What's new",
161
191
  annHead: 'What changed in v{version}',
@@ -206,6 +236,9 @@ window.__ModuleLoader__.load({
206
236
  .dsa-set-desc { font-size: 13px; line-height: 1.55; color: var(--dsw-alias-label-secondary); margin-bottom: 10px; max-width: 720px; }
207
237
  .dsa-set-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
208
238
  .dsa-set-row:last-of-type { border-bottom: none; }
239
+ /* 动作区:标题行 + 整宽的卡片网格(动作数量跟着素材目录长,不用改代码)。 */
240
+ .dsa-set-stack { padding: 12px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
241
+ .dsa-set-stack-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
209
242
  .dsa-set-label { font-size: 13.5px; color: var(--dsw-alias-label-primary); }
210
243
  .dsa-set-note { font-size: 12px; color: var(--dsw-alias-label-tertiary); margin-top: 3px; }
211
244
  .dsa-set-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
@@ -230,6 +263,15 @@ window.__ModuleLoader__.load({
230
263
  .dsa-set-sec { font-size: 13px; color: var(--dsw-alias-label-primary); min-width: 52px; text-align: right; font-variant-numeric: tabular-nums; }
231
264
  .dsa-set-range { width: 180px; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; }
232
265
  .dsa-set-range:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
266
+ /* 动作列表:一格一个动作,名字 + 播放方式;正在演的那个描边高亮。 */
267
+ .dsa-set-acts { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-top: 12px; }
268
+ .dsa-set-act { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); font: inherit; color: var(--dsw-alias-label-primary); text-align: left; cursor: pointer; transition: border-color 120ms ease, background 120ms ease; }
269
+ .dsa-set-act:hover { background: var(--dsw-alias-bg-layer-2); }
270
+ .dsa-set-act:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
271
+ .dsa-set-act[data-on="1"] { border-color: var(--dsw-alias-brand-primary); background: var(--dsw-alias-bg-layer-2); }
272
+ .dsa-set-act:disabled { opacity: 0.55; cursor: default; pointer-events: none; }
273
+ .dsa-set-act-name { font-size: 13px; line-height: 1.4; }
274
+ .dsa-set-act-kind { font-size: 11.5px; line-height: 1.2; color: var(--dsw-alias-label-tertiary); }
233
275
  .dsa-set-empty { font-size: 12.5px; color: var(--dsw-alias-label-tertiary); padding: 10px 0; }
234
276
  /* ── 当前素材预览(设置区素材行)── */
235
277
  .dsa-set-cur { display: flex; align-items: center; gap: 10px; min-width: 0; }
@@ -713,8 +755,545 @@ window.__ModuleLoader__.load({
713
755
  return lines
714
756
  }
715
757
 
758
+ // ──────────────────────────────────────────────────────────────
759
+ // 宠物浮层
760
+ // ──────────────────────────────────────────────────────────────
761
+
716
762
  /**
717
- * 「启动动画」页:常驻设置区 + 下方 [MP4|壁纸|音频|更新公告] 分类切换器与内容面板。
763
+ * 素材的宽高比(高 ÷ 宽):480×648。
764
+ *
765
+ * 必须和 `tools/make-pet-assets.mjs` 的 `CROP` 一致 —— 素材是裁好再发的,
766
+ * 客户端只能按这个比例算高度(没有别的地方知道真实尺寸)。换裁剪窗口时两处一起改。
767
+ */
768
+ const PET_ASPECT = 648 / 480
769
+ /** 轮播里两个动作之间的定格时长:动作演完一遍停住,隔这么久再演下一个。 */
770
+ const PET_GAP_MS = 3000
771
+
772
+ /** 浮层样式。挂 body 的常驻元素,不走组件局部样式,单独注入一份 <style>。 */
773
+ const PET_STYLE = `
774
+ .dsa-pet { position: fixed; left: 0; top: 0; z-index: 999999; pointer-events: none; }
775
+ .dsa-pet[data-on="0"] { display: none; }
776
+ .dsa-pet-stage { position: relative; display: grid; pointer-events: auto; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; transform-origin: 50% 88%; transition: transform 210ms cubic-bezier(.22, 1.4, .36, 1); }
777
+ .dsa-pet-stage[data-drag="1"] { cursor: grabbing; transition: none; }
778
+ /* 三段动画叠在同一格:切换只改 opacity,不增删 DOM —— 重建 video 要重新解码,会明显闪。 */
779
+ .dsa-pet-video { grid-area: 1 / 1; width: 100%; height: auto; display: block; opacity: 0; transition: opacity 140ms linear; pointer-events: none; }
780
+ .dsa-pet-video[data-on="1"] { opacity: 1; }
781
+ /* 右键菜单:跟随应用深浅色(同一份文档,--dsw-alias-* 全局可用)。 */
782
+ .dsa-pet-menu { position: fixed; z-index: 1000000; min-width: 150px; max-height: 62vh; overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 10px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35)); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16); }
783
+ .dsa-pet-menu-head { font-size: 11px; color: var(--dsw-alias-label-tertiary, #999); padding: 4px 8px 2px; }
784
+ .dsa-pet-menu-item { display: block; width: 100%; text-align: left; font: inherit; font-size: 12.5px; color: var(--dsw-alias-label-primary, #333); background: none; border: 0; border-radius: 6px; padding: 6px 8px; cursor: pointer; white-space: nowrap; }
785
+ .dsa-pet-menu-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(128, 128, 128, 0.12)); }
786
+ .dsa-pet-menu-item[data-on="1"] { color: var(--dsw-alias-brand-primary, #4b6bfb); }
787
+ .dsa-pet-menu-sep { height: 1px; margin: 5px 6px; background: var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.25)); }
788
+ `
789
+
790
+ /**
791
+ * 桌面宠物浮层。
792
+ *
793
+ * 为什么不用 React:它挂在 `document.body` 上,生命周期跨整个应用,和设置页的
794
+ * 渲染树没有关系(关掉设置页宠物还在)。原生 DOM 让"进设置页才创建、退出就
795
+ * 销毁"这类错误不可能发生,也省掉一次 createRoot。
796
+ *
797
+ * 位置口径:`x/y` 是 0–100 的百分比,表示"在可移动区间里的比例"
798
+ * (0 = 贴左/贴上,100 = 贴右/贴下),所以窗口缩放后宠物仍然贴着同一角,
799
+ * 也不会跑到屏幕外。像素位置 = 百分比 × (视口 − 宠物尺寸)。
800
+ *
801
+ * @param {{ onPersist: (patch: object) => void, t: (key: string) => string }} options 位置落盘回调 + 菜单文案。
802
+ * @returns {{ load: () => Promise<void>, sync: (pet: object) => void, play: (id: string) => void, report: () => void, reset: () => void, destroy: () => void }} 浮层控制器。
803
+ */
804
+ function createPetOverlay(options) {
805
+ const onPersist = options.onPersist
806
+ const t = options.t
807
+ /** @type {HTMLStyleElement | null} */
808
+ let styleEl = null
809
+ /** @type {HTMLDivElement | null} */
810
+ let host = null
811
+ /** @type {HTMLDivElement | null} */
812
+ let stage = null
813
+ /** @type {Map<string, HTMLVideoElement>} */
814
+ const videos = new Map()
815
+ /** 已构建的清单指纹:只有动作清单变了才重建 video。 */
816
+ let builtKey = ''
817
+ /** 当前配置快照(位置字段只在 load/reset 时采纳,见 sync 的注释)。 */
818
+ let pet = { enabled: false, size: 176, x: 100, y: 100, idle: '', click: '', drag: '', clips: [] }
819
+ let current = ''
820
+ /** @type {{ id: number, dx: number, dy: number, startX: number, startY: number, moved: boolean } | null} */
821
+ let drag = null
822
+ let frame = 0
823
+ // ── 轮播状态机:动作按清单顺序演,演完一遍定格 PET_GAP_MS 再下一个 ──
824
+ /** 轮播位置(clips 的下标)。手动点播会把位置挪到那个动作上,播完从它下一个继续。 */
825
+ let carouselIndex = 0
826
+ /** 定格计时器(0 = 没有在定格)。 */
827
+ let gapTimer = 0
828
+ /** 当前动作播完(ended)之后要去哪:轮播/点播都会排一个回调进去。 */
829
+ let afterEnded = null
830
+ /** @type {HTMLDivElement | null} 右键菜单(同一时刻至多一个)。 */
831
+ let menu = null
832
+
833
+ /** 注入一次样式(重复调用安全)。 */
834
+ const ensureStyle = () => {
835
+ if (styleEl !== null) return
836
+ styleEl = document.createElement('style')
837
+ styleEl.setAttribute('data-dsa-pet', '')
838
+ styleEl.textContent = PET_STYLE
839
+ document.head.appendChild(styleEl)
840
+ }
841
+
842
+ /**
843
+ * 按当前清单建 DOM。只建一次 —— 清单没变就不动,避免每次状态回包都重新解码。
844
+ */
845
+ const build = () => {
846
+ const key = pet.clips.map((clip) => `${clip.id}:${clip.file}`).join('|')
847
+ if (key === builtKey) return
848
+ builtKey = key
849
+ ensureStyle()
850
+ if (host === null) {
851
+ host = document.createElement('div')
852
+ host.className = 'dsa-pet'
853
+ host.dataset.on = '0'
854
+ stage = document.createElement('div')
855
+ stage.className = 'dsa-pet-stage'
856
+ host.appendChild(stage)
857
+ document.body.appendChild(host)
858
+ }
859
+ if (stage === null) return
860
+ // 换素材(未来支持多套宠物时)才需要清空;同一清单重复调用在上面就返回了。
861
+ for (const [, el] of videos) el.remove()
862
+ videos.clear()
863
+ current = ''
864
+ for (const clip of pet.clips) {
865
+ const video = document.createElement('video')
866
+ video.className = 'dsa-pet-video'
867
+ video.src = `${ASSET}?path=${encodeURIComponent(clip.file)}`
868
+ video.muted = true
869
+ video.loop = clip.loop === true
870
+ video.playsInline = true
871
+ video.preload = 'auto'
872
+ video.setAttribute('aria-hidden', 'true')
873
+ video.dataset.clip = clip.id
874
+ // 所有动作统一在 ended 里接"播完之后"的回调:轮播接"定格后下一个",
875
+ // 点播接"定格后从下一个继续"。循环素材在轮播/点播时会被临时改成单遍。
876
+ video.addEventListener('ended', () => {
877
+ if (current !== clip.id) return
878
+ const after = afterEnded
879
+ afterEnded = null
880
+ if (after !== null) after()
881
+ })
882
+ stage.appendChild(video)
883
+ videos.set(clip.id, video)
884
+ }
885
+ }
886
+
887
+ /** 把百分比位置换算成像素并应用(窗口尺寸变了就重算)。 */
888
+ const layout = () => {
889
+ if (host === null) return
890
+ const width = pet.size
891
+ const height = Math.round(width * PET_ASPECT)
892
+ const maxLeft = Math.max(0, window.innerWidth - width)
893
+ const maxTop = Math.max(0, window.innerHeight - height)
894
+ host.style.width = `${width}px`
895
+ host.style.left = `${Math.round((maxLeft * pet.x) / 100)}px`
896
+ host.style.top = `${Math.round((maxTop * pet.y) / 100)}px`
897
+ }
898
+
899
+ /** 暂停所有视频(关掉宠物时调用:不在后台白烧解码)。 */
900
+ const pauseAll = () => {
901
+ for (const [, video] of videos) {
902
+ try {
903
+ video.pause()
904
+ } catch {
905
+ /* 已停 */
906
+ }
907
+ }
908
+ }
909
+
910
+ /**
911
+ * 切到某个动作。
912
+ * @param {string} id 动作 id(即素材文件名去扩展名,见宿主返回的 pet.clips)。
913
+ */
914
+ const show = (id) => {
915
+ const video = videos.get(id)
916
+ // 动作缺失(用户只放了部分素材)就退回待机,不留一个空白框。
917
+ if (video === undefined) {
918
+ if (id !== pet.idle) show(pet.idle)
919
+ return
920
+ }
921
+ // 注意这里**不能**因为"已经在演这个动作"就早退:show 兼着"重播一遍"的职责
922
+ // (轮播/点播都要从头演)。早退会让"点播当前正在演的动作"变成什么都不发生,
923
+ // 那一轮排好的"演完接下一个"也就永远不会触发 —— 宠物就此卡住。
924
+ current = id
925
+ for (const [key, el] of videos) {
926
+ if (key === id) continue
927
+ el.dataset.on = '0'
928
+ // 淡出跑完再暂停:立刻 pause 会让上一段定在最后一帧,交叉时看得见。
929
+ window.setTimeout(() => {
930
+ if (current !== key) {
931
+ try {
932
+ el.pause()
933
+ } catch {
934
+ /* 已停 */
935
+ }
936
+ }
937
+ }, 160)
938
+ }
939
+ video.dataset.on = '1'
940
+ try {
941
+ video.currentTime = 0
942
+ } catch {
943
+ /* 还没元数据,play 之后自己从头播 */
944
+ }
945
+ const played = video.play()
946
+ if (played && typeof played.catch === 'function') played.catch(() => {})
947
+ // 告诉设置页"现在在演哪一段",动作列表据此高亮(宠物也可能自己切,比如回应播完)。
948
+ window.dispatchEvent(new CustomEvent('dsa:pet-state', { detail: id }))
949
+ }
950
+
951
+ /**
952
+ * 回报当前动作(设置页刚挂载时问一次)。
953
+ *
954
+ * 为什么需要:宠物在页面加载时就开播待机了,那一刻设置页还没渲染,事件没人听;
955
+ * 等用户翻到宠物页签时,高亮会是空的。让设置页主动问一声,两边状态就对齐了。
956
+ */
957
+ const report = () => {
958
+ if (current !== '') window.dispatchEvent(new CustomEvent('dsa:pet-state', { detail: current }))
959
+ }
960
+
961
+ // ── 轮播 / 点播 ──
962
+ //
963
+ // 节奏:每个动作演**一遍**(循环素材临时关掉 loop),end 后定格在最后一帧
964
+ // PET_GAP_MS,再演下一个;到表尾绕回第一个。手动点播(右键菜单 / 左键点击)
965
+ // 是插队:演完从"那个动作的下一个"继续轮播。
966
+
967
+ /** 清掉定格计时器。 */
968
+ const clearGap = () => {
969
+ if (gapTimer !== 0) {
970
+ window.clearTimeout(gapTimer)
971
+ gapTimer = 0
972
+ }
973
+ }
974
+
975
+ /** 轮播彻底停下(关宠物 / 拖拽接管时用)。 */
976
+ const stopSequence = () => {
977
+ clearGap()
978
+ afterEnded = null
979
+ }
980
+
981
+ /**
982
+ * 播一个动作**一遍**,播完把控制权交给 `after`。
983
+ * 与 show 的区别:临时关掉 loop,让 ended 事件能告诉我们"演完了"。
984
+ */
985
+ const playOnce = (id, after) => {
986
+ const video = videos.get(id)
987
+ if (video === undefined) {
988
+ // 动作缺失(清单和素材不同步)就跳过它,节奏不能断。
989
+ after()
990
+ return
991
+ }
992
+ clearGap()
993
+ afterEnded = after
994
+ video.loop = false
995
+ show(id)
996
+ }
997
+
998
+ /** 从序列的第 index 个开始轮播(越界自动绕回)。 */
999
+ const playCarouselAt = (index) => {
1000
+ const count = pet.clips.length
1001
+ if (!pet.enabled || count === 0) return
1002
+ carouselIndex = ((index % count) + count) % count
1003
+ playOnce(pet.clips[carouselIndex].id, () => {
1004
+ gapTimer = window.setTimeout(() => playCarouselAt(carouselIndex + 1), PET_GAP_MS)
1005
+ })
1006
+ }
1007
+
1008
+ /**
1009
+ * 手动点播:演 `id` 一遍,定格后从**它的下一个**继续轮播。
1010
+ * 右键菜单和左键点击都走这里 —— 对轮播来说是同一种"插队"。
1011
+ */
1012
+ const playManual = (id) => {
1013
+ const at = pet.clips.findIndex((clip) => clip.id === id)
1014
+ if (at < 0) return
1015
+ // 轮播位置要跟着挪到点播的那个动作上:不然中途被打断(拖拽)再恢复,
1016
+ // 会从旧位置接着演,而不是从"点播动作的下一个"接着演。
1017
+ carouselIndex = at
1018
+ playOnce(id, () => {
1019
+ gapTimer = window.setTimeout(() => playCarouselAt(at + 1), PET_GAP_MS)
1020
+ })
1021
+ }
1022
+
1023
+ /**
1024
+ * 恢复轮播:**立刻**演下一个。
1025
+ *
1026
+ * 为什么不等那 3 秒间隔:间隔是"两个动作之间"的节奏,而这里是**用户主动打断**
1027
+ * 之后回来(拖拽松手)。再等 3 秒,画面就停在被打断动作的最后一帧上不动 ——
1028
+ * 看着像卡住了。间隔留给轮播本身,打断后立刻接上。
1029
+ */
1030
+ const resumeCarousel = () => {
1031
+ clearGap()
1032
+ afterEnded = null
1033
+ playCarouselAt(carouselIndex + 1)
1034
+ }
1035
+
1036
+ // ── 右键菜单 ──
1037
+
1038
+ const closeMenu = () => {
1039
+ if (menu !== null) {
1040
+ menu.remove()
1041
+ menu = null
1042
+ document.removeEventListener('pointerdown', onMenuDocDown, true)
1043
+ }
1044
+ }
1045
+
1046
+ /** 点菜单以外的地方就收起(capture 阶段拦,抢在拖拽/点击处理之前)。 */
1047
+ const onMenuDocDown = (event) => {
1048
+ if (menu !== null && event.target instanceof Node && !menu.contains(event.target)) closeMenu()
1049
+ }
1050
+
1051
+ const menuItem = (label, onClick, on) => {
1052
+ const item = document.createElement('button')
1053
+ item.type = 'button'
1054
+ item.className = 'dsa-pet-menu-item'
1055
+ item.textContent = label
1056
+ item.dataset.on = on ? '1' : '0'
1057
+ item.addEventListener('click', () => {
1058
+ closeMenu()
1059
+ onClick()
1060
+ })
1061
+ return item
1062
+ }
1063
+
1064
+ /** 右键菜单:「动作」列表 + 归位 + 隐藏。列表来自 pet.clips,加动作自动出现。 */
1065
+ const openMenu = (x, y) => {
1066
+ closeMenu()
1067
+ ensureStyle()
1068
+ menu = document.createElement('div')
1069
+ menu.className = 'dsa-pet-menu'
1070
+ const head = document.createElement('div')
1071
+ head.className = 'dsa-pet-menu-head'
1072
+ head.textContent = options.t('petMenuActions')
1073
+ menu.appendChild(head)
1074
+ for (const clip of pet.clips) {
1075
+ menu.appendChild(menuItem(clip.name, () => playManual(clip.id), current === clip.id))
1076
+ }
1077
+ menu.appendChild(Object.assign(document.createElement('div'), { className: 'dsa-pet-menu-sep' }))
1078
+ menu.appendChild(
1079
+ menuItem(options.t('petReset'), () => {
1080
+ reset()
1081
+ }, false),
1082
+ )
1083
+ menu.appendChild(menuItem(options.t('petHide'), hideSelf, false))
1084
+ document.body.appendChild(menu)
1085
+ // 贴着鼠标展开,但不许出屏。
1086
+ const rect = menu.getBoundingClientRect()
1087
+ const left = Math.max(4, Math.min(x, window.innerWidth - rect.width - 4))
1088
+ const top = Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))
1089
+ menu.style.left = `${Math.round(left)}px`
1090
+ menu.style.top = `${Math.round(top)}px`
1091
+ document.addEventListener('pointerdown', onMenuDocDown, true)
1092
+ }
1093
+
1094
+ /** 菜单里的"隐藏":等于设置页那个总开关,落盘后浮层会随 sync 自己收起来。 */
1095
+ const hideSelf = () => {
1096
+ stopSequence()
1097
+ onPersist({ petEnabled: false })
1098
+ }
1099
+
1100
+ /** 把当前像素位置写回百分比并落盘(拖动结束时调用)。 */
1101
+ const commitPosition = () => {
1102
+ if (host === null) return
1103
+ const width = pet.size
1104
+ const height = Math.round(width * PET_ASPECT)
1105
+ const maxLeft = Math.max(1, window.innerWidth - width)
1106
+ const maxTop = Math.max(1, window.innerHeight - height)
1107
+ const left = Number.parseFloat(host.style.left) || 0
1108
+ const top = Number.parseFloat(host.style.top) || 0
1109
+ pet.x = Math.max(0, Math.min(100, Math.round((left / maxLeft) * 1000) / 10))
1110
+ pet.y = Math.max(0, Math.min(100, Math.round((top / maxTop) * 1000) / 10))
1111
+ onPersist({ petX: pet.x, petY: pet.y })
1112
+ }
1113
+
1114
+ /** 拖动开始:接管指针、切到拖拽动作。 */
1115
+ const onPointerDown = (event) => {
1116
+ if (event.button !== 0 || host === null || !pet.enabled) return
1117
+ closeMenu()
1118
+ const rect = host.getBoundingClientRect()
1119
+ drag = {
1120
+ id: event.pointerId,
1121
+ dx: event.clientX - rect.left,
1122
+ dy: event.clientY - rect.top,
1123
+ startX: event.clientX,
1124
+ startY: event.clientY,
1125
+ moved: false,
1126
+ }
1127
+ stage.dataset.drag = '1'
1128
+ try {
1129
+ stage.setPointerCapture(event.pointerId)
1130
+ } catch {
1131
+ /* 某些浏览器对非活动指针会抛,拖拽仍可用 */
1132
+ }
1133
+ // 拖拽接管轮播:被拎着的时候循环演"拖拽悬空"(没有这个角色就保持当前动作)。
1134
+ stopSequence()
1135
+ if (pet.drag !== '') {
1136
+ const dragVideo = videos.get(pet.drag)
1137
+ if (dragVideo !== undefined) dragVideo.loop = true
1138
+ show(pet.drag)
1139
+ }
1140
+ event.preventDefault()
1141
+ }
1142
+
1143
+ const onPointerMove = (event) => {
1144
+ if (drag === null || event.pointerId !== drag.id || host === null) return
1145
+ const dx = event.clientX - drag.startX
1146
+ const dy = event.clientY - drag.startY
1147
+ // 4px 门槛:点击时手会抖一两像素,不该被当成拖拽。
1148
+ if (!drag.moved && Math.hypot(dx, dy) < 4) return
1149
+ drag.moved = true
1150
+ const width = pet.size
1151
+ const height = Math.round(width * PET_ASPECT)
1152
+ const maxLeft = Math.max(0, window.innerWidth - width)
1153
+ const maxTop = Math.max(0, window.innerHeight - height)
1154
+ const left = Math.max(0, Math.min(maxLeft, event.clientX - drag.dx))
1155
+ const top = Math.max(0, Math.min(maxTop, event.clientY - drag.dy))
1156
+ host.style.left = `${Math.round(left)}px`
1157
+ host.style.top = `${Math.round(top)}px`
1158
+ // 被拎起来的那点手感:往哪边甩就往哪边倒,最多 9°。
1159
+ stage.style.transform = `rotate(${Math.max(-9, Math.min(9, dx * 0.12)).toFixed(1)}deg)`
1160
+ }
1161
+
1162
+ const onPointerUp = (event) => {
1163
+ if (drag === null || event.pointerId !== drag.id) return
1164
+ const moved = drag.moved
1165
+ drag = null
1166
+ if (stage !== null) {
1167
+ stage.dataset.drag = '0'
1168
+ stage.style.transform = ''
1169
+ }
1170
+ try {
1171
+ stage.releasePointerCapture(event.pointerId)
1172
+ } catch {
1173
+ /* 已经释放 */
1174
+ }
1175
+ if (moved) {
1176
+ commitPosition()
1177
+ // 拖拽结束:把"拖拽悬空"定格在当前帧,隔一会儿从轮播的下一个接着演。
1178
+ if (pet.drag !== '') {
1179
+ const dragVideo = videos.get(pet.drag)
1180
+ if (dragVideo !== undefined) {
1181
+ try {
1182
+ dragVideo.pause()
1183
+ } catch {
1184
+ /* 已停 */
1185
+ }
1186
+ }
1187
+ }
1188
+ resumeCarousel()
1189
+ } else if (pet.click !== '') {
1190
+ // 没位移 = 一次点击:插播"点击回应",演完从它的下一个继续轮播。
1191
+ playManual(pet.click)
1192
+ } else {
1193
+ resumeCarousel()
1194
+ }
1195
+ }
1196
+
1197
+ const onResize = () => {
1198
+ if (frame !== 0) return
1199
+ frame = window.requestAnimationFrame(() => {
1200
+ frame = 0
1201
+ layout()
1202
+ })
1203
+ }
1204
+
1205
+ const bind = () => {
1206
+ if (stage === null || stage.dataset.bound === '1') return
1207
+ stage.dataset.bound = '1'
1208
+ stage.addEventListener('pointerdown', onPointerDown)
1209
+ stage.addEventListener('pointermove', onPointerMove)
1210
+ stage.addEventListener('pointerup', onPointerUp)
1211
+ stage.addEventListener('pointercancel', onPointerUp)
1212
+ // 右键:动作菜单。浏览器默认菜单拦掉。
1213
+ stage.addEventListener('contextmenu', (event) => {
1214
+ if (!pet.enabled) return
1215
+ event.preventDefault()
1216
+ openMenu(event.clientX, event.clientY)
1217
+ })
1218
+ window.addEventListener('resize', onResize)
1219
+ }
1220
+
1221
+ /** 首次从服务端取状态(失败就安静地不出现 —— 宠物是装饰,不该报错)。 */
1222
+ const load = () =>
1223
+ fetch(MANAGE, { cache: 'no-store' })
1224
+ .then((res) => res.json())
1225
+ .then((payload) => {
1226
+ if (!payload || !payload.ok || !payload.pet) return
1227
+ pet.x = typeof payload.pet.x === 'number' ? payload.pet.x : pet.x
1228
+ pet.y = typeof payload.pet.y === 'number' ? payload.pet.y : pet.y
1229
+ sync(payload.pet)
1230
+ })
1231
+ .catch(() => {})
1232
+
1233
+ /**
1234
+ * 应用一份新配置(开关 / 大小 / 动作清单)。
1235
+ *
1236
+ * **不采纳 x/y**:位置由本浮层独占(拖动后自己写盘)。设置页每次写别的字段都会
1237
+ * 拿到一份状态回包,如果那份回包里的旧坐标也被应用,宠物会被拽回上一次保存的位置。
1238
+ *
1239
+ * @param {object} next 服务端给的 pet 状态。
1240
+ */
1241
+ const sync = (next) => {
1242
+ if (!next || typeof next !== 'object') return
1243
+ pet.enabled = next.enabled === true
1244
+ if (typeof next.size === 'number' && Number.isFinite(next.size)) pet.size = next.size
1245
+ if (Array.isArray(next.clips) && next.clips.length > 0) pet.clips = next.clips
1246
+ // 三个语义角色由宿主给:待机 / 点击时播哪个 / 拖动时播哪个。
1247
+ for (const role of ['idle', 'click', 'drag']) {
1248
+ if (typeof next[role] === 'string') pet[role] = next[role]
1249
+ }
1250
+ // 关着的时候**不建 DOM**:九段素材加起来 4 MB,没开宠物就不该占用户的带宽
1251
+ // 和内存。打开时才建,那一次开销是用户自己按出来的。
1252
+ if (!pet.enabled) {
1253
+ stopSequence()
1254
+ closeMenu()
1255
+ pauseAll()
1256
+ current = ''
1257
+ if (host !== null) host.dataset.on = '0'
1258
+ return
1259
+ }
1260
+ build()
1261
+ bind()
1262
+ layout()
1263
+ if (host !== null) host.dataset.on = '1'
1264
+ if (current === '') playCarouselAt(carouselIndex)
1265
+ }
1266
+
1267
+ /** 归位:回到右下角并落盘。 */
1268
+ const reset = () => {
1269
+ pet.x = 100
1270
+ pet.y = 100
1271
+ layout()
1272
+ onPersist({ petX: 100, petY: 100 })
1273
+ }
1274
+
1275
+ const destroy = () => {
1276
+ window.removeEventListener('resize', onResize)
1277
+ if (frame !== 0) window.cancelAnimationFrame(frame)
1278
+ frame = 0
1279
+ stopSequence()
1280
+ closeMenu()
1281
+ pauseAll()
1282
+ videos.clear()
1283
+ if (host !== null) host.remove()
1284
+ if (styleEl !== null) styleEl.remove()
1285
+ host = null
1286
+ stage = null
1287
+ styleEl = null
1288
+ builtKey = ''
1289
+ }
1290
+
1291
+ // play = 手动点播:设置页的动作卡和右键菜单都走这条 —— 演一遍然后从下一个继续轮播。
1292
+ return { load, sync, play: playManual, report, reset, destroy }
1293
+ }
1294
+
1295
+ /**
1296
+ * 「启动动画」页:常驻设置区 + 下方 [MP4|壁纸|音频|宠物|更新公告] 分类切换器与内容面板。
718
1297
  * @param {(key: string) => string} t 翻译。
719
1298
  * @returns {React.ComponentType} 页面组件。
720
1299
  */
@@ -739,6 +1318,11 @@ window.__ModuleLoader__.load({
739
1318
  const avolRef = React.useRef(null)
740
1319
  const [playingPath, setPlayingPath] = React.useState('')
741
1320
  const audioElRef = React.useRef(null)
1321
+ // 宠物:大小滑杆的本地读数(拖动时只改本地,停手再提交)。
1322
+ const [petSize, setPetSize] = React.useState(176)
1323
+ const petSizeRef = React.useRef(null)
1324
+ /** 宠物此刻在演哪个动作 —— 由浮层回报,用来高亮动作列表(它自己也会切,比如回应播完)。 */
1325
+ const [activeClip, setActiveClip] = React.useState('')
742
1326
  // ── 卸载 / 更新(v1.11.0)──
743
1327
  // 自管状态:宿主在 profile 里有没有装我们、有没有选中、装的是什么形态。
744
1328
  const [selfState, setSelfState] = React.useState(null)
@@ -748,8 +1332,25 @@ window.__ModuleLoader__.load({
748
1332
  React.useEffect(() => {
749
1333
  if (state && typeof state.durationSec === 'number') setSec(state.durationSec)
750
1334
  if (state && typeof state.audioVolume === 'number') setAvol(Math.round(state.audioVolume * 100))
1335
+ if (state && state.pet && typeof state.pet.size === 'number') setPetSize(state.pet.size)
751
1336
  }, [state])
752
1337
 
1338
+ // 设置页里的改动(开关 / 大小)要立刻反映到浮层上:两边不在同一棵渲染树上,
1339
+ // 用一条 window 事件桥接。浮层只取 enabled/size/clips,位置它自己管。
1340
+ React.useEffect(() => {
1341
+ if (!state || !state.pet) return
1342
+ window.dispatchEvent(new CustomEvent('dsa:pet-sync', { detail: state.pet }))
1343
+ }, [state])
1344
+
1345
+ // 反过来:浮层切了动作就回报一声,列表跟着高亮。
1346
+ React.useEffect(() => {
1347
+ const onState = (event) => setActiveClip(typeof event.detail === 'string' ? event.detail : '')
1348
+ window.addEventListener('dsa:pet-state', onState)
1349
+ // 先挂监听再问:宠物在页面加载时就开播了,那会儿没人听。
1350
+ window.dispatchEvent(new CustomEvent('dsa:pet-query'))
1351
+ return () => window.removeEventListener('dsa:pet-state', onState)
1352
+ }, [])
1353
+
753
1354
  // 首次拿到状态时,把分类切换器对齐到当前使用的素材(只做一次,之后尊重手动切换)。
754
1355
  React.useEffect(() => {
755
1356
  if (!state || syncedRef.current) return
@@ -775,6 +1376,7 @@ window.__ModuleLoader__.load({
775
1376
  () => () => {
776
1377
  if (commitRef.current !== null) clearTimeout(commitRef.current)
777
1378
  if (avolRef.current !== null) clearTimeout(avolRef.current)
1379
+ if (petSizeRef.current !== null) clearTimeout(petSizeRef.current)
778
1380
  if (audioElRef.current !== null) {
779
1381
  try {
780
1382
  audioElRef.current.pause()
@@ -818,8 +1420,40 @@ window.__ModuleLoader__.load({
818
1420
  }, 350)
819
1421
  }
820
1422
 
821
- /** 试听/停止一条音频(列表内点击,不影响开机配置)。 */
822
- const togglePlay = (file) => {
1423
+ // 宠物大小:同展示时长的防抖方式(停手 350ms 提交一次)。
1424
+ const onPetSize = (value) => {
1425
+ const next = Math.min(320, Math.max(100, Math.round(Number(value))))
1426
+ setPetSize(next)
1427
+ if (petSizeRef.current !== null) clearTimeout(petSizeRef.current)
1428
+ petSizeRef.current = setTimeout(() => {
1429
+ petSizeRef.current = null
1430
+ postJson(send, { petSize: next }, t('petSaved'))
1431
+ }, 350)
1432
+ }
1433
+
1434
+ /** 宠物开关:字段在 state.pet 里,不走通用 toggle(那个读的是顶层字段)。 */
1435
+ const togglePet = () => {
1436
+ if (!state || !state.pet) return
1437
+ postJson(send, { petEnabled: !state.pet.enabled }, t('petSaved'))
1438
+ }
1439
+
1440
+ /** 动作预览:让浮层立刻播一段(不写配置,纯预览)。宠物没开就先打开它 —— 用户按了按钮就是要看。 */
1441
+ const previewPet = (id) => {
1442
+ const fire = () => window.dispatchEvent(new CustomEvent('dsa:pet-play', { detail: id }))
1443
+ if (state && state.pet && !state.pet.enabled) {
1444
+ void postJson(send, { petEnabled: true }, t('petSaved')).then((payload) => {
1445
+ if (!payload || !payload.ok) return
1446
+ // 先把新状态推给浮层(它要在这之后才有 DOM 可播),再发播放事件。
1447
+ // 只靠 effect 那条路会有一个"打开还没渲染完就播"的空窗。
1448
+ if (payload.pet) window.dispatchEvent(new CustomEvent('dsa:pet-sync', { detail: payload.pet }))
1449
+ setTimeout(fire, 60)
1450
+ })
1451
+ return
1452
+ }
1453
+ fire()
1454
+ }
1455
+
1456
+ /** 试听/停止一条音频(列表内点击,不影响开机配置)。 */ const togglePlay = (file) => {
823
1457
  if (audioElRef.current !== null) {
824
1458
  try {
825
1459
  audioElRef.current.pause()
@@ -1013,10 +1647,19 @@ window.__ModuleLoader__.load({
1013
1647
  const files = categoryData ? categoryData.files : []
1014
1648
  const activeFile = state ? state.file : ''
1015
1649
  const activeAudio = state ? state.audioFile : ''
1016
- const categoryLabel = category === 'mp4' ? t('catMp4') : category === 'wall' ? t('catWall') : t('catAudio')
1650
+ const categoryLabel =
1651
+ category === 'pet'
1652
+ ? t('catPet')
1653
+ : category === 'mp4'
1654
+ ? t('catMp4')
1655
+ : category === 'wall'
1656
+ ? t('catWall')
1657
+ : t('catAudio')
1017
1658
  const isAudioCat = category === 'audio'
1018
1659
  // 第四个页签不是素材分类:它没有上传、没有卡片,内容是 /update 带回的 Release 正文。
1019
1660
  const isReleaseCat = category === 'release'
1661
+ // 宠物页签同理:长得像分类,其实是"宠物设置 + 动作预览"。
1662
+ const isPetCat = category === 'pet'
1020
1663
 
1021
1664
  /**
1022
1665
  * 标题旁的版本小字。
@@ -1335,6 +1978,98 @@ window.__ModuleLoader__.load({
1335
1978
  ),
1336
1979
  )
1337
1980
 
1981
+ /**
1982
+ * 宠物面板:开关 + 大小 + 动作列表。
1983
+ *
1984
+ * 动作**不写死**:清单来自宿主扫描 `builtin/宠物/`,丢一个新 webm 进去就多一条。
1985
+ * 每格写着名字和「循环 / 单次」,正在演的那格描边高亮(浮层切动作时会回报)。
1986
+ */
1987
+ const pet = state && state.pet && typeof state.pet === 'object' ? state.pet : null
1988
+ const petClips = pet && Array.isArray(pet.clips) ? pet.clips : []
1989
+ const petSwitch = h(
1990
+ 'button',
1991
+ {
1992
+ type: 'button',
1993
+ className: 'dsa-set-switch',
1994
+ role: 'switch',
1995
+ 'aria-checked': pet && pet.enabled ? 'true' : 'false',
1996
+ 'aria-label': t('petEnable'),
1997
+ disabled: busy || pet === null,
1998
+ onClick: togglePet,
1999
+ },
2000
+ h('span', { className: 'dsa-set-knob' }),
2001
+ )
2002
+ const petResetBtn = h(
2003
+ 'button',
2004
+ {
2005
+ type: 'button',
2006
+ className: 'dsa-set-btn',
2007
+ disabled: busy || pet === null || !pet.enabled,
2008
+ onClick: () => window.dispatchEvent(new CustomEvent('dsa:pet-reset')),
2009
+ },
2010
+ t('petReset'),
2011
+ )
2012
+ /** 一个动作 = 一个格子。宠物没开也能按(会先打开它),按了就该看到反应。 */
2013
+ const petClipCard = (clip) =>
2014
+ h(
2015
+ 'button',
2016
+ {
2017
+ key: clip.id,
2018
+ type: 'button',
2019
+ className: 'dsa-set-act',
2020
+ 'data-on': activeClip === clip.id ? '1' : '0',
2021
+ 'aria-pressed': activeClip === clip.id ? 'true' : 'false',
2022
+ disabled: busy || pet === null,
2023
+ onClick: () => previewPet(clip.id),
2024
+ },
2025
+ h('span', { className: 'dsa-set-act-name' }, clip.name),
2026
+ h('span', { className: 'dsa-set-act-kind' }, clip.loop === false ? t('petOnce') : t('petLoop')),
2027
+ )
2028
+ const petPanel = h(
2029
+ 'div',
2030
+ { className: 'dsa-set-panel', role: 'tabpanel', 'aria-label': t('catPet') },
2031
+ h('div', { className: 'dsa-set-desc' }, t('petNote')),
2032
+ h(
2033
+ 'div',
2034
+ { className: 'dsa-set-row' },
2035
+ h('div', { className: 'dsa-set-label' }, t('petEnable')),
2036
+ h('div', { className: 'dsa-set-right' }, petSwitch),
2037
+ ),
2038
+ h(
2039
+ 'div',
2040
+ { className: 'dsa-set-row' },
2041
+ h('div', { className: 'dsa-set-label' }, t('petSize')),
2042
+ h(
2043
+ 'div',
2044
+ { className: 'dsa-set-right' },
2045
+ h('span', { className: 'dsa-set-sec' }, `${petSize}px`),
2046
+ h('input', {
2047
+ type: 'range',
2048
+ className: 'dsa-set-range',
2049
+ min: 100,
2050
+ max: 320,
2051
+ step: 4,
2052
+ value: petSize,
2053
+ 'aria-label': t('petSize'),
2054
+ onChange: (event) => onPetSize(event.target.value),
2055
+ }),
2056
+ ),
2057
+ ),
2058
+ h(
2059
+ 'div',
2060
+ { className: 'dsa-set-stack' },
2061
+ h(
2062
+ 'div',
2063
+ { className: 'dsa-set-stack-head' },
2064
+ h('div', { className: 'dsa-set-label' }, t('petAction')),
2065
+ h('div', { className: 'dsa-set-right' }, petResetBtn),
2066
+ ),
2067
+ petClips.length === 0
2068
+ ? h('div', { className: 'dsa-set-empty' }, t('petEmpty'))
2069
+ : h('div', { className: 'dsa-set-acts' }, petClips.map(petClipCard)),
2070
+ ),
2071
+ )
2072
+
1338
2073
  return h(
1339
2074
  'div',
1340
2075
  { className: 'dsa-set-wrap', 'data-busy': busy ? '1' : '0' },
@@ -1486,7 +2221,7 @@ window.__ModuleLoader__.load({
1486
2221
  ),
1487
2222
  ),
1488
2223
  ),
1489
- // ── 分类切换器:[MP4 | 壁纸 | 音频 | 更新公告](素材三类 + 一个信息页)──
2224
+ // ── 分类切换器:[MP4 | 壁纸 | 音频 | 宠物 | 更新公告](素材三类 + 宠物 + 一个信息页)──
1490
2225
  h(
1491
2226
  'div',
1492
2227
  { className: 'dsa-set-tabs', role: 'tablist', 'aria-label': t('title') },
@@ -1494,6 +2229,7 @@ window.__ModuleLoader__.load({
1494
2229
  { id: 'mp4', label: t('catMp4') },
1495
2230
  { id: 'wall', label: t('catWall') },
1496
2231
  { id: 'audio', label: t('catAudio') },
2232
+ { id: 'pet', label: t('catPet') },
1497
2233
  { id: 'release', label: t('catRelease') },
1498
2234
  ].map((entry) =>
1499
2235
  h(
@@ -1515,7 +2251,7 @@ window.__ModuleLoader__.load({
1515
2251
  ),
1516
2252
  ),
1517
2253
  ),
1518
- isReleaseCat ? releasePanel : categoryPanel,
2254
+ isReleaseCat ? releasePanel : isPetCat ? petPanel : categoryPanel,
1519
2255
  // 上传输入随分类切换 accept。
1520
2256
  h('input', {
1521
2257
  ref: fileRef,
@@ -1550,7 +2286,50 @@ window.__ModuleLoader__.load({
1550
2286
  ),
1551
2287
  )
1552
2288
 
2289
+ // ── 桌面宠物:常驻浮层,和设置页无关(关掉设置页它还在)──
2290
+ //
2291
+ // 位置由浮层自己写盘:拖动结束时 POST 一次 petX/petY。设置页只做
2292
+ // "开关 / 大小 / 触发动作",两边用 window 事件桥接,互不持有引用。
2293
+ // POST 的回包会广播成 dsa:pet-sync:右键菜单里"隐藏"自己之后,浮层和
2294
+ // 设置页的开关都能立刻对上,不用等用户手动刷新。
2295
+ const petLayer = createPetOverlay({
2296
+ t,
2297
+ onPersist: (patch) => {
2298
+ void fetch(MANAGE, {
2299
+ method: 'POST',
2300
+ headers: { 'Content-Type': 'application/json' },
2301
+ body: JSON.stringify(patch),
2302
+ })
2303
+ .then((res) => res.json())
2304
+ .then((payload) => {
2305
+ if (payload && payload.pet) {
2306
+ window.dispatchEvent(new CustomEvent('dsa:pet-sync', { detail: payload.pet }))
2307
+ }
2308
+ })
2309
+ .catch(() => {})
2310
+ },
2311
+ })
2312
+ const onPetSync = (event) => petLayer.sync(event.detail)
2313
+ const onPetPlay = (event) => petLayer.play(String(event.detail || ''))
2314
+ const onPetReset = () => petLayer.reset()
2315
+ // 设置页翻到宠物页签时会问一句"现在在演哪个",好把动作列表的高亮对上。
2316
+ const onPetQuery = () => petLayer.report()
2317
+ window.addEventListener('dsa:pet-sync', onPetSync)
2318
+ window.addEventListener('dsa:pet-play', onPetPlay)
2319
+ window.addEventListener('dsa:pet-reset', onPetReset)
2320
+ window.addEventListener('dsa:pet-query', onPetQuery)
2321
+ void petLayer.load()
2322
+
1553
2323
  return () => {
2324
+ window.removeEventListener('dsa:pet-sync', onPetSync)
2325
+ window.removeEventListener('dsa:pet-play', onPetPlay)
2326
+ window.removeEventListener('dsa:pet-reset', onPetReset)
2327
+ window.removeEventListener('dsa:pet-query', onPetQuery)
2328
+ try {
2329
+ petLayer.destroy()
2330
+ } catch {
2331
+ /* 浮层可能已经因为页面卸载被清掉 */
2332
+ }
1554
2333
  for (const off of [offSlots, offZh, offEn]) {
1555
2334
  try {
1556
2335
  off && off()
Binary file
package/index.js CHANGED
@@ -293,6 +293,42 @@ export const MEDIA_DIR_NAME = 'startup-animation-media'
293
293
  /** 内置素材库目录名(包内,随插件分发,见 builtin/README 一节的分类结构)。 */
294
294
  export const BUILTIN_DIR_NAME = 'builtin'
295
295
 
296
+ /**
297
+ * 宠物动画目录(包内 `builtin/宠物/`)。
298
+ *
299
+ * 它和 MP4 / 图片 / 音频并列放在 `builtin/` 下,但**不是启动素材** ——
300
+ * 不出现在素材库列表里(scanLibrary 跳过),只由宠物浮层取用。
301
+ * 加动作就是往这个目录丢一个 `.webm`,**不用改代码**:文件名即动作名。
302
+ */
303
+ export const PET_DIR_NAME = '宠物'
304
+
305
+ /**
306
+ * 宠物动作的语义约定:哪一个文件当"待机"、哪一个是"点击回应"、哪一个演"被拖拽"。
307
+ *
308
+ * 只是**声明**,不是白名单 —— 目录里其他 `.webm` 一样会出现在动作列表里(按文件名排序)。
309
+ * 这三个角色缺失时对应行为降级:没有待机 = 用列表第一个;没有点击回应 = 点了没反应;
310
+ * 没有拖拽悬空 = 拖动时保持当前动作。
311
+ */
312
+ export const PET_ROLES = Object.freeze({ idle: '待机', click: '点击回应', drag: '拖拽悬空' })
313
+
314
+ /**
315
+ * 已知动作的播放方式:`loop: false` = 播一遍就回待机(点击回应、打招呼这类一次性反应)。
316
+ *
317
+ * 没在这里声明的动作默认**循环**播放 —— 新加的动作通常是想让它一直演。
318
+ * 这份清单同时决定**排列顺序**(没声明的按文件名排在后面)。
319
+ */
320
+ export const PET_CLIPS = Object.freeze([
321
+ { id: PET_ROLES.idle, loop: true },
322
+ { id: PET_ROLES.click, loop: false },
323
+ { id: '害羞捂脸', loop: false },
324
+ { id: PET_ROLES.drag, loop: true },
325
+ { id: '原地踏步', loop: true },
326
+ { id: '吃橘子', loop: false },
327
+ { id: '挥手打招呼', loop: false },
328
+ { id: '原地小憩', loop: true },
329
+ { id: '泡温泉', loop: true },
330
+ ])
331
+
296
332
  /** 仓库主页(更新提示与"查看更新"链接的唯一来源)。 */
297
333
  export const REPO_URL = 'https://github.com/WWFZS/dsh-plugin-startup-animation'
298
334
 
@@ -369,6 +405,14 @@ export const DEFAULT_CONFIG = Object.freeze({
369
405
  skipLabel: '',
370
406
  /** 声音被浏览器拦下时开关的文案;'' = 运行时按语言选择。 */
371
407
  soundLabel: '',
408
+ // ── 桌面宠物(浮在 DSH 界面上的透明小动物)──
409
+ /** 是否显示宠物。默认关:装上新版不该突然冒出一只动物。 */
410
+ petEnabled: false,
411
+ /** 宠物显示宽度(px)。高度按素材 3:4 自动算。 */
412
+ petSize: 176,
413
+ /** 宠物位置:0–100 的百分比,100 = 贴右/贴下。见 client.js 的定位算法。 */
414
+ petX: 100,
415
+ petY: 100,
372
416
  })
373
417
 
374
418
  /** 素材扩展名 → 运行时呈现方式。不支持的扩展名只让该素材降级,不影响动画本身。 */
@@ -1149,6 +1193,9 @@ export function scanLibrary(packageRoot, deleted = [], homeDir) {
1149
1193
  const byName = (a, b) => a.name.localeCompare(b.name)
1150
1194
  for (const dir of dirs) {
1151
1195
  if (!dir.isDirectory()) continue
1196
+ // 宠物动画不是启动素材:它们只有三个固定文件,由宠物浮层按 PET_CLIPS 取用。
1197
+ // 列进素材库只会让用户在"MP4"分类里点到一个 3:4 的小动物。
1198
+ if (dir.name === PET_DIR_NAME) continue
1152
1199
  const catDir = path.join(rootDir, dir.name)
1153
1200
  let files
1154
1201
  try {
@@ -1678,6 +1725,70 @@ export function removeUploadedFile(homeDir, value) {
1678
1725
  return abs
1679
1726
  }
1680
1727
 
1728
+ /**
1729
+ * 宠物可用的动作清单:**扫目录**,不是白名单。
1730
+ *
1731
+ * 往 `builtin/宠物/` 丢一个 `睡觉.webm`,设置页的动作列表里就会多一条「睡觉」,
1732
+ * 不需要改代码、不需要重启(每次 manage 都重新扫)。
1733
+ *
1734
+ * 三个约定:
1735
+ * - 文件名(去扩展名)就是动作 id,也是界面上显示的名字;
1736
+ * - `PET_CLIPS` 里声明过的动作按声明顺序排在前面,其余按文件名排;
1737
+ * - 没声明的动作默认循环播放,`loop:false` 的(点击回应)播完回待机。
1738
+ *
1739
+ * 读不到目录就返回空数组 —— 宠物不出现,比整个设置页报错好。
1740
+ *
1741
+ * @param {string} packageRoot 包根。
1742
+ * @returns {Array<{ id: string, name: string, file: string, loop: boolean }>} 可播放的动作。
1743
+ */
1744
+ export function listPetClips(packageRoot) {
1745
+ const dir = path.join(packageRoot, BUILTIN_DIR_NAME, PET_DIR_NAME)
1746
+ /** @type {import('node:fs').Dirent[]} */
1747
+ let entries
1748
+ try {
1749
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1750
+ } catch {
1751
+ return []
1752
+ }
1753
+ const declared = new Map(PET_CLIPS.map((clip, index) => [clip.id, { index, loop: clip.loop !== false }]))
1754
+ /** @type {Array<{ id: string, name: string, file: string, loop: boolean, rank: number }>} */
1755
+ const clips = []
1756
+ for (const entry of entries) {
1757
+ if (!entry.isFile()) continue
1758
+ const name = entry.name
1759
+ if (path.extname(name).toLowerCase() !== '.webm') continue
1760
+ let stat
1761
+ try {
1762
+ stat = fs.statSync(path.join(dir, name))
1763
+ } catch {
1764
+ continue
1765
+ }
1766
+ if (!stat.isFile() || stat.size <= 0) continue
1767
+ const id = name.slice(0, -'.webm'.length)
1768
+ if (id === '') continue
1769
+ const known = declared.get(id)
1770
+ clips.push({
1771
+ id,
1772
+ name: id,
1773
+ file: `${BUILTIN_DIR_NAME}/${PET_DIR_NAME}/${name}`,
1774
+ loop: known === undefined ? true : known.loop,
1775
+ rank: known === undefined ? PET_CLIPS.length : known.index,
1776
+ })
1777
+ }
1778
+ clips.sort((a, b) => (a.rank === b.rank ? a.id.localeCompare(b.id, 'zh') : a.rank - b.rank))
1779
+ return clips.map(({ rank, ...clip }) => clip)
1780
+ }
1781
+
1782
+ /**
1783
+ * 从动作清单里取一个语义角色实际指向的动作 id。
1784
+ * @param {Array<{ id: string }>} clips 动作清单。
1785
+ * @param {string} wanted 期望的动作 id。
1786
+ * @returns {string} 存在则返回它,否则 `''`(客户端据此降级)。
1787
+ */
1788
+ function pickClip(clips, wanted) {
1789
+ return clips.some((clip) => clip.id === wanted) ? wanted : ''
1790
+ }
1791
+
1681
1792
  /**
1682
1793
  * 当前管理状态(界面上"上传/开关/恢复/素材库"的回显)。
1683
1794
  * @param {{ packageRoot: string, homeDir?: string, route: string }} options 位置。
@@ -1686,6 +1797,8 @@ export function removeUploadedFile(homeDir, value) {
1686
1797
  export function manageStatus(options) {
1687
1798
  // 一次 loadConfig 就够:`raw` 与 `sources` 已经在里面读好了。
1688
1799
  const { client, assetPath, warnings, raw } = loadConfig(options)
1800
+ // 动作清单只扫一次:pet 里三处语义 + 列表都从它来(扫目录要 stat 每个文件)。
1801
+ const petClips = listPetClips(options.packageRoot)
1689
1802
  const fileRaw = typeof raw.file === 'string' ? raw.file : ''
1690
1803
  const audioFileRaw = typeof raw.audioFile === 'string' ? raw.audioFile : ''
1691
1804
  const deleted = Array.isArray(raw.deleted)
@@ -1724,6 +1837,25 @@ export function manageStatus(options) {
1724
1837
  /** 素材库:内置 + 上传合并,上传按扩展名自动归入 MP4/图片/音频分类;已排除 tombstone。 */
1725
1838
  library: scanLibrary(options.packageRoot, deleted, options.homeDir),
1726
1839
  limitBytes: MEDIA_LIMIT_BYTES,
1840
+ /**
1841
+ * 桌面宠物域:开关 / 大小 / 位置 / 动作列表。
1842
+ *
1843
+ * `clips[].file` 是包内相对路径,客户端拼成 `ASSET?path=…` 取流 —— 复用既有的
1844
+ * 素材路由(它有 Range、ETag、逃逸防护),不为宠物再开一条发文件的通道。
1845
+ *
1846
+ * `idle` / `click` / `drag` 是**语义**:哪个动作当待机、点击时播哪个、拖动时播哪个。
1847
+ * 由宿主决定、客户端照用,这样改文件名不用动前端。
1848
+ */
1849
+ pet: {
1850
+ enabled: raw.petEnabled === true,
1851
+ size: clampNumber(raw.petSize, DEFAULT_CONFIG.petSize, 100, 320),
1852
+ x: clampNumber(raw.petX, DEFAULT_CONFIG.petX, 0, 100),
1853
+ y: clampNumber(raw.petY, DEFAULT_CONFIG.petY, 0, 100),
1854
+ idle: pickClip(petClips, PET_ROLES.idle) || (petClips[0] === undefined ? '' : petClips[0].id),
1855
+ click: pickClip(petClips, PET_ROLES.click),
1856
+ drag: pickClip(petClips, PET_ROLES.drag),
1857
+ clips: petClips,
1858
+ },
1727
1859
  /**
1728
1860
  * 开机动画运行时的完整配置(boot.js 每次页面加载实时读取它 —— 桌面注入表是宿主
1729
1861
  * 启动时的一次性快照,配置改动必须走实时通道才能"刷新即生效")。
@@ -2246,8 +2378,27 @@ export function apply(ctx) {
2246
2378
  }
2247
2379
  patch.durationSec = clampDurationSec(body.durationSec)
2248
2380
  }
2381
+ // ── 宠物域:开关 / 大小 / 位置 ──
2382
+ // 大小与位置照旧"服务端夹取不给商用":越界的值就地夹好,不报错 ——
2383
+ // 拖到屏幕边缘时客户端算出的百分比偶尔会是 100.4 这种数。
2384
+ if (typeof body.petEnabled === 'boolean') patch.petEnabled = body.petEnabled
2385
+ if (body.petSize !== undefined) {
2386
+ if (typeof body.petSize !== 'number' || !Number.isFinite(body.petSize)) {
2387
+ reply(res, 400, { ok: false, error: '宠物大小必须是 100–320 的数字(px)' })
2388
+ return
2389
+ }
2390
+ patch.petSize = clampNumber(body.petSize, DEFAULT_CONFIG.petSize, 100, 320)
2391
+ }
2392
+ for (const key of ['petX', 'petY']) {
2393
+ if (body[key] === undefined) continue
2394
+ if (typeof body[key] !== 'number' || !Number.isFinite(body[key])) {
2395
+ reply(res, 400, { ok: false, error: '宠物位置必须是 0–100 的数字' })
2396
+ return
2397
+ }
2398
+ patch[key] = clampNumber(body[key], DEFAULT_CONFIG[key], 0, 100)
2399
+ }
2249
2400
  if (Object.keys(patch).length === 0) {
2250
- reply(res, 400, { ok: false, error: 'expected "enabled", "sound", "audioEnabled", "audioVolume", "durationSec", "use", "useAudio" or "forget"' })
2401
+ reply(res, 400, { ok: false, error: 'expected "enabled", "sound", "audioEnabled", "audioVolume", "durationSec", "petEnabled", "petSize", "petX", "petY", "use", "useAudio" or "forget"' })
2251
2402
  return
2252
2403
  }
2253
2404
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-startup-animation",
3
- "version": "1.12.7",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "Animated full-screen startup screen shown while DeepSeek Harness boots — click to skip. Ships a built-in MP4/wallpaper/audio library and accepts your own files. 开机动画 / 启动动画:启动 Harness 时显示全屏动画,随包内置视频、壁纸与开机音频,也可以用自己的素材。",
6
6
  "license": "MIT",
@@ -56,7 +56,8 @@
56
56
  "screenshots.json",
57
57
  "LICENSE",
58
58
  "builtin/图片/**/*",
59
- "builtin/音频/**/*"
59
+ "builtin/音频/**/*",
60
+ "builtin/宠物/**/*"
60
61
  ],
61
62
  "scripts": {
62
63
  "test": "node selftest.mjs && node tools/boot-runtime.mjs && node tools/client-test.mjs && node lib/self-manage-test.mjs && node lib/e2e-uninstall.mjs && node tools/boot-css-test.mjs && node tools/host-smoke.mjs",
package/screenshots.json CHANGED
@@ -1,5 +1,4 @@
1
1
  [
2
- "docs/avatar.png",
3
- "docs/library.jpg",
2
+ "docs/library-videos.jpg",
4
3
  "docs/library-wallpapers.jpg"
5
4
  ]
package/docs/library.jpg DELETED
Binary file