dsh-mpkg-wallpaper 3.1.4 → 3.1.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.en.md CHANGED
@@ -64,7 +64,7 @@ The plugin offers two partial solutions (chosen automatically by scene content):
64
64
  1. **Static-frame extraction**: parses `scene.pkg` (PKG container + LZ4 + TEX decode) and picks the main texture → **high-resolution static image** (photography/illustration scenes near-original quality; tested up to 7680×4320)
65
65
  2. **Layer compositing**: parses all `image` layers in `scene.json` (background + subject + layered character parts) and draws them **accurately composited** on a canvas using the source coordinates/sizes; time-variation scenes pick the frame for the current hour
66
66
 
67
- **Not covered**: MDL puppet characters (Kal'sit, Blue Archive characters, etc. — the body is assembled from rig parts; the flat textures are nearly empty, e.g. `girl.tex` is 0.1% opaque), shader wave/particle effects, scripted interactions. These fall back to the **official preview animation** (preview.gif).
67
+ **Not covered**: MDL puppet characters (the body is assembled from rig parts; the flat textures are nearly empty), shader wave/particle effects, scripted interactions. These fall back to the **official preview animation** (preview.gif).
68
68
 
69
69
  > For full dynamic scenes, the practical path: render externally to video → use the plugin's **video wallpaper** (Windows: official WE app screen-record; Linux: we-layerd; mobile: WE app screen-record).
70
70
 
package/README.md CHANGED
@@ -64,7 +64,7 @@
64
64
  1. **静态帧提取**:解析 `scene.pkg`(PKG 容器 + LZ4 解压 + TEX 纹理解码),从场景图选取主纹理输出**高清静态图**(摄影/插画类场景可达原图画质,实测 7680×4320)
65
65
  2. **图层合成**:解析 `scene.json` 的全部 image 图层(背景 + 主体 + 分层角色部件),按源文件坐标/尺寸在 canvas 上**精确合成完整画面**(平铺图层类场景可完整还原构图;时间变化场景按当前时段选帧)
66
66
 
67
- **无法覆盖的**:MDL 木偶人物(凯尔希、碧蓝档案等角色的身体由骨架拼装,纹理层几乎为空——如 `girl.tex` 仅 0.1% 不透明像素)、shader 波浪/粒子特效、脚本交互。这些场景回退**官方预览动图**(preview.gif,作者生成的动画预览)。
67
+ **无法覆盖的**:MDL 木偶人物(角色的身体由骨架拼装,纹理层几乎为空)、shader 波浪/粒子特效、脚本交互。这些场景回退**官方预览动图**(preview.gif,作者生成的动画预览)。
68
68
 
69
69
  > 如果你需要场景壁纸的完整动态效果,现实路径:外部渲染成视频 → 用本插件的**视频壁纸**功能(Windows 用 WE 官方版录屏、Linux 用 we-layerd 录屏、移动端用壁纸引擎 App 录屏)。
70
70
 
package/lib/client.js CHANGED
@@ -6,6 +6,11 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
 
8
8
  let react = require("react");
9
+ // ①(修正) react-dom portal:弹窗遮罩提到 document.body。
10
+ // 设置面板/backdrop-filter 容器会创建包含块/backdrop root,把 fixed 遮罩
11
+ // 困在面板内 → 遮罩只盖住右侧设置区(用户实测);portal 后盖全屏且模糊全屏。
12
+ let ReactDOM = null;
13
+ try { ReactDOM = require("react-dom"); } catch {}
9
14
 
10
15
 
11
16
 
@@ -62,6 +67,8 @@ window.__ModuleLoader__.load({
62
67
  // ⑲(新) 面板取色强度(壁纸色混合比例 0-100%,默认 45——用户实测默认太淡看不出变化)
63
68
  const DEFAULT_AQUA_TINT_STRENGTH = 45;
64
69
  const DEFAULT_ROTATE = false; // ③(新) 壁纸轮换:定时自动切换下一个壁纸
70
+ const DEFAULT_PLAYBACK_RATE = 1; // ⑳(新) 视频倍速(0.5-2x,原生 playbackRate)
71
+ const SPEED_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2]; // ⑳(新) 倍速档位
65
72
  const DEFAULT_BRIGHTNESS = 100; // ⑧(新) 画面亮度(50-150%,滤镜)
66
73
  const DEFAULT_FLOAT = false; // ①(新) 悬浮效果:侧边栏/标题栏悬浮卡片(默认关)
67
74
  const DEFAULT_ROUND_COMPAT = false; // ②(新) 第三方插件 UI 圆角兼容开关(默认关,未完全兼容暂不打扰)
@@ -649,6 +656,11 @@ window.__ModuleLoader__.load({
649
656
  if (lastObjectUrl && lastObjectUrl !== url) { try { URL.revokeObjectURL(lastObjectUrl); } catch {} }
650
657
  lastObjectUrl = url;
651
658
  try { if (video.src !== url) video.src = url; } catch {}
659
+ // ⑳(新) 视频倍速:原生 playbackRate(0.5-2x,即时生效,不重载)
660
+ try {
661
+ const rate = readSection().playbackRate;
662
+ if (typeof rate === "number" && rate >= 0.5 && rate <= 2 && video.playbackRate !== rate) video.playbackRate = rate;
663
+ } catch {}
652
664
  video.style.display = "";
653
665
  try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {}
654
666
  }
@@ -951,6 +963,12 @@ window.__ModuleLoader__.load({
951
963
  try {
952
964
  if (aquaOn(section)) document.body.setAttribute("data-mpw-aqua", "");
953
965
  else document.body.removeAttribute("data-mpw-aqua");
966
+ // ①(修正) 标题栏跟随 mask 色只在统一雾/面板取色时开;纯自适应文字色
967
+ // 不刷标题栏底色(否则亮色主题下标题栏变 62% 白 → session log 白框)
968
+ const headerTint = (section.aquaMask !== void 0 ? !!section.aquaMask : DEFAULT_AQUA_MASK)
969
+ || (section.aquaTint !== void 0 ? !!section.aquaTint : DEFAULT_AQUA_TINT);
970
+ if (headerTint) document.body.setAttribute("data-mpw-aqua-header", "");
971
+ else document.body.removeAttribute("data-mpw-aqua-header");
954
972
  if (section.aquaTextEnhance !== void 0 ? !!section.aquaTextEnhance : DEFAULT_AQUA_TEXT_ENHANCE)
955
973
  document.body.setAttribute("data-mpw-aqua-text", "");
956
974
  else document.body.removeAttribute("data-mpw-aqua-text");
@@ -1080,7 +1098,11 @@ window.__ModuleLoader__.load({
1080
1098
  root.style.setProperty("--mpw-aqua-brand-contrast", aquaInkContrast(aquaBrandColor(s)));
1081
1099
  const maskOn = s.aquaMask !== void 0 ? !!s.aquaMask : DEFAULT_AQUA_MASK;
1082
1100
  if (maskOn) {
1083
- const panel = Math.max(0, Math.min(100, s.aquaMaskAlpha !== void 0 ? s.aquaMaskAlpha : DEFAULT_AQUA_MASK_ALPHA)) / 100;
1101
+ let panel = Math.max(0, Math.min(100, s.aquaMaskAlpha !== void 0 ? s.aquaMaskAlpha : DEFAULT_AQUA_MASK_ALPHA)) / 100;
1102
+ // ①(修正) 亮色遮罩防过曝:自定义色偏亮(luma>0.6)时自动把强度
1103
+ // 压到 ≤55%,避免全屏亮色雾把画面洗白(用户实测:预置色 1/5 变白)
1104
+ const luma = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
1105
+ if (luma > 0.6) panel = Math.min(panel, 0.55);
1084
1106
  el.style.background = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${panel})`;
1085
1107
  el.style.display = "";
1086
1108
  } else {
@@ -1618,6 +1640,25 @@ ${frostedLayers}
1618
1640
  body:not([data-ds-dark-theme]) .wSkVaW_header {
1619
1641
  background-color: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) ${hdrAlpha}%, transparent) !important;
1620
1642
  }
1643
+ `;
1644
+ // ①(修正) 标题栏内部白框:unify(透明标题栏)时 session logo 按钮 /
1645
+ // 「标准模式」标签等自带实心底的盒子仍显示为白色块(用户两次反馈),
1646
+ // 全部改透明,保留 hover 交互态。
1647
+ css += `
1648
+ .wSkVaW_header button,
1649
+ .wSkVaW_header [class*="button"],
1650
+ .wSkVaW_header [class*="logo"],
1651
+ .wSkVaW_header [class*="mode"],
1652
+ .wSkVaW_header [class*="badge"],
1653
+ .wSkVaW_header [class*="segmented"] {
1654
+ background: transparent !important;
1655
+ box-shadow: none !important;
1656
+ }
1657
+ .wSkVaW_header button:hover,
1658
+ .wSkVaW_header [class*="button"]:hover,
1659
+ .wSkVaW_header [class*="segmented"] button:hover {
1660
+ background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 70%, transparent) !important;
1661
+ }
1621
1662
  `;
1622
1663
  }
1623
1664
  if (headerBg && !headerBlur) {
@@ -1975,11 +2016,18 @@ body[data-mpw-aqua] {
1975
2016
  --dsw-alias-brand-text: var(--mpw-aqua-ink, inherit);
1976
2017
  --dsw-alias-state-business-primary: var(--mpw-aqua-ink-secondary, inherit);
1977
2018
  }
1978
- /* ①(修正) 面板取色/统一雾时标题栏背景跟随 mask 色(用户实测只改侧边栏不改标题栏) */
1979
- body[data-mpw-aqua] .wSkVaW_header {
2019
+ /* ①(修正) 面板取色/统一雾时标题栏背景跟随 mask 色(用户实测只改侧边栏不改标题栏)。
2020
+ ①(修正2) 标题栏是否刷 mask 色由 JS 打 data-mpw-aqua-header 门控:
2021
+ 纯 aquaInk(自适应文字色)时不刷——此时遮罩色=主题中性色(亮色主题=白),
2022
+ 会把标题栏刷成 62% 白 → session log/标准模式背后的「白框」(用户实测:
2023
+ 统一虚化 + 自适应文字色同时开出现)。 */
2024
+ body[data-mpw-aqua][data-mpw-aqua-header] .wSkVaW_header {
1980
2025
  background-color: color-mix(in srgb, rgb(var(--mpw-aqua-rgb)) 62%, transparent) !important;
1981
2026
  backdrop-filter: none !important;
1982
2027
  }
2028
+ body[data-mpw-aqua] .wSkVaW_header {
2029
+ backdrop-filter: none !important;
2030
+ }
1983
2031
  /* ⑲(修正) Aqua 模式下弹层背景被 mask 色半透明化,必须配 backdrop blur 防文字叠压
1984
2032
  (用户实测:加号/命令/权限/模型/上下文选择器透明但无模糊 → 文字叠在一起) */
1985
2033
  body[data-mpw-aqua] [role="menu"],
@@ -2126,11 +2174,25 @@ body[data-ds-dark-theme] .mpw_tabBar {
2126
2174
  .mpw_label { font-size: 13px; line-height: 20px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
2127
2175
  .mpw_inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
2128
2176
  .mpw_input {
2129
- flex: 1; min-width: 0; height: 34px; padding: 0 10px; box-sizing: border-box;
2177
+ flex: 1; min-width: 0; height: 40px; padding: 0 12px; box-sizing: border-box;
2130
2178
  border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px;
2131
2179
  background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-primary);
2132
2180
  font: inherit; font-size: 13px;
2133
2181
  }
2182
+ /* ⑳(新) 自定义下拉(替代原生 select) */
2183
+ .mpw_selectBtn { display: flex; align-items: center; gap: 6px; cursor: pointer; }
2184
+ .mpw_selectMenu {
2185
+ background: var(--dsw-alias-bg-module-platform, #1c2230);
2186
+ border: 1px solid var(--dsw-alias-border-l2, #444b5c);
2187
+ border-radius: 10px; padding: 4px; box-shadow: var(--dsw-shadow-lv3, 0 12px 40px rgba(0,0,0,.35));
2188
+ }
2189
+ .mpw_selectOpt {
2190
+ display: block; width: 100%; box-sizing: border-box; text-align: left;
2191
+ padding: 8px 10px; border: 0; border-radius: 8px; background: transparent;
2192
+ color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; cursor: pointer;
2193
+ }
2194
+ .mpw_selectOpt:hover { background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover, #333a4a) 60%, transparent); }
2195
+ .mpw_selectOpt.mpw_on { background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4f8cff) 20%, transparent); }
2134
2196
  .mpw_button {
2135
2197
  flex: none; height: 34px; padding: 0 14px; border: 1px solid rgba(255,255,255,0.25); border-radius: 10px;
2136
2198
  background: #3964fe !important; color: #ffffff !important;
@@ -2168,6 +2230,12 @@ body[data-ds-dark-theme] .mpw_tabBar {
2168
2230
  color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; cursor: pointer;
2169
2231
  }
2170
2232
  .mpw_miniBtn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
2233
+ /* ①(修正) 选中态:品牌色描边 + 淡品牌底(顺序/随机等按钮选中要有明确显示) */
2234
+ .mpw_miniBtn.mpw_on {
2235
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4f8cff) 22%, transparent);
2236
+ color: var(--dsw-alias-brand-primary, #4f8cff);
2237
+ border-color: var(--dsw-alias-brand-primary, #4f8cff);
2238
+ }
2171
2239
  .mpw_hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); margin: 0; }
2172
2240
  /* ①(新) 标题行:仓库链接(灰色、无下划线、hover 变深)+ 版本号 */
2173
2241
  .mpw_titleRow { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
@@ -2550,6 +2618,10 @@ body[data-mpw-modal] [role="dialog"] {
2550
2618
  const [updState, setUpdState] = react.useState(null); // null=未检测 / {checking}/{hasUpdate...}
2551
2619
  const [updConfirm, setUpdConfirm] = react.useState(null); // ⑩(新) 新版本确认下载弹窗
2552
2620
  const [webConfirm, setWebConfirm] = react.useState(null); // ①(新) 网页壁纸确认弹窗(实验性警告)
2621
+ const [rotModal, setRotModal] = react.useState(false); // ⑳(新) 轮播列表管理弹窗
2622
+ const [rotEditor, setRotEditor] = react.useState(false); // ⑳(新) 轮播列表编辑弹窗
2623
+ const [rotEdit, setRotEdit] = react.useState(null); // ⑳(新) 正在编辑的列表草稿
2624
+ const [rotFilter, setRotFilter] = react.useState("all"); // ⑳(新) 勾选界面来源过滤:all/custom/steam
2553
2625
  const [wallList, setWallList] = react.useState([]); // ② 合并后的可选壁纸列表
2554
2626
  const [wallIdx, setWallIdx] = react.useState(-1); // 当前在列表中的索引
2555
2627
  const [libShow, setLibShow] = react.useState(10); // 1(新) 列表单次展示数量(可展开)
@@ -2718,6 +2790,19 @@ body[data-mpw-modal] [role="dialog"] {
2718
2790
  const walls = d.ok && d.wallpapers ? d.wallpapers : [];
2719
2791
  setLibWalls(walls);
2720
2792
  if (d.ok && !d.installDir) showError(t("lib.noInstall"));
2793
+ // ⑳(新) WE 原生播放列表 → 首次自动导入为轮播列表(rotSeeded 防止删除后再播种)
2794
+ try {
2795
+ const pls = (d.playlists || []).filter((p) => p && Array.isArray(p.keys) && p.keys.length);
2796
+ const cur = readSection();
2797
+ if (pls.length && !(cur.rotGroups || []).length && !cur.rotSeeded) {
2798
+ const seed = pls[0];
2799
+ const gid = "we" + Date.now();
2800
+ commit({ rotGroups: [{ id: gid, name: seed.name || t("rot.wePlaylist"), interval: seed.interval || 5, order: seed.order === "random" ? "random" : "sequence", keys: seed.keys }], rotGroupId: gid, rotSeeded: true }, true);
2801
+ setHint(t("rot.seeded") + ":" + (seed.name || ""));
2802
+ } else if (!pls.length && !cur.rotSeeded) {
2803
+ commit({ rotSeeded: true }, true);
2804
+ }
2805
+ } catch {}
2721
2806
  // ②(新) Steam 库合并进统一列表(轮播/上下切换可用):
2722
2807
  // 并入 video(mp4 播放)+ web(iframe)+ scene(预览图/后续静态帧),
2723
2808
  // 用 src:"steam" 标记,next/prev 走 applyLibraryWallpaper。
@@ -2741,6 +2826,11 @@ body[data-mpw-modal] [role="dialog"] {
2741
2826
  // ①(新) 检测更新(对比实际代码内容哈希,README 变更不触发)
2742
2827
  const checkUpdate = async () => {
2743
2828
  setUpdState({ checking: true });
2829
+ // ①(修正) 实时刷新当前版本(像插件市场那样打开即读最新安装版本)
2830
+ try {
2831
+ const p = await fetch(HOST_BASE + "/ping", { method: "GET" });
2832
+ if (p.ok) { const pd = await p.json(); if (pd && pd.version) setHostVersion(pd.version); }
2833
+ } catch {}
2744
2834
  try {
2745
2835
  const r = await fetch(HOST_BASE + "/update-check");
2746
2836
  const d = await r.json();
@@ -2787,8 +2877,20 @@ body[data-mpw-modal] [role="dialog"] {
2787
2877
  setCustomFiles(d.files || []);
2788
2878
  // ①(新) 合并进统一列表:自定义目录文件(图片+视频)+ workshop 原始子文件夹
2789
2879
  // (video → mp4 播放;web → iframe;scene → 预览图/后续静态帧)
2790
- setWallList((d.files || []).map((f) => {
2880
+ setWallList((prev) => {
2881
+ // ①(修正) 自定义目录改为**合并**进列表(不再替换——用户误扫自定义目录后
2882
+ // 切不回 Steam 库的问题;现在两个来源共存,去重)
2883
+ const fresh = (d.files || []).map((f) => {
2791
2884
  if (f.folder) {
2885
+ // ①(修正) 子文件夹里的 .mpkg(wallpaperE 角色收藏夹):独立成条
2886
+ if (f.folderMpkg && f.type === "mpkg") {
2887
+ return {
2888
+ key: "custom|" + f.name + "/" + f.media, title: f.title || f.media, name: f.name,
2889
+ type: "mpkg", folder: true, folderMpkg: true, folderName: f.name, mpkgFile: f.media,
2890
+ image: "host:?custom=1&folder=" + encodeURIComponent(f.name) + "&file=" + encodeURIComponent(f.media),
2891
+ converted: "mp4"
2892
+ };
2893
+ }
2792
2894
  if (f.type === "web") {
2793
2895
  const pv = f.preview || "preview.jpg";
2794
2896
  return {
@@ -2822,7 +2924,10 @@ body[data-mpw-modal] [role="dialog"] {
2822
2924
  image: "host:?custom=1&file=" + encodeURIComponent(f.name),
2823
2925
  converted: f.type === "video" ? "mp4" : (f.type === "mpkg" ? "mp4" : "gif")
2824
2926
  };
2825
- }));
2927
+ });
2928
+ const keys = new Set((prev || []).map((w) => w.key));
2929
+ return fresh.filter((w) => !keys.has(w.key)).concat(prev || []);
2930
+ });
2826
2931
  }
2827
2932
  } catch (err) { showError(t("lib.fail") + String(err && err.message || err)); }
2828
2933
  setLibBusy(false);
@@ -2831,19 +2936,24 @@ body[data-mpw-modal] [role="dialog"] {
2831
2936
  const applyCustomMpkg = async (w) => {
2832
2937
  setLibBusy(true);
2833
2938
  try {
2834
- const r = await fetch(HOST_BASE + "/custom-mpkg?file=" + encodeURIComponent(w.name));
2939
+ // ①(修正) folderMpkg:子文件夹里的 .mpkg 走 folder 参数
2940
+ const fq = w.folderMpkg
2941
+ ? "folder=" + encodeURIComponent(w.folderName || w.name) + "&file=" + encodeURIComponent(w.mpkgFile || w.name)
2942
+ : "file=" + encodeURIComponent(w.name);
2943
+ const r = await fetch(HOST_BASE + "/custom-mpkg?" + fq);
2835
2944
  const d = await r.json();
2836
- if (!d.ok || !d.selected) { showError(t("mpkg.noAsset") + "(" + w.name + ")"); setLibBusy(false); return; }
2945
+ const display = w.title || w.name;
2946
+ if (!d.ok || !d.selected) { showError(t("mpkg.noAsset") + "(" + display + ")"); setLibBusy(false); return; }
2837
2947
  const sel = d.selected;
2838
2948
  commit({
2839
2949
  image: "host:?token=" + d.token + "&index=" + sel.index + (sel.offset ? "&offset=" + sel.offset : ""),
2840
- source: sel.name, mpkgKey: "custommpkg|" + w.name, mpkgName: w.name,
2950
+ source: sel.name, mpkgKey: "custommpkg|" + (w.folderMpkg ? w.folderName + "/" + w.mpkgFile : w.name), mpkgName: display,
2841
2951
  fromMpkg: true, converted: sel.isMp4 ? "mp4" : "gif", slot: null,
2842
2952
  info: undefined, timeVideos: undefined, timeConfig: undefined, activeSlot: null,
2843
2953
  webUrl: undefined, sceneKey: undefined
2844
2954
  }, true);
2845
- setMpkgMeta({ name: w.name, key: "custommpkg|" + w.name, info: null, entryName: sel.name, slot: null });
2846
- setHint(t("lib.applied") + ":" + w.name);
2955
+ setMpkgMeta({ name: display, key: "custommpkg|" + (w.folderMpkg ? w.folderName + "/" + w.mpkgFile : w.name), info: null, entryName: sel.name, slot: null });
2956
+ setHint(t("lib.applied") + ":" + display);
2847
2957
  if (!sel.isMp4) { setPreviewModal(true); }
2848
2958
  } catch (err) { showError(t("lib.fail") + String(err && err.message || err)); }
2849
2959
  setLibBusy(false);
@@ -2919,26 +3029,60 @@ body[data-mpw-modal] [role="dialog"] {
2919
3029
  } catch (err) { console.error("[dsh-mpkg-wallpaper] 应用场景壁纸失败:", err); showError(t("lib.fail") + String(err && err.message || err)); }
2920
3030
  };
2921
3031
  // ②(新) 切换到下一个壁纸(本地库 + 自定义目录合并列表)
3032
+ // ⑳(新) 轮播候选:激活了列表 → 列表内的壁纸;否则全部
3033
+ const rotCandidates = () => {
3034
+ const all = wallList;
3035
+ if (!all.length) return [];
3036
+ const gid = section.rotGroupId;
3037
+ if (gid) {
3038
+ const g = (section.rotGroups || []).find((x) => x.id === gid);
3039
+ if (g && (g.keys || []).length) {
3040
+ const filtered = all.filter((w) => (g.keys || []).includes(w.key));
3041
+ if (filtered.length) return filtered;
3042
+ }
3043
+ }
3044
+ return all;
3045
+ };
2922
3046
  const nextWallpaper = () => {
2923
- const list = wallList;
3047
+ const list = rotCandidates();
2924
3048
  if (!list.length) { showError(t("lib.empty")); return; }
2925
- const idx = (wallIdx + 1) % list.length;
2926
- setWallIdx(idx);
2927
- applyWallFromList(list[idx]);
3049
+ let idx = list.findIndex((w) => w.key === (wallList[wallIdx] && wallList[wallIdx].key));
3050
+ if (idx < 0) idx = -1;
3051
+ idx = (idx + 1) % list.length;
3052
+ const target = list[idx];
3053
+ setWallIdx(wallList.findIndex((w) => w.key === target.key));
3054
+ applyWallFromList(target);
2928
3055
  };
2929
3056
  // 1(新) 切换到上一个壁纸
2930
3057
  const prevWallpaper = () => {
2931
- const list = wallList;
3058
+ const list = rotCandidates();
2932
3059
  if (!list.length) { showError(t("lib.empty")); return; }
2933
- const idx = (wallIdx - 1 + list.length) % list.length;
2934
- setWallIdx(idx);
2935
- applyWallFromList(list[idx]);
3060
+ let idx = list.findIndex((w) => w.key === (wallList[wallIdx] && wallList[wallIdx].key));
3061
+ if (idx < 0) idx = 0;
3062
+ idx = (idx - 1 + list.length) % list.length;
3063
+ const target = list[idx];
3064
+ setWallIdx(wallList.findIndex((w) => w.key === target.key));
3065
+ applyWallFromList(target);
2936
3066
  };
2937
- // ③(新) 壁纸轮换:定时自动切换
3067
+ // ③(新) 壁纸轮换:定时自动切换(⑳(新) 随机顺序的列表打乱)
2938
3068
  const startRotation = () => {
2939
3069
  try { if (window.__mpwRotTimer) clearInterval(window.__mpwRotTimer); } catch {}
2940
- const sec = section.rotateMin > 0 ? section.rotateMin : 5;
2941
- window.__mpwRotTimer = setInterval(nextWallpaper, sec * 60 * 1000);
3070
+ const gid = section.rotGroupId;
3071
+ const g = gid ? (section.rotGroups || []).find((x) => x.id === gid) : null;
3072
+ const sec = g && g.interval > 0 ? g.interval : (section.rotateMin > 0 ? section.rotateMin : 5);
3073
+ if (g && g.order === "random") {
3074
+ // 随机顺序:每轮洗牌后顺序播放(先随机跳一次)
3075
+ window.__mpwRotTimer = setInterval(() => {
3076
+ const list = rotCandidates();
3077
+ if (list.length < 2) { nextWallpaper(); return; }
3078
+ const r = Math.floor(Math.random() * list.length);
3079
+ const target = list[r];
3080
+ setWallIdx(wallList.findIndex((w) => w.key === target.key));
3081
+ applyWallFromList(target);
3082
+ }, sec * 60 * 1000);
3083
+ } else {
3084
+ window.__mpwRotTimer = setInterval(nextWallpaper, sec * 60 * 1000);
3085
+ }
2942
3086
  };
2943
3087
  // ③(修正) 任一弹窗打开时给 body 打标记(背后设置面板禁虚化),关闭时移除
2944
3088
  react.useEffect(() => {
@@ -3037,6 +3181,7 @@ body[data-mpw-modal] [role="dialog"] {
3037
3181
  aquaMask: DEFAULT_AQUA_MASK, aquaTint: DEFAULT_AQUA_TINT, aquaInk: DEFAULT_AQUA_INK, aquaColor: "", aquaInkColor: "", aquaTextEnhance: DEFAULT_AQUA_TEXT_ENHANCE, todoBlur: DEFAULT_TODO_BLUR, aquaMaskAlpha: DEFAULT_AQUA_MASK_ALPHA, aquaTintStrength: DEFAULT_AQUA_TINT_STRENGTH, themeColor: DEFAULT_THEME_COLOR,
3038
3182
  hybrid: DEFAULT_HYBRID, roundCompat: DEFAULT_ROUND_COMPAT,
3039
3183
  rotate: DEFAULT_ROTATE, rotateMin: 5, customDirPath: "", brightness: DEFAULT_BRIGHTNESS, float: DEFAULT_FLOAT,
3184
+ playbackRate: DEFAULT_PLAYBACK_RATE, rotGroups: [], rotGroupId: "", rotSeeded: false,
3040
3185
  thinkBg: DEFAULT_THINK_BG,
3041
3186
  enabled: DEFAULT_ENABLED
3042
3187
  }), true);
@@ -3330,6 +3475,93 @@ body[data-mpw-modal] [role="dialog"] {
3330
3475
  const propsToShow = propsExpanded ? propsShown : [];
3331
3476
 
3332
3477
  const h = react.createElement;
3478
+ // ①(修正) 弹窗遮罩 portal:渲染到 body,避开设置面板包含块/backdrop root 困局
3479
+ const MaskPortal = (props) => {
3480
+ const node = h("div", { className: "mpw_mask", onClick: props.onClick }, props.children);
3481
+ if (ReactDOM && ReactDOM.createPortal && typeof document !== "undefined" && document.body) {
3482
+ try { return ReactDOM.createPortal(node, document.body); } catch { /* 回退原位渲染 */ }
3483
+ }
3484
+ return node;
3485
+ };
3486
+ // ⑳(新) 自定义下拉(不调用原生 select——安卓原生太难看,用户实测)
3487
+ const [selectOpen, setSelectOpen] = react.useState("");
3488
+ const [selectDirUp, setSelectDirUp] = react.useState({}); // name → 是否向上展开
3489
+ const SelectBox = (props) => {
3490
+ const { value, options, onChange, placeholder } = props;
3491
+ const cur = options.find((o) => o.value === value);
3492
+ const dirUp = !!selectDirUp[props.name];
3493
+ const menuStyle = dirUp
3494
+ ? { position: "absolute", left: 0, right: 0, bottom: "100%", marginBottom: 4, zIndex: 2600, maxHeight: 220, overflowY: "auto" }
3495
+ : { position: "absolute", left: 0, right: 0, top: "100%", marginTop: 4, zIndex: 2600, maxHeight: 220, overflowY: "auto" };
3496
+ return h("div", { className: "mpw_select", style: { position: "relative", flex: 1, minWidth: 0 } }, [
3497
+ h("button", { className: "mpw_input mpw_selectBtn", type: "button",
3498
+ onClick: (ev) => {
3499
+ if (selectOpen !== props.name) {
3500
+ // ①(修正) 下方空间不足时向上展开(用户实测列表底部被屏幕边缘截断)
3501
+ let up = false;
3502
+ try {
3503
+ const rect = ev.currentTarget.getBoundingClientRect();
3504
+ const est = Math.min(220, (options.length + 1) * 34);
3505
+ if (rect.bottom + est > (window.innerHeight || 600)) up = true;
3506
+ } catch {}
3507
+ setSelectDirUp((prev) => Object.assign({}, prev, { [props.name]: up }));
3508
+ }
3509
+ setSelectOpen(selectOpen === props.name ? "" : props.name);
3510
+ } },
3511
+ h("span", { style: { flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, cur ? cur.label : placeholder),
3512
+ h("span", { className: "mpw_hint" }, selectOpen === props.name ? (dirUp ? "▼" : "▲") : (dirUp ? "▲" : "▼"))
3513
+ ),
3514
+ selectOpen === props.name
3515
+ ? h("div", { className: "mpw_selectMenu", style: menuStyle },
3516
+ options.map((o) => h("button", { className: "mpw_selectOpt" + (o.value === value ? " mpw_on" : ""), type: "button", key: o.value,
3517
+ onClick: () => { setSelectOpen(""); onChange(o.value); } }, o.label)))
3518
+ : null
3519
+ ]);
3520
+ };
3521
+ // ⑳(新) 视频倍速分段条:6 段竖线分隔,点击/拖拽直接定位到对应档位
3522
+ const SpeedBar = (props) => {
3523
+ const { value, onChange } = props;
3524
+ const ref = react.useRef(null);
3525
+ const dragging = react.useRef(false);
3526
+ const idx = Math.max(0, SPEED_LEVELS.indexOf(value));
3527
+ const pick = (clientX) => {
3528
+ const el = ref.current;
3529
+ if (!el) return;
3530
+ const rect = el.getBoundingClientRect();
3531
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
3532
+ const i2 = Math.min(SPEED_LEVELS.length - 1, Math.round(ratio * (SPEED_LEVELS.length - 1)));
3533
+ onChange(SPEED_LEVELS[i2]);
3534
+ };
3535
+ return h("div", { className: "mpw_speedBar", style: { position: "relative", height: 56, margin: "8px 0 0", touchAction: "none", cursor: "pointer" },
3536
+ ref,
3537
+ onPointerDown: (ev) => { dragging.current = true; try { ev.target.setPointerCapture && ev.target.setPointerCapture(ev.pointerId); } catch {} pick(ev.clientX); },
3538
+ onPointerMove: (ev) => { if (dragging.current) pick(ev.clientX); },
3539
+ onPointerUp: () => { dragging.current = false; },
3540
+ onPointerCancel: () => { dragging.current = false; },
3541
+ onClick: (ev) => pick(ev.clientX) },
3542
+ // ①(修正) 统一坐标系:左右 16px 内边距,轨道/竖线/球/进度/数字标签
3543
+ // 全部用 pos(i) = 16px + i/5*(100%-32px)——数字中心严格落在刻度上。
3544
+ // (之前 space-between 按钮宽度不同 → 中心不落在刻度;全宽又边缘被裁)
3545
+ // 轨道
3546
+ h("div", { style: { position: "absolute", left: 16, right: 16, top: 14, height: 6, borderRadius: 3, background: "color-mix(in srgb, var(--dsw-alias-interactive-bg-hover, #333a4a) 60%, transparent)" } }),
3547
+ // 填充进度
3548
+ h("div", { style: { position: "absolute", left: 16, top: 14, height: 6, borderRadius: 3, background: "var(--dsw-alias-brand-primary, #4f8cff)", width: "calc(" + (idx / (SPEED_LEVELS.length - 1) * (100 - 32)) + "% - 0px)" } }),
3549
+ // 分段竖线
3550
+ SPEED_LEVELS.map((rate, i) => h("div", { key: rate, style: {
3551
+ position: "absolute", top: 6, left: "calc(16px + " + (i / (SPEED_LEVELS.length - 1) * (100 - 32)) + "% - 1px)",
3552
+ width: 1, height: 22, background: "color-mix(in srgb, var(--dsw-alias-border-l2, #444b5c) 70%, transparent)"
3553
+ } })),
3554
+ // 当前档位球
3555
+ h("div", { style: { position: "absolute", top: 8, left: "calc(16px + " + (idx / (SPEED_LEVELS.length - 1) * (100 - 32)) + "% - 7px)", width: 14, height: 14, borderRadius: "50%", background: "var(--dsw-alias-brand-primary, #4f8cff)", border: "2px solid var(--dsw-alias-bg-base, #0e1420)" } }),
3556
+ // 数字标签也可点击
3557
+ SPEED_LEVELS.map((rate, i) => h("button", { key: "b" + rate, type: "button", style: {
3558
+ position: "absolute", top: 32, left: "calc(16px + " + (i / (SPEED_LEVELS.length - 1) * (100 - 32)) + "%)", transform: "translateX(-50%)",
3559
+ padding: "4px 2px", fontSize: 11, border: 0, background: "transparent", cursor: "pointer", whiteSpace: "nowrap",
3560
+ color: i === idx ? "var(--dsw-alias-brand-primary, #4f8cff)" : "var(--dsw-alias-label-tertiary)",
3561
+ fontWeight: i === idx ? 700 : 400
3562
+ }, onClick: () => onChange(SPEED_LEVELS[i]) }, (rate === 1 ? "1" : rate) + "x"))
3563
+ );
3564
+ };
3333
3565
  const sliderRow = (label, field, min, max, suffix, step, def) => {
3334
3566
  const val = section[field] !== void 0 ? section[field] : def;
3335
3567
  const rangeRef = react.useRef(null);
@@ -3517,7 +3749,7 @@ body[data-mpw-modal] [role="dialog"] {
3517
3749
  // 图片项用 <img>(参照 elysia395 的 preview 显示)
3518
3750
  // ①(修正) mpkg 项用容器内 preview 图(custom-mpkg-preview 路由);视频用首帧;图片直接用
3519
3751
  w.type === "mpkg"
3520
- ? h("img", { className: "mpw_thumbImg", src: HOST_BASE + "/custom-mpkg-preview?file=" + encodeURIComponent(w.name), alt: "", loading: "lazy",
3752
+ ? h("img", { className: "mpw_thumbImg", src: HOST_BASE + "/custom-mpkg-preview?" + (w.folderMpkg ? "folder=" + encodeURIComponent(w.folderName) + "&" : "") + "file=" + encodeURIComponent(w.folderMpkg ? w.mpkgFile : w.name), alt: "", loading: "lazy",
3521
3753
  onError: (ev) => { ev.target.style.display = "none"; } })
3522
3754
  : ((w.type === "video" || w.converted === "mp4")
3523
3755
  ? h("video", { className: "mpw_thumbImg", src: resolveHostUrl(w.image), muted: true, playsInline: true, preload: "metadata",
@@ -3590,7 +3822,7 @@ body[data-mpw-modal] [role="dialog"] {
3590
3822
  ])
3591
3823
  : libWalls && !libWalls.length ? h("p", { className: "mpw_hint" }, t("lib.empty")) : null
3592
3824
  ]),
3593
- // ②③(新) 下一个壁纸 + 定时轮换
3825
+ // ②③(新) 下一个壁纸 + 定时轮换 + ⑳(新) 轮播列表(WE 原生播放列表风格)
3594
3826
  h("div", { className: "mpw_field" }, [
3595
3827
  h("label", { className: "mpw_label" }, t("lib.rotate")),
3596
3828
  h("div", { className: "mpw_inline" }, [
@@ -3599,9 +3831,139 @@ body[data-mpw-modal] [role="dialog"] {
3599
3831
  h(Toggle, { checked: section.rotate !== void 0 ? !!section.rotate : DEFAULT_ROTATE, onChange: (v) => commit({ rotate: v }) }),
3600
3832
  h("span", { className: "mpw_hint" }, t("lib.rotateDesc"))
3601
3833
  ]),
3834
+ // ⑳(新) 轮播列表:选择激活列表(自定义下拉)+ 管理 + 新建
3835
+ h("div", { className: "mpw_inline", style: { marginTop: 4 } }, [
3836
+ h(SelectBox, { name: "rotGroup", value: section.rotGroupId || "", placeholder: t("rot.all"),
3837
+ options: [{ value: "", label: t("rot.all") }].concat((section.rotGroups || []).map((g) => ({ value: g.id, label: g.name + "(" + (g.keys || []).length + ")" }))),
3838
+ onChange: (v) => commit({ rotGroupId: v }, true) }),
3839
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: () => setRotModal(true) }, t("rot.manage")),
3840
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: () => { setRotEdit({ id: "", name: "", interval: 5, order: "sequence", keys: [] }); setRotEditor(true); } }, t("rot.new"))
3841
+ ]),
3602
3842
  h("div", { style: { display: (section.rotate !== void 0 ? !!section.rotate : DEFAULT_ROTATE) ? "" : "none" } },
3603
- sliderRow(t("lib.rotateMin"), "rotateMin", 1, 120, t("lib.minutes"), 1, 5))
3843
+ sliderRow(t("lib.rotateMin"), "rotateMin", 1, 120, t("lib.minutes"), 1, 5)),
3844
+ // ①(修正) 选中列表后:单独设置该列表的轮换间隔(用户要求,编辑弹窗不再设间隔)
3845
+ (section.rotGroupId && (section.rotGroups || []).find((g) => g.id === section.rotGroupId))
3846
+ ? h("div", { className: "mpw_inline", style: { marginTop: 4 } }, [
3847
+ h("span", { className: "mpw_hint" }, t("rot.groupInterval")),
3848
+ h("input", { type: "range", min: 1, max: 120, step: 1, style: { flex: 1, minWidth: 0 },
3849
+ value: (section.rotGroups || []).find((g) => g.id === section.rotGroupId).interval || 5,
3850
+ onChange: (ev) => {
3851
+ const v = Math.max(1, parseInt(ev.target.value || "5", 10));
3852
+ const groups = (section.rotGroups || []).map((g) => g.id === section.rotGroupId ? Object.assign({}, g, { interval: v }) : g);
3853
+ commit({ rotGroups: groups }, true);
3854
+ } }),
3855
+ h("span", { className: "mpw_hint" }, ((section.rotGroups || []).find((g) => g.id === section.rotGroupId).interval || 5) + t("lib.minutes"))
3856
+ ])
3857
+ : null
3604
3858
  ]),
3859
+ // ⑳(新) 轮播列表管理弹窗(新建/编辑/删除)
3860
+ rotModal ? h(MaskPortal, { onClick: () => setRotModal(false) }, [
3861
+ h("div", { className: "mpw_dialog", style: { width: "min(560px, calc(100vw - 32px))" }, onClick: (ev) => ev.stopPropagation() }, [
3862
+ h("div", { className: "mpw_title" }, t("rot.title")),
3863
+ (section.rotGroups || []).length === 0
3864
+ ? h("p", { className: "mpw_hint" }, t("rot.empty"))
3865
+ : h("div", { style: { maxHeight: 260, overflowY: "auto" } }, (section.rotGroups || []).map((g) => h("div", { className: "mpw_inline", key: g.id, style: { marginBottom: 6 } }, [
3866
+ h("span", { className: "mpw_propLabel", style: { flex: 1, minWidth: 0 } }, h("b", null, g.name)),
3867
+ h("span", { className: "mpw_hint" }, (g.keys || []).length + t("rot.items") + " · " + (g.interval || 5) + t("lib.minutes") + " · " + (g.order === "random" ? t("rot.random") : t("rot.seq"))),
3868
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: () => { setRotEdit(JSON.parse(JSON.stringify(g))); setRotModal(false); setRotEditor(true); } }, t("rot.edit")),
3869
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: () => {
3870
+ const next = (section.rotGroups || []).filter((x) => x.id !== g.id);
3871
+ commit({ rotGroups: next, rotGroupId: section.rotGroupId === g.id ? "" : section.rotGroupId }, true);
3872
+ } }, t("rot.del"))
3873
+ ]))),
3874
+ h("div", { className: "mpw_inline", style: { marginTop: 8 } }, [
3875
+ h("button", { className: "mpw_button", type: "button", onClick: () => { setRotEdit({ id: "", name: "", interval: 5, order: "sequence", keys: [] }); setRotModal(false); setRotEditor(true); } }, t("rot.new")),
3876
+ h("button", { className: "mpw_reset", type: "button", onClick: () => setRotModal(false) }, t("conflict.cancel"))
3877
+ ])
3878
+ ])
3879
+ ]) : null,
3880
+ // ⑳(新) 轮播列表编辑弹窗(命名 + 勾选壁纸 + 间隔 + 顺序)
3881
+ rotEditor && rotEdit ? h(MaskPortal, { onClick: () => setRotEditor(false) }, [
3882
+ h("div", { className: "mpw_dialog", style: { width: "min(560px, calc(100vw - 32px))" }, onClick: (ev) => ev.stopPropagation() }, [
3883
+ h("div", { className: "mpw_title" }, rotEdit.id ? t("rot.editTitle") : t("rot.newTitle")),
3884
+ // ①(修正) 名称输入框与「图片链接」输入框同款(mpw_input;dialog 是列布局,
3885
+ // flex:1 会纵向拉伸 → 显式 flex:none 恢复正常高度)
3886
+ h("input", { className: "mpw_input", type: "text", style: { width: "100%", boxSizing: "border-box", flex: "none", marginBottom: 10 }, placeholder: t("rot.namePh"), value: rotEdit.name, onChange: (ev) => setRotEdit(Object.assign({}, rotEdit, { name: ev.target.value })) }),
3887
+ // ①(修正) 播放顺序(顺序/随机);间隔在列表选择下方单独设置
3888
+ h("div", { className: "mpw_inline", style: { marginBottom: 8 } }, [
3889
+ h("span", { className: "mpw_label", style: { fontSize: 13 } }, t("rot.order")),
3890
+ h("button", { className: "mpw_miniBtn" + (rotEdit.order !== "random" ? " mpw_on" : ""), type: "button", onClick: () => setRotEdit(Object.assign({}, rotEdit, { order: "sequence" })) }, t("rot.seq")),
3891
+ h("button", { className: "mpw_miniBtn" + (rotEdit.order === "random" ? " mpw_on" : ""), type: "button", onClick: () => setRotEdit(Object.assign({}, rotEdit, { order: "random" })) }, t("rot.random"))
3892
+ ]),
3893
+ h("p", { className: "mpw_hint" }, t("rot.pickHint") + "(" + wallList.length + ")"),
3894
+ // ①(修正) 来源过滤 + 扫描入口:误扫自定义目录后也能切回 Steam 库(用户要求"返回")
3895
+ h("div", { className: "mpw_inline", style: { marginBottom: 6 } }, [
3896
+ h("button", { className: "mpw_miniBtn" + (rotFilter === "all" ? " mpw_on" : ""), type: "button", onClick: () => setRotFilter("all") }, t("rot.all")),
3897
+ h("button", { className: "mpw_miniBtn" + (rotFilter === "custom" ? " mpw_on" : ""), type: "button", onClick: () => setRotFilter("custom") }, t("rot.filterCustom")),
3898
+ h("button", { className: "mpw_miniBtn" + (rotFilter === "steam" ? " mpw_on" : ""), type: "button", onClick: () => setRotFilter("steam") }, t("rot.filterSteam")),
3899
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: () => { if (customDir) scanCustomDir(); else showError(t("lib.dirEmpty")); } }, t("rot.scanDir")),
3900
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: scanLibrary }, t("rot.scanLib"))
3901
+ ]),
3902
+ // ①(修正) 勾选改整行点击;壁纸列表为空时给扫描入口(wallList 是运行时状态,刷新后需重扫)
3903
+ wallList.length === 0
3904
+ ? h("div", { style: { padding: "12px 0" } }, [
3905
+ h("p", { className: "mpw_hint", style: { marginBottom: 6 } }, t("rot.noWallpapers")),
3906
+ h("div", { className: "mpw_inline" }, [
3907
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: () => { if (customDir) scanCustomDir(); else showError(t("lib.dirEmpty")); } }, t("rot.scanDir")),
3908
+ h("button", { className: "mpw_reset mpw_moreBtn", type: "button", onClick: scanLibrary }, t("rot.scanLib"))
3909
+ ])
3910
+ ])
3911
+ : h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6, maxHeight: 220, overflowY: "auto" } }, wallList.filter((w) => rotFilter === "all" ? true : rotFilter === "steam" ? w.src === "steam" : !w.src || w.src !== "steam").map((w) => {
3912
+ const on = (rotEdit.keys || []).includes(w.key);
3913
+ // ①(修正) 缩略图:与扫描目录网格同款逻辑(mpkg preview / 视频首帧 / 静态图)
3914
+ let turl = "";
3915
+ try {
3916
+ if (w.type === "mpkg") turl = HOST_BASE + "/custom-mpkg-preview?" + (w.folderMpkg ? "folder=" + encodeURIComponent(w.folderName) + "&" : "") + "file=" + encodeURIComponent(w.folderMpkg ? w.mpkgFile : w.name);
3917
+ else if (w.image) turl = resolveHostUrl(w.image);
3918
+ else if (w.src === "steam" && w.ltoken) {
3919
+ if (w.type === "video" && w.media) turl = HOST_BASE + "/library-media?ltoken=" + encodeURIComponent(w.ltoken) + "&file=" + encodeURIComponent(String(w.media).split(/[\\/]/).pop());
3920
+ else turl = HOST_BASE + "/library-web?ltoken=" + encodeURIComponent(w.ltoken) + "&file=" + encodeURIComponent(String(w.preview || "").split(/[\\/]/).pop() || "preview.jpg");
3921
+ }
3922
+ } catch {}
3923
+ return h("div", { className: "mpw_pickCard" + (on ? " mpw_on" : ""), key: w.key,
3924
+ style: { position: "relative", borderRadius: 10, overflow: "hidden", cursor: "pointer", border: "1px solid " + (on ? "var(--dsw-alias-brand-primary, #4f8cff)" : "var(--dsw-alias-border-l2, #444b5c)"),
3925
+ background: on ? "color-mix(in srgb, var(--dsw-alias-brand-primary, #4f8cff) 12%, transparent)" : "var(--dsw-alias-bg-module-platform, #1c2230)" },
3926
+ onClick: () => {
3927
+ const keys = (rotEdit.keys || []).slice();
3928
+ const i = keys.indexOf(w.key);
3929
+ if (i >= 0) keys.splice(i, 1); else keys.push(w.key);
3930
+ setRotEdit(Object.assign({}, rotEdit, { keys }));
3931
+ } }, [
3932
+ // ①(修正) 固定 64px 高度容器:**只有真正的视频项**用首帧 <video>;
3933
+ // mpkg 项 converted 也是 "mp4" 但缩略 URL 是预览图(图片)——
3934
+ // 之前误用 <video> 加载图片 → 必然失败被隐藏 → 缩略图全空白
3935
+ h("div", { style: { width: "100%", height: 64, position: "relative", background: "color-mix(in srgb, var(--dsw-alias-interactive-bg-hover, #333a4a) 30%, transparent)" } }, [
3936
+ turl && w.type === "video"
3937
+ ? h("video", { src: turl, muted: true, playsInline: true, preload: "metadata",
3938
+ style: { width: "100%", height: 64, objectFit: "cover", display: "block" },
3939
+ onLoadedMetadata: (ev) => { try { const v = ev.target; if (v.duration && v.duration > 0.1) v.currentTime = 0.05; } catch {} },
3940
+ onError: (ev) => { ev.target.style.display = "none"; } })
3941
+ : turl
3942
+ ? h("img", { src: turl, alt: "", loading: "lazy", style: { width: "100%", height: 64, objectFit: "cover", display: "block" }, onError: (ev) => { ev.target.style.display = "none"; } })
3943
+ : null
3944
+ ]),
3945
+ h("div", { style: { display: "flex", alignItems: "center", gap: 6, padding: "6px 8px" } }, [
3946
+ h("span", { style: { flex: "none", width: 16, height: 16, borderRadius: 5, border: "1px solid " + (on ? "var(--dsw-alias-brand-primary, #4f8cff)" : "var(--dsw-alias-border-l2, #444b5c)"), display: "flex", alignItems: "center", justifyContent: "center", color: on ? "var(--dsw-alias-brand-primary, #4f8cff)" : "transparent", fontSize: 12 } }, "✓"),
3947
+ h("span", { className: "mpw_hint", style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--dsw-alias-label-primary)", fontSize: 12 } }, w.title)
3948
+ ])
3949
+ ]);
3950
+ })),
3951
+ h("div", { className: "mpw_inline", style: { marginTop: 10 } }, [
3952
+ h("button", { className: "mpw_button", type: "button", onClick: () => {
3953
+ const groups = (section.rotGroups || []).slice();
3954
+ const keys = (rotEdit.keys || []).filter((k) => wallList.some((w) => w.key === k));
3955
+ if (!keys.length) { showError(t("rot.emptyKeys")); return; }
3956
+ // ①(修正) 新建列表默认间隔用全局 rotateMin(间隔在列表选择下方单独调)
3957
+ const g = { id: rotEdit.id || ("rot" + Date.now()), name: rotEdit.name.trim() || t("rot.unnamed"), interval: Math.max(1, rotEdit.interval || (section.rotateMin > 0 ? section.rotateMin : 5)), order: rotEdit.order === "random" ? "random" : "sequence", keys };
3958
+ const i = groups.findIndex((x) => x.id === g.id);
3959
+ if (i >= 0) groups[i] = g; else groups.push(g);
3960
+ commit({ rotGroups: groups, rotGroupId: section.rotGroupId || g.id }, true);
3961
+ setRotEditor(false);
3962
+ } }, t("rot.save")),
3963
+ h("button", { className: "mpw_reset", type: "button", onClick: () => setRotEditor(false) }, t("conflict.cancel"))
3964
+ ])
3965
+ ])
3966
+ ]) : null,
3605
3967
 
3606
3968
  // ③(新) 宿主端状态(proot/本机测试方法:可用 = 大文件无限制生效)
3607
3969
  h("p", { className: "mpw_hint" },
@@ -3611,6 +3973,13 @@ body[data-mpw-modal] [role="dialog"] {
3611
3973
  : "混合模式:检测宿主端中…")
3612
3974
  : "纯浏览器模式(600MB 上限)"),
3613
3975
 
3976
+ // ⑳(新) 视频倍速分段条(放在 mpkg 选择上方;点击/拖拽定位档位)
3977
+ h("div", { className: "mpw_field" }, [
3978
+ h("label", { className: "mpw_label" }, t("speed") + " · " + (section.playbackRate !== void 0 ? section.playbackRate : DEFAULT_PLAYBACK_RATE) + "x"),
3979
+ h(SpeedBar, { value: section.playbackRate !== void 0 ? section.playbackRate : DEFAULT_PLAYBACK_RATE, onChange: (v) => commit({ playbackRate: v }, true) }),
3980
+ h("p", { className: "mpw_hint" }, t("speed.hint"))
3981
+ ]),
3982
+
3614
3983
  // mpkg 文件
3615
3984
  h("div", { className: "mpw_field" }, [
3616
3985
  h("div", { className: "mpw_inline" }, [
@@ -3995,7 +4364,7 @@ body[data-mpw-modal] [role="dialog"] {
3995
4364
  hint ? h("p", { className: "mpw_hint" + (/^(解析失败|文件过大|背景素材过大|存储空间|内存不足|此壁纸的视频纹理|不支持)/.test(hint) ? " mpw_err" : "") }, hint) : null,
3996
4365
 
3997
4366
  // ⑩(新) 新版本确认下载弹窗(发布<24h 提示)
3998
- updConfirm ? h("div", { className: "mpw_mask", onClick: () => setUpdConfirm(null) }, [
4367
+ updConfirm ? h(MaskPortal, { onClick: () => setUpdConfirm(null) }, [
3999
4368
  h("div", { className: "mpw_dialog", onClick: (ev) => ev.stopPropagation() }, [
4000
4369
  h("div", { className: "mpw_title" }, t("update.warnTitle")),
4001
4370
  h("p", { className: "mpw_desc" }, t("update.warnBody") + updConfirm.localVersion + " → " + updConfirm.remoteVersion + ")"),
@@ -4007,7 +4376,7 @@ body[data-mpw-modal] [role="dialog"] {
4007
4376
  ]) : null,
4008
4377
 
4009
4378
  // ①(新) 目录选择弹窗(文件夹选择器,跨平台)
4010
- dirPick ? h("div", { className: "mpw_mask", onClick: () => setDirPick(false) }, [
4379
+ dirPick ? h(MaskPortal, { onClick: () => setDirPick(false) }, [
4011
4380
  h("div", { className: "mpw_dialog", onClick: (ev) => ev.stopPropagation() }, [
4012
4381
  h("div", { className: "mpw_title" }, t("lib.pickDir")),
4013
4382
  h("p", { className: "mpw_hint", style: { wordBreak: "break-all" } }, t("lib.curDir") + ":" + (dirPath || "(默认)")),
@@ -4054,7 +4423,7 @@ body[data-mpw-modal] [role="dialog"] {
4054
4423
  ]) : null,
4055
4424
 
4056
4425
  // ①(新) 通用错误弹窗(文件过大 / 无法使用 / 解析失败等)
4057
- errorModal ? h("div", { className: "mpw_mask", onClick: () => setErrorModal(false) }, [
4426
+ errorModal ? h(MaskPortal, { onClick: () => setErrorModal(false) }, [
4058
4427
  h("div", { className: "mpw_dialog", onClick: (ev) => ev.stopPropagation() }, [
4059
4428
  h("div", { className: "mpw_title" }, t("error.title")),
4060
4429
  h("p", { className: "mpw_desc" }, errorMsg),
@@ -4065,7 +4434,7 @@ body[data-mpw-modal] [role="dialog"] {
4065
4434
  ]) : null,
4066
4435
 
4067
4436
  // ⑤(新) 预览模式弹窗(导入后最终用 GIF/图片时提示)
4068
- previewModal ? h("div", { className: "mpw_mask", onClick: () => setPreviewModal(false) }, [
4437
+ previewModal ? h(MaskPortal, { onClick: () => setPreviewModal(false) }, [
4069
4438
  h("div", { className: "mpw_dialog", onClick: (ev) => ev.stopPropagation() }, [
4070
4439
  h("div", { className: "mpw_title" }, t("preview.title")),
4071
4440
  h("p", { className: "mpw_desc" }, t("preview.desc")),
@@ -4077,7 +4446,7 @@ body[data-mpw-modal] [role="dialog"] {
4077
4446
 
4078
4447
  // ④ 冲突确认弹窗(自绘覆盖层)
4079
4448
  // ①(新) 网页壁纸确认弹窗(实验性警告:可能卡顿或无法加载;预检标注风险)
4080
- webConfirm ? h("div", { className: "mpw_mask", onClick: () => setWebConfirm(null) }, [
4449
+ webConfirm ? h(MaskPortal, { onClick: () => setWebConfirm(null) }, [
4081
4450
  h("div", { className: "mpw_dialog", onClick: (ev) => ev.stopPropagation() }, [
4082
4451
  h("div", { className: "mpw_title" }, t("web.confirmTitle")),
4083
4452
  h("p", { className: "mpw_desc" }, `${t("web.confirmBody")}(${webConfirm.title || ""})`),
@@ -4099,7 +4468,7 @@ body[data-mpw-modal] [role="dialog"] {
4099
4468
  ])
4100
4469
  ])
4101
4470
  ]) : null,
4102
- conflictModal ? h("div", { className: "mpw_mask", onClick: () => setConflictModal(false) }, [
4471
+ conflictModal ? h(MaskPortal, { onClick: () => setConflictModal(false) }, [
4103
4472
  h("div", { className: "mpw_dialog", onClick: (ev) => ev.stopPropagation() }, [
4104
4473
  h("div", { className: "mpw_title" }, t("conflict.title")),
4105
4474
  h("p", { className: "mpw_desc" }, `${t("conflict.body")}:${conflicts.join(", ")}`),
@@ -4274,6 +4643,35 @@ body[data-mpw-modal] [role="dialog"] {
4274
4643
  "flipY.desc": "壁纸上下镜像(scaleY -1)",
4275
4644
  "themeColor": "主题颜色",
4276
4645
  "themeColor.hint": "驱动按钮/滑条/选中项/链接/发送键等品牌色(取色盘 + 预置);空 = DSH 默认蓝",
4646
+ "speed": "视频倍速",
4647
+ "speed.hint": "视频壁纸播放速度(0.5x–2x,原生播放器变速,即时生效不重载)",
4648
+ "rot.title": "轮播列表管理",
4649
+ "rot.all": "全部壁纸(不限定列表)",
4650
+ "rot.manage": "管理列表",
4651
+ "rot.empty": "还没有轮播列表。点「新建列表」从壁纸中挑选,或扫描 Steam 库自动导入 WE 播放列表。",
4652
+ "rot.new": "新建列表",
4653
+ "rot.newTitle": "新建轮播列表",
4654
+ "rot.edit": "编辑",
4655
+ "rot.editTitle": "编辑轮播列表",
4656
+ "rot.del": "删除",
4657
+ "rot.save": "保存",
4658
+ "rot.items": " 个壁纸",
4659
+ "rot.seq": "顺序",
4660
+ "rot.random": "随机",
4661
+ "rot.namePh": "列表名称",
4662
+ "rot.unnamed": "未命名列表",
4663
+ "rot.pickHint": "勾选参与轮播的壁纸",
4664
+ "rot.emptyKeys": "请至少勾选一个壁纸",
4665
+ "rot.wePlaylist": "WE 播放列表",
4666
+ "rot.seeded": "已导入 WE 原生播放列表",
4667
+ "rot.groupInterval": "该列表轮换间隔",
4668
+ "rot.order": "播放顺序",
4669
+ "rot.noWallpapers": "没有可选的壁纸(先扫描壁纸库)",
4670
+ "rot.scanDir": "扫描自定义目录",
4671
+ "rot.filterCustom": "自定义",
4672
+ "rot.filterSteam": "Steam库",
4673
+ "rot.scanLib": "扫描本地库",
4674
+ "rot.intervalHint": "间隔在选中列表后下方单独设置",
4277
4675
  "themeColorReset": "默认",
4278
4676
  "blur": "磨砂模糊",
4279
4677
  "blur.overridden": "统一虚化 + 聊天区跟随均开启:壁纸模糊由「整屏虚化程度」接管,磨砂条暂不可调;关闭「聊天区跟随整屏虚化」后磨砂条恢复可调(此时统一虚化只管侧边栏/标题栏)",
@@ -4379,6 +4777,35 @@ body[data-mpw-modal] [role="dialog"] {
4379
4777
  "lib.rotateDesc": "Automatically switch to the next wallpaper on a timer (range: custom folder + local library + Steam-discovered video wallpapers)",
4380
4778
  "lib.rotMerged": "Playable Steam-library videos merged into the rotation: ",
4381
4779
  "lib.rotateMin": "Rotation interval",
4780
+ "speed": "Video speed",
4781
+ "speed.hint": "Video wallpaper playback speed (0.5x–2x, native player rate, instant, no reload)",
4782
+ "rot.title": "Carousel list management",
4783
+ "rot.all": "All wallpapers (no list)",
4784
+ "rot.manage": "Manage lists",
4785
+ "rot.empty": "No carousel lists yet. Click \"New list\" to pick wallpapers, or scan the Steam library to auto-import WE playlists.",
4786
+ "rot.new": "New list",
4787
+ "rot.newTitle": "New carousel list",
4788
+ "rot.edit": "Edit",
4789
+ "rot.editTitle": "Edit carousel list",
4790
+ "rot.del": "Delete",
4791
+ "rot.save": "Save",
4792
+ "rot.items": " wallpapers",
4793
+ "rot.seq": "Sequence",
4794
+ "rot.random": "Random",
4795
+ "rot.namePh": "List name",
4796
+ "rot.unnamed": "Unnamed list",
4797
+ "rot.pickHint": "Check wallpapers to include in rotation",
4798
+ "rot.emptyKeys": "Select at least one wallpaper",
4799
+ "rot.wePlaylist": "WE playlist",
4800
+ "rot.seeded": "Imported WE native playlist",
4801
+ "rot.groupInterval": "This list rotation interval",
4802
+ "rot.order": "Play order",
4803
+ "rot.noWallpapers": "No wallpapers to pick (scan a wallpaper library first)",
4804
+ "rot.scanDir": "Scan custom folder",
4805
+ "rot.filterCustom": "Custom",
4806
+ "rot.filterSteam": "Steam library",
4807
+ "rot.scanLib": "Scan local library",
4808
+ "rot.intervalHint": "Interval is set separately below after selecting a list",
4382
4809
  "lib.minutes": "min",
4383
4810
  "hybrid.desc": "On: mpkg is uploaded to the DSH host and streamed (supports files >600MB; restart dsh web for the host half). Off: pure browser mode (600MB cap)",
4384
4811
  "conflict.detected": "Potentially conflicting plugins detected",
package/lib/index.js CHANGED
@@ -468,6 +468,18 @@ function apply(ctx) {
468
468
  else if (hasHtml) { type = 'web'; media = 'index.html'; }
469
469
  else if (vid) { type = 'video'; media = vid; }
470
470
  }
471
+ // ①(修正) 子文件夹里的 .mpkg(无 project.json/scene.pkg/html 的纯 mpkg 收藏夹,
472
+ // 如 wallpaperE 下的角色文件夹)→ 每个 mpkg 独立成条(folderMpkg)
473
+ if ((!type || !media) && e.isDirectory()) {
474
+ let mpkgs = [];
475
+ try { mpkgs = readdirSync(sub).filter((x) => x.toLowerCase().endsWith('.mpkg')); } catch {}
476
+ if (mpkgs.length) {
477
+ for (const m of mpkgs) {
478
+ files.push({ name: f, title: m, type: 'mpkg', media: m, folder: true, folderMpkg: true, preview: null });
479
+ }
480
+ continue;
481
+ }
482
+ }
471
483
  if (type && media) {
472
484
  // ①(新) 网页壁纸风险预检:Spine/L2D 骨骼动画(skel/atlas)→ 重动画
473
485
  // 可能卡顿;引用外网 http(s) → 依赖外网可能失败(用户实测:昔涟类
@@ -520,8 +532,10 @@ function apply(ctx) {
520
532
  if (!customDir) { json(res, 404, { ok: false, error: 'no dir' }); return; }
521
533
  const url = new URL(req.url || '', 'http://localhost');
522
534
  const file = url.searchParams.get('file') || '';
523
- if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
524
- const filePath = join(customDir, file);
535
+ const folder = url.searchParams.get('folder') || '';
536
+ if (!file || file.includes('..') || file.includes('/') || file.includes('\\') || (folder && (folder.includes('..') || folder.includes('/') || folder.includes('\\') || folder.startsWith('.')))) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
537
+ // ①(修正) folder= 支持子文件夹里的 .mpkg(wallpaperE 角色收藏夹)
538
+ const filePath = folder ? join(customDir, folder, file) : join(customDir, file);
525
539
  if (!existsSync(filePath) || !file.toLowerCase().endsWith('.mpkg')) { json(res, 404, { ok: false, error: 'not found' }); return; }
526
540
  const head = readFileSync(filePath).subarray(0, HEAD_BYTES);
527
541
  const { dataStart, entries } = parseMpkgHead(head);
@@ -560,12 +574,15 @@ function apply(ctx) {
560
574
  if (!customDir) { json(res, 404, { ok: false, error: 'no dir' }); return; }
561
575
  const url = new URL(req.url || '', 'http://localhost');
562
576
  const file = url.searchParams.get('file') || '';
563
- if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
564
- const filePath = join(customDir, file);
577
+ const folder = url.searchParams.get('folder') || '';
578
+ if (!file || file.includes('..') || file.includes('/') || file.includes('\\') || (folder && (folder.includes('..') || folder.includes('/') || folder.includes('\\') || folder.startsWith('.')))) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
579
+ // ①(修正) folder= 支持子文件夹里的 .mpkg
580
+ const filePath = folder ? join(customDir, folder, file) : join(customDir, file);
565
581
  if (!existsSync(filePath) || !file.toLowerCase().endsWith('.mpkg')) { json(res, 404, { ok: false, error: 'not found' }); return; }
566
582
  // ①(新) 缓存:mtime 未变 → 直接返回缓存 bytes(不再解析头部/打开大文件)
567
583
  const st = statSync(filePath);
568
- const cached = mpkgPreviewGet(file);
584
+ const cacheKey = (folder ? folder + '/' : '') + file;
585
+ const cached = mpkgPreviewGet(cacheKey);
569
586
  if (cached && cached.mtimeMs === st.mtimeMs && cached.bytes) {
570
587
  res.writeHead(200, { 'content-type': cached.mime, 'content-length': cached.bytes.length, 'cache-control': 'no-cache' });
571
588
  res.end(cached.bytes);
@@ -592,7 +609,7 @@ function apply(ctx) {
592
609
  if (n <= 0) break; got += n;
593
610
  }
594
611
  } finally { closeSync(fd); }
595
- mpkgPreviewSet(file, { mtimeMs: st.mtimeMs, mime, bytes: buf });
612
+ mpkgPreviewSet(cacheKey, { mtimeMs: st.mtimeMs, mime, bytes: buf });
596
613
  res.writeHead(200, { 'content-type': mime, 'content-length': buf.length, 'cache-control': 'no-cache' });
597
614
  res.end(buf);
598
615
  return;
@@ -1003,7 +1020,53 @@ function apply(ctx) {
1003
1020
  }
1004
1021
  };
1005
1022
  for (const root of projectsRoots) scan(root);
1006
- json(res, 200, { ok: true, installDir, wallpapers });
1023
+ // ①(新) WE 原生播放列表(config.json → general.playlists):解析为
1024
+ // 轮播列表(项 = 壁纸路径 → 映射到本插件的 key:steam|ltoken / custom|目录名)
1025
+ const playlists = [];
1026
+ try {
1027
+ const cfgPath = join(installDir, 'config.json');
1028
+ if (existsSync(cfgPath)) {
1029
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
1030
+ const byFolder = new Map(); // 文件夹名 → ltoken
1031
+ for (const [lt, rec] of library) {
1032
+ const base = String(rec.dir || '').split(/[\\/]/).filter(Boolean).pop() || '';
1033
+ if (base && !byFolder.has(base)) byFolder.set(base.toLowerCase(), lt);
1034
+ }
1035
+ const seen = new Set();
1036
+ for (const profile of Object.values(cfg || {})) {
1037
+ if (!profile || typeof profile !== 'object') continue;
1038
+ const general = profile.general || {};
1039
+ const rows = Array.isArray(general.playlists) ? general.playlists : [];
1040
+ for (const row of rows) {
1041
+ const items = Array.isArray(row.items) ? row.items.filter((x) => typeof x === 'string' && x.trim()) : [];
1042
+ if (!items.length) continue;
1043
+ const keys = [];
1044
+ for (const item of items) {
1045
+ // "…/workshop/content/431960/<folder>/…" → 文件夹名 → ltoken
1046
+ const m = /[\\/]431960[\\/]([^\\/]+)(?:[\\/]|$)/i.exec(item);
1047
+ const folder = m ? m[1] : /[\\/]([^\\/]+)[\\/][^\\/]+$/i.exec(item) ? /[\\/]([^\\/]+)[\\/][^\\/]+$/i.exec(item)[1] : null;
1048
+ if (!folder) continue;
1049
+ const lt = byFolder.get(folder.toLowerCase());
1050
+ if (lt) keys.push('steam|' + lt);
1051
+ else if (customDir && existsSync(join(customDir, folder))) keys.push('custom|' + folder);
1052
+ }
1053
+ if (!keys.length) continue;
1054
+ const name = typeof row.name === 'string' && row.name.trim() ? row.name.trim() : 'WE Playlist';
1055
+ const sig = name + '\u0000' + keys.join('\u0000');
1056
+ if (seen.has(sig)) continue;
1057
+ seen.add(sig);
1058
+ const settings = row.settings && typeof row.settings === 'object' ? row.settings : {};
1059
+ playlists.push({
1060
+ name,
1061
+ keys,
1062
+ order: settings.order === 'random' ? 'random' : 'sequence',
1063
+ interval: typeof settings.delay === 'number' && settings.delay > 0 ? Math.min(1440, Math.round(settings.delay)) : 5,
1064
+ });
1065
+ }
1066
+ }
1067
+ }
1068
+ } catch { /* 播放列表读取失败不阻塞 */ }
1069
+ json(res, 200, { ok: true, installDir, wallpapers, playlists });
1007
1070
  } catch (err) {
1008
1071
  json(res, 500, { ok: false, error: String(err && err.message || err) });
1009
1072
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mpkg-wallpaper",
3
- "version": "3.1.4",
3
+ "version": "3.1.5",
4
4
  "description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
5
5
  "private": false,
6
6
  "type": "module",