dsh-folia 4.8.1 → 4.8.2

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/lib/client.js CHANGED
@@ -778,6 +778,8 @@ window.__ModuleLoader__.load({
778
778
  "Qwen": "Qwen",
779
779
  "Folia": "Folia",
780
780
  "…": "…",
781
+ // 开机动画:已开启但无选片时的提示(4.8.2)
782
+ "已开启,但还没有选片 —— 下载一条后点「使用」": "Enabled, but no clip is selected yet — download one, then press Use",
781
783
  };
782
784
 
783
785
  // ── 内联模块:src/i18n-copy-picker.js —— 英文词表域:壁纸选择器与快捷面板(picker-* · quick-panel · sidebar-right · theme-follow)(构建期注入;勿手改本段)──
@@ -17127,7 +17129,9 @@ window.__ModuleLoader__.load({
17127
17129
 
17128
17130
  async function act(kind, payload, label) {
17129
17131
  if (busy) return;
17130
- setBusy(payload && payload.id ? payload.id : kind);
17132
+ // 只有结构性操作(下载 / 选择 / 删除)才锁 UI;开关与参数是幂等的,锁了反而闪。
17133
+ const structural = kind === "download" || kind === "select" || kind === "remove";
17134
+ if (structural) setBusy(payload && payload.id ? payload.id : kind);
17131
17135
  setTick(0);
17132
17136
  setNote(null);
17133
17137
  try {
@@ -17139,7 +17143,13 @@ window.__ModuleLoader__.load({
17139
17143
  if (kind === "remove") setNote({ ok: true, text: weT("已删除:{name}", { name: label }) });
17140
17144
  if (kind === "toggle") setNote({ ok: true, text: payload.enabled ? weT("开机动画已开启") : weT("开机动画已关闭") });
17141
17145
  if (kind === "settings") setNote({ ok: true, text: weT("已保存") });
17142
- await load(true);
17146
+ // 开关/参数类:宿主把整份 boot 配置回给我们({ ok, ...boot }),直接并进 state,
17147
+ // **不再 load(true) 重拉整表** —— 那次重拉会让所有控件先禁用再恢复,视觉上就是闪一下。
17148
+ if (kind === "toggle" || kind === "settings") {
17149
+ setState((prev) => (prev ? { ...prev, ...r } : prev));
17150
+ } else {
17151
+ await load(true);
17152
+ }
17143
17153
  } else {
17144
17154
  const code = (r && r.error) || "failed";
17145
17155
  setNote({ ok: false, text: r && r.detail ? weT("操作失败:{code}({detail})", { code, detail: String(r.detail) }) : weT("操作失败:{code}", { code }) });
@@ -17172,13 +17182,18 @@ window.__ModuleLoader__.load({
17172
17182
  },
17173
17183
  React.createElement("label", { style: { display: "flex", gap: "6px", alignItems: "center", fontSize: "12px" } },
17174
17184
  React.createElement("input", {
17175
- type: "checkbox", checked: enabled, disabled: Boolean(busy) || !activeId,
17185
+ type: "checkbox", checked: enabled,
17186
+ // 不因"还没选片"而禁用:开关本身是幂等的,宿主在无选片时不会注入任何脚本。
17187
+ // (实测反馈:勾不动,因为此时 activeId 为空 —— 那是把两件事绑在一起了。)
17188
+ disabled: Boolean(busy),
17176
17189
  onChange: (e) => act("toggle", { enabled: e.target.checked }, ""),
17177
17190
  }),
17178
17191
  weT("启用(下次启动播放)"),
17179
17192
  ),
17180
17193
  React.createElement("span", { className: "we-picker__hint" },
17181
- activeId ? weT("当前:{name}", { name: (clips.find((c) => c.id === activeId) || {}).title || activeId }) : weT("未选择")),
17194
+ activeId
17195
+ ? weT("当前:{name}", { name: (clips.find((c) => c.id === activeId) || {}).title || activeId })
17196
+ : (enabled ? weT("已开启,但还没有选片 —— 下载一条后点「使用」") : weT("未选择"))),
17182
17197
  ));
17183
17198
 
17184
17199
  children.push(React.createElement("div", { key: "hint", className: "we-picker__hint", style: { margin: "2px 0 6px", opacity: 0.75 } },
@@ -17189,9 +17204,17 @@ window.__ModuleLoader__.load({
17189
17204
  weT("候选清单读不到:{msg}", { msg: String(state.error) })));
17190
17205
  }
17191
17206
 
17192
- if (note) {
17193
- children.push(React.createElement("div", { key: "note", className: "we-picker__hint", style: { color: note.ok ? "#8ee6a8" : "#ff9a9a", margin: "4px 0 8px" } }, note.text));
17194
- }
17207
+ // 提示行**常驻且占位**:它出现/消失会改变面板高度,视觉上就是"点一下闪一下"。
17208
+ // 固定 minHeight 之后文案变了、布局不动。
17209
+ children.push(React.createElement("div", {
17210
+ key: "note",
17211
+ className: "we-picker__hint",
17212
+ style: {
17213
+ color: note && !note.ok ? "#ff9a9a" : "#8ee6a8",
17214
+ margin: "4px 0 8px", minHeight: "18px", lineHeight: "18px",
17215
+ opacity: note ? 1 : 0, transition: "opacity 120ms linear",
17216
+ },
17217
+ }, note ? note.text : "\u00a0"));
17195
17218
 
17196
17219
  // 淡出 / 停留
17197
17220
  if (state) {
@@ -17348,7 +17371,9 @@ window.__ModuleLoader__.load({
17348
17371
  React.useEffect(() => { load(false); }, [load]);
17349
17372
 
17350
17373
  const act = async (kind, payload, label) => {
17351
- setBusy(kind + ":" + (payload.id || ""));
17374
+ // 只有安装/卸载/导入这类**结构性**操作才锁 UI;开关与大小是幂等的。
17375
+ const structural = kind === "install" || kind === "remove" || kind === "import";
17376
+ if (structural) setBusy(kind + ":" + (payload.id || ""));
17352
17377
  setNote(null);
17353
17378
  try {
17354
17379
  const resp = await apiPostJson("/pets/" + kind, payload || {}, { parse: "always" });
@@ -17358,7 +17383,13 @@ window.__ModuleLoader__.load({
17358
17383
  if (kind === "remove") setNote({ ok: true, text: weT("已卸载:{name}", { name: label || "" }) });
17359
17384
  if (kind === "import") setNote({ ok: true, text: weT("已导入并启用:{name}", { name: label || "" }) });
17360
17385
  if (kind === "config") setNote({ ok: true, text: weT("已保存") });
17361
- await load(true);
17386
+ // config 的响应里带新的 config({ ok, config })⇒ 直接并进 state,
17387
+ // 不再 load(true) 重拉整个清单(那次重拉会让所有按钮先禁用再恢复 = 闪动)。
17388
+ if (kind === "config" && r && r.config) {
17389
+ setState((prev) => (prev ? { ...prev, config: r.config } : prev));
17390
+ } else {
17391
+ await load(true);
17392
+ }
17362
17393
  } else {
17363
17394
  const code = (r && r.error) || "failed";
17364
17395
  setNote({ ok: false, text: weT("操作失败:{code}", { code }) });
@@ -17444,13 +17475,16 @@ window.__ModuleLoader__.load({
17444
17475
  ),
17445
17476
  ));
17446
17477
 
17447
- if (note) {
17448
- children.push(React.createElement("div", {
17449
- key: "note",
17450
- className: "we-picker__hint",
17451
- style: { color: note.ok ? "rgba(120,220,150,0.95)" : "rgba(255,140,140,0.95)", margin: "2px 0 8px" },
17452
- }, note.text));
17453
- }
17478
+ // 与开机动画页签同理:提示行常驻占位,避免开关时整块高度跳动。
17479
+ children.push(React.createElement("div", {
17480
+ key: "note",
17481
+ className: "we-picker__hint",
17482
+ style: {
17483
+ color: note && !note.ok ? "rgba(255,140,140,0.95)" : "rgba(120,220,150,0.95)",
17484
+ margin: "2px 0 8px", minHeight: "18px", lineHeight: "18px",
17485
+ opacity: note ? 1 : 0, transition: "opacity 120ms linear",
17486
+ },
17487
+ }, note ? note.text : "\u00a0"));
17454
17488
 
17455
17489
  const btn = (text, onClick, disabled) => React.createElement("button", {
17456
17490
  type: "button", className: "we-picker__btn", disabled: Boolean(disabled),
@@ -216,15 +216,27 @@ function foliaAbs(base, u) {
216
216
  *
217
217
  * 站点目录的条目长这样:`{ slug, title, kind: 'dynamic', mime: 'video/mp4', url: '/media/...' }` ——
218
218
  * 它没有 `file` 字段,而且 `kind: 'dynamic'` 既可能是视频也可能是有动画的图,所以 mime 最可靠。
219
+ *
220
+ * ⚠️ 实测踩过的坑:WE **工程包**(zip)的 `kind` 也是 `dynamic`、`mime` 是 `application/zip`。
221
+ * 早先的顺序是「kind=dynamic ⇒ video」,于是 56 MB 的 zip 被当成视频塞进 `<video>`,
222
+ * 用户看到的是「浏览器无法解码这段视频」。⇒ **非 image/ 非 video/ 的 mime 一律不猜视频**,
223
+ * 而是判成 `package`(可显示、不可播放,客户端会给出明确原因而不是解码失败)。
219
224
  */
220
225
  function foliaTypeOfEntry(it) {
221
226
  const mime = String((it && it.mime) || '').toLowerCase();
222
227
  if (mime.startsWith('video/')) return 'video';
223
228
  if (mime.startsWith('image/')) return 'image';
229
+ // 已知的"包"形态:工程包 / 压缩包。它们不是可直接播放的媒体。
230
+ if (/^application\/(zip|x-zip|x-zip-compressed)$/.test(mime)) return 'package';
224
231
  const kind = String((it && it.kind) || '').toLowerCase();
232
+ const probe = String((it && (it.file || it.url || it.slug)) || '');
233
+ if (/\.zip$/i.test(probe)) return 'package';
234
+ // `application/octet-stream` 是"不知道是什么的二进制":按后缀与 kind 继续判,
235
+ // 不当成包(上传工具经常把视频标成 octet-stream)。
236
+ // 其余"有 mime 但不是媒体"的(例如 application/json)一律不猜 —— 宁可丢也不塞进播放器。
237
+ if (mime && mime !== 'application/octet-stream') return null;
225
238
  if (kind === 'video' || kind === 'dynamic') return 'video';
226
239
  if (kind === 'image') return 'image';
227
- const probe = String((it && (it.file || it.url || it.slug)) || '');
228
240
  if (/\.(mp4|webm|mov|m4v)$/i.test(probe)) return 'video';
229
241
  if (/\.(jpg|jpeg|png|webp|gif|avif)$/i.test(probe)) return 'image';
230
242
  return null;
@@ -269,9 +281,12 @@ function foliaItemFromCatalog(it, base) {
269
281
  return {
270
282
  id: 'folia-' + (slug || media),
271
283
  title: String((it && it.title) || slug || 'Folia'),
272
- type, // 'video' | 'image'
284
+ type, // 'video' | 'image' | 'package'
273
285
  contentrating: null,
274
- playable: true,
286
+ // 工程包(zip)不是可直接播放的媒体:标成不可播放,客户端据此**不塞进 <video>**,
287
+ // 而是显示「工程包 · 暂不支持」的明确说明(而不是让浏览器报解码失败)。
288
+ playable: type !== 'package',
289
+ unsupportedReason: type === 'package' ? 'we-package' : null,
275
290
  media, // 绝对 URL
276
291
  mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
277
292
  // 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
@@ -472,6 +487,9 @@ function warmInventorySiteCache() {
472
487
  }
473
488
 
474
489
  export { warmInventorySiteCache };
490
+ // 类型判定与条目构造导出**只为测试**(test/inventory-types.mjs):它们是纯函数,
491
+ // 用它做判据比"起一个假站点再走 HTTP"稳定得多 —— 后者要伪造缓存目录与时点。
492
+ export { foliaTypeOfEntry, foliaItemFromCatalog };
475
493
  export function registerInventoryRoutes(webServer, c) {
476
494
  const { disposers, base: BASE, mediaOriginApi, buildInventory, componentsProbe } = c;
477
495
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.8.1",
3
+ "version": "4.8.2",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {