wallpaper-engine-dsh 1.0.3 → 1.0.5

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
@@ -10,17 +10,17 @@ A DSH bundle that turns your local **Wallpaper Engine** library into the live ba
10
10
 
11
11
  **Legibility on any wallpaper.** Text stays readable by construction, not by luck. The veil always opposes the text colour — black in the dark theme, white in the light theme — so neither a pitch-black nor a pure-white wallpaper can swallow the UI. On top of that, a Smart veil samples the wallpaper's brightness every few seconds and automatically strengthens the veil when a scene drifts too bright (or too dark) for the current theme; it also strengthens the border contrast and the glass frosting on the same evidence. Dropdowns, menus, and dialogs sit on DSH's opaque surfaces and are never affected.
12
12
 
13
- **Manual framing.** Pick a base layout — Cover, Contain, Stretch, or Original — then frame the shot yourself. **Adjust** opens the live wallpaper fullscreen: scroll to zoom in (up to 4×), drag to pan within the zoomed framing — the crop is clamped so the image always fills the screen with no exposed edges. Esc or Done finishes, the framing persists per selection, and **Reset crop** restores the default in one click. Web wallpapers can be framed the same way (zoom and pan apply to the iframe); the layout select only means something for video/stills and hides itself for web.
13
+ **Manual framing.** Pick a base layout — Cover, Contain, Stretch, Original, or Free — then frame the shot yourself. **Adjust** opens the live wallpaper fullscreen: scroll to zoom, drag to pan. In the first four layouts the crop is clamped so the image always fills the screen with no exposed edges; **Free** places the wallpaper at natural size on a black canvas where it can be dragged anywhere and zoomed from 0.1× to 4×, with just enough kept on screen that it can never be dragged out of sight. Esc or Done finishes, the framing persists, and **Reset crop** restores the default in one click. Web wallpapers take the same select and the same zoom/pan framing — the transform applies to the iframe directly.
14
14
 
15
- **Four live sliders.** Wallpaper blur, Scrim, Borders, and Glass — every change lands instantly and persists. Blur never re-frames the picture: at any blur level the composition, layout, and crop are exactly what they are at zero blur — the wallpaper's edges are never silently cropped. Within one blur radius of the screen edge the picture fades softly into the veil, hidden under the UI chrome. A whisper of dither noise keeps smooth gradients band-free; and at zero blur the media bypasses the filter pipeline entirely for bit-exact pixels.
15
+ **Four live sliders.** Wallpaper blur, Scrim, Borders, and Glass — every change lands instantly and persists. Blur renders in place: composition, layout, and crop stay identical at every blur level, and within one blur radius of the screen edge the picture fades softly into the veil beneath the UI chrome. A whisper of dither noise keeps smooth gradients band-free; at zero blur the media bypasses the filter pipeline entirely for bit-exact pixels.
16
16
 
17
- **Rotation lists.** Build any number of carousel lists, each with its own wallpapers, interval (1–120 min), and order (sequence or random). Your first playable Wallpaper Engine playlist is imported automatically on first run; any other playlist can be imported into any list.
17
+ **Rotation lists.** Build any number of carousel lists, each with its own wallpapers, interval (1–120 min), and order (sequence or random). Auto-rotate arms once the active list holds at least two usable wallpapers; the status row always reports whether rotation is actually running. Your first playable Wallpaper Engine playlist is imported automatically on first run; any other playlist can be imported into any list.
18
18
 
19
19
  **Resource monitor.** A live FPS readout with a low-framerate hint, plus optional auto-pause when the tab is hidden or the battery runs low. `prefers-reduced-motion` starts videos paused.
20
20
 
21
21
  **Bilingual.** The UI follows the shell's Language setting (Chinese/English) with a manual override in the picker.
22
22
 
23
- **Resilient playback.** Switches and clears crossfade smoothly. A video the browser cannot decode retries once with fresh tokens, then falls back to its preview still with a note in the status row — and the next Refresh retries the live media. Inventory rebuilds re-issue URLs in the background without ever interrupting a playing stream.
23
+ **Resilient playback.** Switches and clears crossfade smoothly. Media that repeatedly fails to decode retries once with fresh tokens, then falls back to the preview still with a note in the status row; an entry with nothing to fall back on is cleared rather than left on a dead layer — and the next Refresh retries the live media. Refresh re-issues every URL and swaps it into the live element in place, so playback position survives. Background inventory rebuilds reuse the same URLs and never touch a playing stream.
24
24
 
25
25
  ## Install
26
26
 
package/README.zh.md CHANGED
@@ -2,25 +2,25 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh.md)
4
4
 
5
- 一个 DSH bundle:把本机 **Wallpaper Engine** 壁纸库变成 DSH web GUI(`dsh web`)的动态背景。视频壁纸在聊天界面背后播放,网页壁纸原地渲染;交叉淡入、液态玻璃面板、随壁纸自适应的可读性遮罩、手动取景、轮播列表、搜索与中英双语界面,全部收拢在一个设置行里。
5
+ 一个 DSH bundle:把本机 **Wallpaper Engine** 壁纸库变成 DSH web GUI(`dsh web`)的动态背景。视频壁纸在聊天界面背后播放,网页壁纸直接渲染。交叉淡入、液态玻璃面板、随壁纸自适应的可读性遮罩、手动取景、轮播列表、搜索与中英双语界面,全部集中在一个设置行里。
6
6
 
7
7
  ## 功能
8
8
 
9
- **只列真正渲染得动的壁纸。** 网格中列出的是浏览器能真实渲染的全部类型——视频与网页壁纸。场景/应用壁纸是 Wallpaper Engine 私有的 `.pkg` 场景包,浏览器无法执行,因此直接从列表中过滤,而不是以降级的静态图充数。列出的每张都真实播放,按 视频/网页 标注类型;搜索框按标题或创意工坊 ID 即时筛选大库。
9
+ **只列出能真实播放的壁纸。** 网格中列出浏览器可以真实渲染的全部类型——视频与网页壁纸。场景/应用壁纸是 Wallpaper Engine 私有的 `.pkg` 场景包,浏览器无法执行,因此直接过滤,不以降级的静态图代替。列出的每张壁纸都真实播放,并按 视频/网页 标注类型;搜索框可按标题或创意工坊 ID 筛选较大的壁纸库。
10
10
 
11
- **任意壁纸都可读。** 文字可读靠的是结构保障,不是运气。遮罩颜色始终与文字颜色相对——深色主题压黑、浅色主题提亮——无论纯黑还是纯白的壁纸都吞不掉界面。在此基础上,"智能可视"每隔几秒采样一次壁纸亮度,画面偏离当前主题的舒适区时自动加强遮罩。下拉菜单、弹窗等浮层坐在 DSH 原生的不透明表面上,从不受影响。
11
+ **任何壁纸上文字都可读。** 遮罩颜色始终与文字颜色相反:深色主题用黑色遮罩,浅色主题用白色遮罩,因此过亮或过暗的壁纸都不会盖住文字。在此基础上,「智能可视」每隔几秒采样一次壁纸亮度,亮度过高或过低时自动加强遮罩。下拉菜单、弹窗等浮层显示在 DSH 原生的不透明表面上,不受影响。
12
12
 
13
- **手动取景。** 先选基础排布——填充、适应、拉伸、原始——然后自己构图:点「调整画面」把壁纸全屏展开,滚轮放大(最高 4×)、拖动在放大的画面内平移——裁剪范围经过钳制,画面永远铺满屏幕、不露边。Esc 或「完成」退出,取景随选择持久保存,「重置裁剪」一键还原。网页壁纸同样可以取景(缩放与平移对 iframe 生效);排布下拉只对视频/静态图有意义,网页壁纸自动隐藏。
13
+ **手动取景。** 先选基础排布(填充、适应、拉伸、原始、自由),再手动调整画面:点「调整画面」将壁纸全屏展开,滚轮缩放,拖动平移。前四种排布下裁剪范围经过限制,画面始终铺满屏幕,不露出边缘;「自由」把壁纸按原始大小放在黑色画布上,可拖动到任意位置,缩放范围 0.1–4×,并始终保留一小块在屏幕内,不会拖丢。按 Esc 或点「完成」退出,取景设置会保存,「重置裁剪」可一键恢复默认。网页壁纸使用相同的排布下拉与缩放/平移取景(变换直接作用于 iframe)。
14
14
 
15
- **四个实时滑杆。** 壁纸模糊、暗化、边框、玻璃——每一步调节即时生效并持久保存。模糊从不改变取景:任意模糊度下画面的比例、排布与取景和模糊为零时完全一致,绝不偷偷裁掉壁纸四边;模糊半径内的屏幕边缘向遮罩自然过渡,被界面边饰覆盖,肉眼不可察。叠加微量抖动噪声消除渐变色带;模糊归零时媒体完全绕开滤镜管线,像素逐位精确。
15
+ **四个实时滑杆。** 壁纸模糊、暗化、边框、玻璃,每一步调节立即生效并保存。模糊在原位渲染:任意模糊度下,画面的比例、排布与取景完全一致;屏幕边缘一个模糊半径内的画面向遮罩过渡,被界面边饰覆盖,通常不可见。模糊时叠加轻微噪声,消除渐变色带;模糊为零时媒体完全绕过滤镜管线,像素保持不变。
16
16
 
17
- **轮播列表。** 可建任意多个列表,各自拥有壁纸集合、切换间隔(1–120 分钟)和顺序(顺序/随机)。首次运行自动导入第一个可播放的 Wallpaper Engine 播放列表,其余列表可随时导入到任意列表。
17
+ **轮播列表。** 可以创建任意多个列表,各自指定壁纸集合、切换间隔(1–120 分钟)与顺序(顺序/随机)。当前列表至少有两张可用壁纸时才会自动轮转;状态行始终显示轮转是否正在运行。首次运行会自动导入第一个可播放的 Wallpaper Engine 播放列表,其他播放列表也可随时导入到任意列表。
18
18
 
19
19
  **资源监控。** 实时帧率读数与低帧率提示,页面隐藏或电量不足时可选自动暂停。系统开启 `prefers-reduced-motion` 时视频默认暂停。
20
20
 
21
- **中英双语。** 界面跟随 DSH 的"语言"设置(中/英),面板内亦可手动指定。
21
+ **中英双语。** 界面跟随 DSH 的「语言」设置(中/英),也可在面板中手动指定。
22
22
 
23
- **播放有韧性。** 切换与关闭都平滑淡入淡出。浏览器解不了的视频先换新令牌重试一次,仍失败则退到预览图并在状态行注明——下次「刷新」自动重试原媒体。后台重建清单时静默更换 URL,绝不掐断正在播放的流。
23
+ **播放出错自动恢复。** 切换与关闭均以淡入淡出过渡。媒体连续解码失败时,先使用新令牌重试一次;仍失败则改用预览图,并在状态行说明;没有预览可用的条目则清空选择,避免黑屏。下次「刷新」会重新尝试原媒体。刷新会更新全部 URL,并直接替换正在播放的元素的地址,播放进度保留。后台重建清单时复用相同 URL,不会中断正在播放的流。
24
24
 
25
25
  ## 安装
26
26
 
@@ -40,8 +40,8 @@ dsh plugin --profile web add link:<克隆文件夹的绝对路径>
40
40
  ## 使用
41
41
 
42
42
  1. 从缩略图网格选择壁纸,它会在界面背后淡入。壁纸库较大时用搜索框快速定位。
43
- 2. **暂停/播放** 控制视频播放;**关闭** 淡出壁纸;**刷新** 重新扫描壁纸库——新订阅的创意工坊壁纸无需重载页面即可出现,已降级的壁纸也会重试原媒体。
44
- 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;开启智能可视后这些自动完成。界面跟随 DSH 明暗主题,无需手动适配。
43
+ 2. **暂停/播放** 控制视频播放;**关闭** 淡出壁纸;**刷新** 重新扫描壁纸库——新订阅的创意工坊壁纸无需重载页面即可出现,已改用预览图的壁纸也会重新尝试播放原媒体。
44
+ 3. 用滑杆调节融合效果。壁纸画面复杂时调高 **暗化** 和 **边框** 直到文字清晰;开启智能可视后这些自动完成。界面跟随 DSH 明暗主题,无需手动适配。
45
45
  4. **排布** 一行决定基础摆放,点「调整画面」在画布上直接拖动平移、滚轮缩放取景。
46
46
  5. 用 **新建** 创建轮播列表,从网格挑选或导入 WE 播放列表,然后开启 **自动轮转**。
47
47
  6. 资源行显示当前帧率与两个自动暂停开关。
@@ -56,9 +56,9 @@ bundle 分为两半:
56
56
  - `GET /we-background/inventory[?refresh=1]`
57
57
  - `GET /we-background/media/<token>[/<入口文件或子资源>…]` —— 支持 HTTP Range,视频可拖动进度。网页壁纸的 URL 直接定位到入口文件(如 `…/media/<token>/index.html`),文档 URL 与壁纸目录结构一一对应,壁纸内部的相对引用(脚本、样式、图片、fetch)按 Wallpaper Engine 自身的规则解析;媒体响应带 `Access-Control-Allow-Origin: *`,沙箱 iframe(不透明源)里的壁纸也能 fetch 自己的资源
58
58
  - `GET /we-background/preview/<token>`
59
- - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。
59
+ - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读取 DSH 设计令牌,主题切换时自动适配。
60
60
 
61
- 媒体服务在每一层都有防御:令牌是不可猜测的 72 位随机值,跨清单重建保持稳定(后台刷新绝不掐断播放中的流),仅在显式刷新时重铸;子资源路径先做词法包含检查,再对照真实文件系统复核,符号链接逃不出壁纸目录;HTML 与 SVG 响应带 `sandbox` CSP,永远无法以 DSH 源的身份执行;媒体响应的 CORS 通配只让"知道令牌的人读到本来就能请求的文件",能力边界仍是令牌本身,且整个服务只监听回环;会短斤缺两的响应自我销毁,而不是悄悄截断。清单由宿主缓存 30 秒,安装发现在请求路径之外异步解析并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
61
+ 媒体服务在多个层面做了防护:令牌是无法猜测的 72 位随机值,清单重建时保持不变(后台刷新不会中断正在播放的流),只在显式刷新时重新生成;子资源路径先做词法包含检查,再对照真实文件系统复核,符号链接无法指向壁纸目录之外;HTML 与 SVG 响应带 `sandbox` CSP,不能以 DSH 源的身份执行;媒体响应的 CORS 通配只允许持有令牌的一方读取其本来就能请求的文件,访问权限仍由令牌决定,且整个服务只监听本机回环地址;长度不足的响应会直接中断,而不是悄悄截断。清单由宿主缓存 30 秒;安装位置的发现过程在请求路径之外异步执行,并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
62
62
 
63
63
  ## 限制
64
64
 
package/lib/client.js CHANGED
@@ -91,6 +91,7 @@ window.__ModuleLoader__.load({
91
91
  fitContain: "适应",
92
92
  fitFill: "拉伸",
93
93
  fitNone: "原始",
94
+ fitFree: "自由",
94
95
  adjust: "调整画面",
95
96
  adjustDone: "完成",
96
97
  adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
@@ -168,6 +169,7 @@ window.__ModuleLoader__.load({
168
169
  fitContain: "Contain",
169
170
  fitFill: "Stretch",
170
171
  fitNone: "Original",
172
+ fitFree: "Free",
171
173
  adjust: "Adjust",
172
174
  adjustDone: "Done",
173
175
  adjustHint: "Drag to pan · wheel to zoom · Esc or Done to exit",
@@ -226,7 +228,7 @@ window.__ModuleLoader__.load({
226
228
  offsetY: 0,
227
229
  };
228
230
 
229
- const FIT_MODES = ["cover", "contain", "fill", "none"];
231
+ const FIT_MODES = ["cover", "contain", "fill", "none", "free"];
230
232
 
231
233
  // ── Persisted selection ─────────────────────────────────────────────────────
232
234
  function clampNum(v, lo, hi, fallback) {
@@ -273,11 +275,12 @@ window.__ModuleLoader__.load({
273
275
  autoScrim: o.autoScrim !== false,
274
276
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
275
277
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
276
- // Older builds could persist zoom < 1 / unclamped pans — clampCrop()
277
- // at store creation re-seats both into the gap-free envelope.
278
- zoom: clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom),
279
- offsetX: clampNum(o.offsetX, -200, 200, DEFAULTS.offsetX),
280
- offsetY: clampNum(o.offsetY, -200, 200, DEFAULTS.offsetY),
278
+ // Older builds could persist unclamped crops — clampCrop() at store
279
+ // creation re-seats both into the envelope the CURRENT fit mode
280
+ // allows (free: zoom ≥ 0.1 with keep-visible pans; others: gap-free).
281
+ zoom: clampNum(o.zoom, 0.1, 4, DEFAULTS.zoom),
282
+ offsetX: clampNum(o.offsetX, -300, 300, DEFAULTS.offsetX),
283
+ offsetY: clampNum(o.offsetY, -300, 300, DEFAULTS.offsetY),
281
284
  };
282
285
  } catch {
283
286
  return { id: "", ...DEFAULTS };
@@ -302,8 +305,8 @@ window.__ModuleLoader__.load({
302
305
  inventory: { installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [], error: null },
303
306
  loaded: false,
304
307
  };
305
- // Older builds allowed zoom < 1 and unclamped pans — re-seat any persisted
306
- // crop into the gap-free envelope before the first render.
308
+ // Re-seat any persisted crop into the envelope the persisted fit mode
309
+ // allows before the first render.
307
310
  clampCrop();
308
311
 
309
312
  const listeners = new Set();
@@ -403,16 +406,15 @@ window.__ModuleLoader__.load({
403
406
  selection.rotationGroupId = "";
404
407
  persistSelection();
405
408
  }
406
- if (selection.rotationEnabled) {
407
- if (!selection.rotationGroupId) {
408
- const usable = firstUsableGroup();
409
- if (usable) selection.rotationGroupId = usable.id;
410
- else selection.rotationEnabled = false;
411
- } else if (rotationCandidates().length < 2) {
412
- const usable = firstUsableGroup();
413
- if (usable && usable.id !== selection.rotationGroupId) selection.rotationGroupId = usable.id;
414
- else if (!usable) selection.rotationEnabled = false;
415
- }
409
+ // Rotation bookkeeping. A chosen list is never migrated away from — a
410
+ // list with fewer than 2 usable wallpapers simply keeps rotation
411
+ // disarmed (the timer needs a pair to alternate), and a hand-picked
412
+ // wallpaper outside the active list keeps playing until the next
413
+ // rotation tick brings the list back around.
414
+ if (selection.rotationEnabled && !selection.rotationGroupId) {
415
+ const usable = firstUsableGroup();
416
+ if (usable) selection.rotationGroupId = usable.id;
417
+ else selection.rotationEnabled = false;
416
418
  persistSelection();
417
419
  }
418
420
 
@@ -421,10 +423,6 @@ window.__ModuleLoader__.load({
421
423
  selection.id = "";
422
424
  persistSelection();
423
425
  }
424
- if (selection.rotationEnabled && selection.id && !rotationCandidates().some((w) => w.id === selection.id)) {
425
- selection.id = "";
426
- persistSelection();
427
- }
428
426
  if (!selection.id && selection.rotationEnabled) {
429
427
  const first = rotationCandidates()[0];
430
428
  if (first) selection.id = first.id;
@@ -720,9 +718,10 @@ window.__ModuleLoader__.load({
720
718
  // The media itself is unusable here — fall back to the still preview.
721
719
  demotedToPreview.add(id);
722
720
  applySelection(id);
723
- } else if (!w || (!w.preview && !w.media)) {
724
- // Nothing renderable at all (preview-less or vanished from the
725
- // inventory): clear the selection instead of a black screen loop.
721
+ } else {
722
+ // Unrenderable here with no preview to fall back on (or the entry has
723
+ // vanished from the inventory entirely): clear the selection rather
724
+ // than leave a dead element on the layer.
726
725
  applySelection("");
727
726
  }
728
727
  });
@@ -733,17 +732,33 @@ window.__ModuleLoader__.load({
733
732
  // never receive pointer events. Adjust mode moves the LIVE layer node into a
734
733
  // topmost transparent overlay — the user edits the real thing, WYSIWYG.
735
734
  //
736
- // Crop geometry invariants: the media box covers the viewport exactly at
737
- // zoom 1; zooming creates headroom, panning spends it. Zoom floors at 1
738
- // and pan is clamped to ±(zoom-1)/2·100 %, so a gap-free fullscreen image
739
- // is guaranteed at every setting.
735
+ // Crop geometry is chosen by the fit mode. The four object-fit modes keep a
736
+ // GAP-FREE invariant: the media box covers the viewport exactly at zoom 1,
737
+ // zoom floors at 1, and pan is clamped to ±(zoom−1)/2·100 % — the image
738
+ // always fills the screen. The "free" mode drops that invariant on purpose:
739
+ // the media sits at natural size on a black canvas, zoom runs 0.1–4×, and
740
+ // pan is only clamped to keep a sliver (≥10 % of the box) on screen so the
741
+ // wallpaper can never be dragged entirely out of sight.
740
742
  function clampCrop() {
741
- selection.zoom = Math.round(Math.min(4, Math.max(1, selection.zoom)) * 100) / 100;
743
+ selection.zoom = Math.round(Math.min(4, Math.max(isFreeFit() ? 0.1 : 1, selection.zoom)) * 100) / 100;
744
+ if (isFreeFit()) {
745
+ // Content spans ±zoom·50 % of the box around its centre; the pan offset
746
+ // shifts it by its own % of the box. |offset| ≤ (zoom+1)·50 − 10 keeps
747
+ // at least 10 % of the media edge-to-edge visible in both axes.
748
+ const reach = (selection.zoom + 1) * 50 - 10;
749
+ selection.offsetX = Math.min(reach, Math.max(-reach, selection.offsetX));
750
+ selection.offsetY = Math.min(reach, Math.max(-reach, selection.offsetY));
751
+ return;
752
+ }
742
753
  const pan = (selection.zoom - 1) * 50;
743
754
  selection.offsetX = Math.min(pan, Math.max(-pan, selection.offsetX));
744
755
  selection.offsetY = Math.min(pan, Math.max(-pan, selection.offsetY));
745
756
  }
746
757
 
758
+ function isFreeFit() {
759
+ return selection.fit === "free";
760
+ }
761
+
747
762
  let adjustOverlay = null;
748
763
  let adjustCleanup = null;
749
764
 
@@ -801,7 +816,7 @@ window.__ModuleLoader__.load({
801
816
  const h = overlay.clientHeight || 1;
802
817
  selection.offsetX += ((e.clientX - drag.x) / w) * 100;
803
818
  selection.offsetY += ((e.clientY - drag.y) / h) * 100;
804
- clampCrop(); // gap-free guarantee: pan never outruns the zoom headroom
819
+ clampCrop(); // mode envelope: gap-free pan headroom, or free keep-visible
805
820
  drag = { x: e.clientX, y: e.clientY };
806
821
  applyEffects();
807
822
  };
@@ -819,10 +834,10 @@ window.__ModuleLoader__.load({
819
834
  };
820
835
  const onWheel = (e) => {
821
836
  if (typeof e.preventDefault === "function") e.preventDefault();
822
- // Crop semantics: zoom IN only (1–4×). Zooming below cover would float a
823
- // shrunken box in empty space — that is not a crop. Tightening zoom also
824
- // re-clamps the pan into the new headroom, so zooming back to 1 pulls
825
- // the framing home instead of leaving edge gaps.
837
+ // clampCrop enforces the mode's zoom floor: the gap-free modes never
838
+ // zoom below 1 (a shrunken box floating in empty space is not a crop)
839
+ // and tightening zoom re-clamps the pan so zooming back to 1 pulls the
840
+ // framing home; free mode shrinks down to 0.1× on its black canvas.
826
841
  selection.zoom *= Math.exp(-(e.deltaY || 0) * 0.0012);
827
842
  clampCrop();
828
843
  applyEffects();
@@ -876,6 +891,30 @@ window.__ModuleLoader__.load({
876
891
  return token;
877
892
  }
878
893
 
894
+ /**
895
+ * Swap re-minted URLs into the LIVE media element instead of remounting.
896
+ * An explicit refresh re-mints every token, so the element's old URL is
897
+ * already dead — the next seek or buffer-miss would 404 on it and trigger
898
+ * a full recovery restart. Hot-swapping keeps the element identity (and
899
+ * for video, the playback position) intact across the token change.
900
+ */
901
+ function refreshLiveMediaUrls(layer) {
902
+ const video = layer.querySelector("video");
903
+ if (video && video.getAttribute("src") !== selection.url) {
904
+ const at = video.currentTime;
905
+ video.src = selection.url;
906
+ if (at > 0) {
907
+ video.addEventListener("loadedmetadata", () => {
908
+ try { video.currentTime = at; } catch { /* not seekable yet */ }
909
+ }, { once: true });
910
+ }
911
+ }
912
+ const img = layer.querySelector("img");
913
+ if (img && img.getAttribute("src") !== selection.url) img.src = selection.url;
914
+ const frame = layer.querySelector("iframe");
915
+ if (frame && frame.getAttribute("src") !== selection.url) frame.src = selection.url;
916
+ }
917
+
879
918
  function syncLayers() {
880
919
  const existing = document.getElementById(LAYER_ID);
881
920
 
@@ -887,7 +926,9 @@ window.__ModuleLoader__.load({
887
926
  const gotKey = existing && existing.dataset.webgKey;
888
927
 
889
928
  if (existing && gotKey === wantKey && !forceRemount) {
890
- // Same wallpaper — only play/pause may have changed.
929
+ // Same wallpaper — adopt any re-minted URL, then only play/pause may
930
+ // have changed.
931
+ refreshLiveMediaUrls(existing);
891
932
  } else {
892
933
  forceRemount = false;
893
934
  // Crossfade: the new layer fades in over the old one; the old layer
@@ -1022,8 +1063,11 @@ window.__ModuleLoader__.load({
1022
1063
  selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
1023
1064
  // Canvas fit: object-fit base mode plus the user's manual crop — pan
1024
1065
  // offsets (percent of the media box) and zoom, applied as a transform so
1025
- // no remount is ever needed while dragging.
1026
- s.setProperty("--webg-fit", selection.fit);
1066
+ // no remount is ever needed while dragging. "free" draws at natural size
1067
+ // (object-fit: none) and paints the layer black, so exposed edges read as
1068
+ // a canvas instead of whatever sits behind the app frame.
1069
+ s.setProperty("--webg-fit", selection.fit === "free" ? "none" : selection.fit);
1070
+ s.setProperty("--webg-layer-bg", selection.fit === "free" ? "#000" : "transparent");
1027
1071
  s.setProperty("--webg-zoom", String(selection.zoom));
1028
1072
  s.setProperty("--webg-offset-x", selection.offsetX + "%");
1029
1073
  s.setProperty("--webg-offset-y", selection.offsetY + "%");
@@ -1049,6 +1093,7 @@ window.__ModuleLoader__.load({
1049
1093
  s.removeProperty("--webg-noise-display");
1050
1094
  s.removeProperty("--webg-noise-opacity");
1051
1095
  s.removeProperty("--webg-fit");
1096
+ s.removeProperty("--webg-layer-bg");
1052
1097
  s.removeProperty("--webg-zoom");
1053
1098
  s.removeProperty("--webg-offset-x");
1054
1099
  s.removeProperty("--webg-offset-y");
@@ -1246,6 +1291,10 @@ window.__ModuleLoader__.load({
1246
1291
 
1247
1292
  function onFit(e) {
1248
1293
  selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
1294
+ // Re-seat the crop into the new mode's envelope: leaving free for a
1295
+ // gap-free mode pulls zoom back up to 1 and reins the pan in; the other
1296
+ // direction only ever relaxes bounds, so the framing carries over.
1297
+ clampCrop();
1249
1298
  persistSelection();
1250
1299
  applyEffects();
1251
1300
  emit();
@@ -1474,19 +1523,20 @@ window.__ModuleLoader__.load({
1474
1523
 
1475
1524
  function EffectsPanel(t, sel) {
1476
1525
  return React.createElement(React.Fragment, null,
1477
- // object-fit only means something for video/still wallpapers — an
1478
- // iframe ignores it and always fills its layer — so the base-mode
1479
- // select is hidden for web. The manual crop (drag / wheel in the
1480
- // adjust overlay) is a plain CSS transform and applies to EVERY kind,
1481
- // so 调整画面 / 重置裁剪 stay available even for iframes.
1526
+ // The fit select covers all wallpaper types: for video/stills it picks
1527
+ // the object-fit base mode, and "free" additionally unlocks shrink +
1528
+ // off-edge dragging for every kind — an iframe ignores object-fit, but
1529
+ // the zoom/pan transform applies to it the same way. The manual crop
1530
+ // (drag / wheel in the adjust overlay) is a plain CSS transform and
1531
+ // applies to EVERY kind, so 调整画面 / 重置裁剪 are always available.
1482
1532
  React.createElement("div", { className: "webg-row webg-fit-row" },
1483
- sel.type !== "web" && React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1484
- sel.type !== "web" && React.createElement("select", {
1533
+ React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1534
+ React.createElement("select", {
1485
1535
  className: "webg-select webg-fit-select",
1486
1536
  value: sel.fit,
1487
1537
  onChange: onFit,
1488
1538
  },
1489
- ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1539
+ ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone, t.fitFree].map((label, i) =>
1490
1540
  React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1491
1541
  )),
1492
1542
  React.createElement("button", {
@@ -1537,13 +1587,17 @@ window.__ModuleLoader__.load({
1537
1587
  }
1538
1588
 
1539
1589
  function StatusRow(t, sel, group, usableCount, usableTotal) {
1590
+ // "Auto-rotating" is claimed only when the timer is genuinely armed —
1591
+ // rotation may be enabled on a list with fewer than 2 usable wallpapers,
1592
+ // in which case nothing switches until the list grows.
1593
+ const rotating = sel.rotationEnabled && usableCount >= 2;
1540
1594
  return React.createElement("div", { className: "webg-row" },
1541
1595
  React.createElement("span", { className: "webg-hint" },
1542
1596
  (group
1543
1597
  ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1544
1598
  group.order === "random" ? t.random : t.sequence)
1545
1599
  : t.statusUsable(usableTotal)) +
1546
- (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1600
+ (rotating ? " · " + t.autoRotating : "") +
1547
1601
  (sel.autoPauseReasons.size > 0 ? " · " + t.autoPaused : "") +
1548
1602
  (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1549
1603
  (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
@@ -1597,10 +1651,13 @@ window.__ModuleLoader__.load({
1597
1651
  // Everything reads DSH design tokens (--dsw-*) so the UI blends into the
1598
1652
  // shell and follows light/dark theme switches automatically.
1599
1653
  const CSS = `
1600
- /* Wallpaper layers: fixed, sunk below the app frame, crossfading. */
1654
+ /* Wallpaper layers: fixed, sunk below the app frame, crossfading. The
1655
+ background paints black only in the "free" fit mode, where the media
1656
+ can be dragged off the edges and the exposed area reads as a canvas. */
1601
1657
  .webg-layer {
1602
1658
  position: fixed; inset: 0; z-index: -2; overflow: hidden;
1603
1659
  pointer-events: none; opacity: 1;
1660
+ background: var(--webg-layer-bg, transparent);
1604
1661
  transition: opacity ${CROSSFADE_MS}ms ease;
1605
1662
  }
1606
1663
  .webg-layer--enter { opacity: 0; }
package/lib/index.js CHANGED
@@ -175,15 +175,20 @@ export function createRouteRegistrar(webServer, opts = {}) {
175
175
  const fresh = discoveryCache && Date.now() - discoveryCache.at < discoveryTtlMs;
176
176
  if (!force && fresh) return discoveryCache.value;
177
177
  // Concurrent callers (registration warm-up + first request) share ONE
178
- // discovery run instead of racing reg.exe twice at cold start.
178
+ // discovery run instead of racing reg.exe twice at cold start. A forced
179
+ // run deliberately does not join an in-flight non-forced one; either way,
180
+ // a run only releases the slot it still owns — clearing it unconditionally
181
+ // would kill another run's join promise, never clearing it would pin the
182
+ // slot to a settled promise and freeze TTL re-discovery forever.
179
183
  if (discovering && !force) return discovering;
180
- discovering = discoverFn()
184
+ const run = discoverFn()
181
185
  .then((value) => {
182
186
  discoveryCache = { at: Date.now(), value };
183
187
  return value;
184
188
  })
185
- .finally(() => { if (!force) discovering = null; });
186
- return discovering;
189
+ .finally(() => { if (discovering === run) discovering = null; });
190
+ discovering = run;
191
+ return run;
187
192
  }
188
193
 
189
194
  async function buildInventory(force) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Wallpaper Engine library as the DSH web GUI background — video/web/still wallpapers, rotation lists, search, resource monitor, bilingual UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",