Files
SpineWallpaper/dist/scripts/wallpaper-engine.js
T
Shuery 4662856963 fix: 初始化竞态、用户级覆盖语义与死代码(重构四个控制器)
初始化竞态(已实证):旧 changeConfig() 整个函数体都在 if (this.player) 里,含配置赋值,
WE 在 window.load 之前下发属性时被静默丢弃,导致背景/音乐已切到新壁纸、立绘还是旧的。
改为「状态先写、应用后置」:index.js 用单一 state 记录(presetId/audioSource/audioVolume),
render() 决定能否立刻应用;setConfig() 无条件记录配置,create() 用记下的配置创建。

- 删除 index.js 里重复的 wallpaperRegisterListener(该 API 在本机 WE 不存在)
- 删除 adjustCanvas():播放器每帧自己做 renderer.resize(1) 且已注册 resize 监听,
  而它用 getBoundingClientRect() 写 canvas.width 却不乘 DPR,属于有害的重复实现
- 用户级音源/音量覆盖:切壁纸不再清回预设默认值(旧 PresetController 的启发式已移除)
- 同源设置改为 no-op:否则两档壁纸共用同一首曲子时一切换就从头播放
- 只在壁纸真的变了才重建播放器,否则拖动音量滑块会触发重新下载 3.6MB 骨架+贴图
- 未知 preset id 保持现状并告警,不再静默回退

验证:属性在 DOMContentLoaded 下发时玩家动画正确为 animation(kv37);
用户自定义音源 custom-track.mp3 与音量 0.2 在切壁纸后完整保留;
重发同一预设时音乐从 1.3s 连续到 1.9s 未重启;未知 id 不崩。
对拍:与重构前同强冻结设置 8 批次 6 配置逐像素完全相同(差异 0)。
2026-09-19 19:53:15 +08:00

975 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// wallpaper-engine.js
// Wallpaper Engine 网页壁纸 API 模拟器(浏览器调试环境)
// 完全兼容 Wallpaper Engine 网页壁纸 API 规范
//
// 使用方式:在 index.html 中以 <script type="module" src="scripts/wallpaper-engine.js"></script>
// 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。
/* ============================================================
* 工具
* ============================================================ */
const WEUtils = {
/** Wallpaper Engine 颜色值(归一化浮点 "r g b")→ HEX */
colorToHex(value) {
if (typeof value !== "string") return "#000000";
const parts = value.trim().split(/\s+/).map(Number);
if (parts.length < 3 || parts.some(isNaN)) return "#000000";
return (
"#" +
parts
.slice(0, 3)
.map((n) => {
const v = Math.max(0, Math.min(255, Math.round(n * 255)));
return v.toString(16).padStart(2, "0");
})
.join("")
);
},
/** HEX → Wallpaper Engine 颜色值 */
hexToColor(hex) {
if (typeof hex !== "string" || hex[0] !== "#" || hex.length < 7)
return "0 0 0";
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return `${r} ${g} ${b}`;
},
/** Wallpaper Engine 文件路径 → 可用 URL */
filePathToUrl(path) {
if (!path) return "";
if (path.startsWith("file:///")) return path;
if (path.startsWith("file://")) return "file:///" + path.slice(7);
return path;
},
/** 简单 debounce */
debounce(fn, wait = 150) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
},
};
/* ============================================================
* 条件表达式求值器
* 支持:value == x / value != x / && / || / ! / ()
* ============================================================ */
class ConditionEvaluator {
constructor(getPropertyValue) {
this.getPropertyValue = getPropertyValue;
this._cache = new Map();
}
/**
* @param {string} condition 例如 "show_author_info.value == true"
* @returns {boolean}
*/
evaluate(condition) {
if (!condition || typeof condition !== "string") return true;
try {
// 把 "xxx.value" 替换为对应的字面量
const expr = condition.replace(/([A-Za-z_$][\w$]*)\.value/g, (_, key) => {
const v = this.getPropertyValue(key);
if (typeof v === "string") return JSON.stringify(v);
if (v === undefined || v === null) return "undefined";
if (typeof v === "boolean" || typeof v === "number") return String(v);
return JSON.stringify(v);
});
// 使用 Function 仅在纯布尔表达式上求值
// 该表达式来源可信(project.json 由壁纸作者提供),且已做 key 白名单替换
const fn =
this._cache.get(expr) ||
new Function(`"use strict"; return Boolean(${expr});`);
this._cache.set(expr, fn);
return fn();
} catch (err) {
console.warn("[WallpaperEngine] 条件求值失败:", condition, err);
return true;
}
}
}
/* ============================================================
* 主模拟器
* ============================================================ */
class WallpaperEngineSimulator {
constructor() {
this.isInitialized = false;
this.isPanelVisible = false;
this.projectData = null;
/** @type {Record<string, {value:any}>} */
this.properties = {};
/** @type {Record<string, any>} 属性原始定义 */
this.propertyCache = {};
this.propertyListeners = [];
this.generalListeners = [];
this.audioListeners = [];
this.userDataPath = "";
// DOM 元素缓存
this.panelEl = null;
this.styleEl = null;
this.fieldEls = new Map();
// 鼠标状态
this._mouse = {
isOverPanel: false,
hideTimer: null,
EDGE_ZONE: 60,
HIDE_DELAY: 400,
};
this.conditionEvaluator = new ConditionEvaluator(
(key) => this.properties[key]?.value,
);
this.initialize();
}
/* --------------------------------------------------------
* 初始化
* -------------------------------------------------------- */
async initialize() {
try {
await this.loadProjectData();
this.initializeProperties();
this.initializeAPI();
this.setupMouseListeners();
this.isInitialized = true;
console.log(
"[WallpaperEngine] 模拟器已初始化,共",
Object.keys(this.properties).length,
"个属性",
);
} catch (err) {
console.error("[WallpaperEngine] 初始化失败:", err);
}
}
async loadProjectData() {
try {
const res = await fetch("./project.json", { cache: "no-cache" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.projectData = await res.json();
console.log("[WallpaperEngine] project.json 已加载");
} catch (err) {
console.warn(
"[WallpaperEngine] 无法加载 project.json,使用内置默认配置:",
err,
);
this.projectData = this.getFallbackProjectData();
}
}
getFallbackProjectData() {
return {
title: "壁纸设置",
general: { properties: {} },
};
}
initializeProperties() {
const props = this.projectData?.general?.properties;
if (!props) {
console.warn("[WallpaperEngine] project.json 缺少 general.properties");
return;
}
for (const [key, def] of Object.entries(props)) {
this.propertyCache[key] = def;
this.properties[key] = {
value:
def.value !== undefined ? def.value : this.getDefaultValue(def.type),
};
}
}
getDefaultValue(type) {
switch (type) {
case "bool":
return false;
case "combo":
return "";
case "slider":
return 0;
case "textinput":
return "";
case "color":
return "0 0 0";
case "text":
return "";
default:
return null;
}
}
/* --------------------------------------------------------
* 鼠标监听
* -------------------------------------------------------- */
setupMouseListeners() {
document.addEventListener("mousemove", (e) => {
const { EDGE_ZONE } = this._mouse;
const nearRightEdge = e.clientX >= window.innerWidth - EDGE_ZONE;
if (nearRightEdge && !this.isPanelVisible) {
this.showSettingsPanel();
} else if (
!nearRightEdge &&
!this._mouse.isOverPanel &&
this.isPanelVisible
) {
this._scheduleHide();
}
});
// 悬停在面板内部时取消隐藏
document.addEventListener("mouseover", (e) => {
const inside = this.panelEl && this.panelEl.contains(e.target);
if (inside !== this._mouse.isOverPanel) {
this._mouse.isOverPanel = inside;
if (inside) this._cancelHide();
}
});
// 鼠标离开窗口
document.addEventListener("mouseleave", () => {
this._mouse.isOverPanel = false;
this._scheduleHide(200);
});
// ESC 关闭
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.isPanelVisible) {
this.hideSettingsPanel();
}
});
}
_scheduleHide(delay = this._mouse.HIDE_DELAY) {
this._cancelHide();
this._mouse.hideTimer = setTimeout(() => {
if (!this._mouse.isOverPanel) this.hideSettingsPanel();
}, delay);
}
_cancelHide() {
if (this._mouse.hideTimer) {
clearTimeout(this._mouse.hideTimer);
this._mouse.hideTimer = null;
}
}
/* --------------------------------------------------------
* 面板显示 / 隐藏
* -------------------------------------------------------- */
showSettingsPanel() {
if (!this.projectData?.general?.properties) return;
// 单例:首次创建
if (!this.panelEl) {
this.createSettingsPanel();
}
if (!this.isPanelVisible) {
this.isPanelVisible = true;
// 强制重排以触发 transition
void this.panelEl.offsetWidth;
this.panelEl.classList.add("we-panel--visible");
}
}
hideSettingsPanel() {
if (!this.isPanelVisible || !this.panelEl) return;
this.isPanelVisible = false;
this.panelEl.classList.remove("we-panel--visible");
}
/* --------------------------------------------------------
* 面板构造
* -------------------------------------------------------- */
createSettingsPanel() {
if (this.styleEl) this.styleEl.remove();
this.styleEl = document.createElement("style");
this.styleEl.id = "wallpaper-settings-style";
this.styleEl.textContent = this.getPanelStyles();
document.head.appendChild(this.styleEl);
const panel = document.createElement("div");
panel.id = "wallpaper-settings-panel";
panel.className = "we-panel";
// 头部
const header = document.createElement("div");
header.className = "we-panel__header";
header.innerHTML = `
<span class="we-panel__title"></span>
<button class="we-panel__close" type="button" aria-label="关闭">×</button>
`;
header.querySelector(".we-panel__title").textContent =
this.projectData.title || "壁纸设置";
header.querySelector(".we-panel__close").addEventListener("click", () => {
this.hideSettingsPanel();
});
// 内容
const content = document.createElement("div");
content.className = "we-panel__content";
const sorted = this.getSortedProperties();
this.fieldEls.clear();
for (const { key, def } of sorted) {
const row = this.createFieldRow(key, def);
if (row) {
content.appendChild(row);
// 缓存可交互的控件
const ctrl = row.querySelector("[data-we-key]");
if (ctrl) this.fieldEls.set(key, ctrl);
}
}
panel.appendChild(header);
panel.appendChild(content);
document.body.appendChild(panel);
this.panelEl = panel;
// 首次渲染后套用条件显示
this.refreshConditionalVisibility();
}
/**
* 生成单个属性控件的外层容器
*/
createFieldRow(key, def) {
if (!def) return null;
const row = document.createElement("div");
row.className = "we-field";
row.id = `we-field-${key}`;
row.dataset.condition = def.condition || "";
const label = document.createElement("label");
label.className = "we-field__label";
// 支持 text 属性里的 <br/>
label.innerHTML = def.text || key;
switch (def.type) {
case "text":
return this.buildTextRow(row, key, def);
case "bool":
return this.buildBoolRow(row, key, def, label);
case "combo":
return this.buildComboRow(row, key, def, label);
case "slider":
return this.buildSliderRow(row, key, def, label);
case "textinput":
return this.buildTextInputRow(row, key, def, label);
case "color":
return this.buildColorRow(row, key, def, label);
default:
return this.buildTextRow(row, key, def);
}
}
buildTextRow(row, key, def) {
const text = document.createElement("div");
text.className = "we-field__text";
// Wallpaper Engine 支持在 text 属性内嵌 HTML
text.innerHTML = def.text || "";
row.appendChild(text);
return row;
}
buildBoolRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const wrap = document.createElement("label");
wrap.className = "we-switch";
wrap.innerHTML = `
<input type="checkbox" data-we-key="${key}" id="${key}">
<span class="we-switch__slider"></span>
`;
const input = wrap.querySelector("input");
input.checked = Boolean(this.properties[key]?.value);
input.addEventListener("change", () => {
this.updateProperty(key, input.checked);
});
row.appendChild(wrap);
return row;
}
buildComboRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const select = document.createElement("select");
select.className = "we-select";
select.id = key;
select.dataset.weKey = key;
const cur = this.properties[key]?.value;
for (const opt of def.options || []) {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label ?? opt.value;
if (opt.value === cur) o.selected = true;
select.appendChild(o);
}
select.addEventListener("change", () => {
this.updateProperty(key, select.value);
});
row.appendChild(select);
return row;
}
buildSliderRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const min = def.min ?? 0;
const max = def.max ?? 1;
const step = def.step ?? 0.01;
const slider = document.createElement("input");
slider.type = "range";
slider.className = "we-slider";
slider.id = key;
slider.dataset.weKey = key;
slider.min = min;
slider.max = max;
slider.step = step;
slider.value = this.properties[key]?.value ?? min;
const valueLabel = document.createElement("div");
valueLabel.className = "we-slider__value";
valueLabel.textContent = this._formatSliderValue(slider.value, min, max);
// 实时更新显示 + 触发属性更新(debounce 写入属性,避免频繁触发监听器)
const pushValue = WEUtils.debounce(() => {
this.updateProperty(key, parseFloat(slider.value));
}, 60);
slider.addEventListener("input", () => {
valueLabel.textContent = this._formatSliderValue(slider.value, min, max);
pushValue();
});
// 松开后强制刷新一次
slider.addEventListener("change", () => {
this.updateProperty(key, parseFloat(slider.value));
});
row.appendChild(slider);
row.appendChild(valueLabel);
return row;
}
_formatSliderValue(v, min, max) {
const n = parseFloat(v);
if (max - min === 1 && min === 0) {
return `${Math.round(n * 100)}%`;
}
return `${n}`;
}
buildTextInputRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const input = document.createElement("input");
input.type = "text";
input.className = "we-input";
input.id = key;
input.dataset.weKey = key;
input.value = this.properties[key]?.value ?? "";
input.placeholder = def.placeholder || "";
const pushValue = WEUtils.debounce(() => {
this.updateProperty(key, input.value);
}, 300);
input.addEventListener("input", pushValue);
input.addEventListener("change", () => {
this.updateProperty(key, input.value);
});
row.appendChild(input);
return row;
}
buildColorRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const input = document.createElement("input");
input.type = "color";
input.className = "we-color";
input.id = key;
input.dataset.weKey = key;
input.value = WEUtils.colorToHex(this.properties[key]?.value || "0 0 0");
input.addEventListener("input", () => {
this.updateProperty(key, WEUtils.hexToColor(input.value));
});
row.appendChild(input);
return row;
}
/* --------------------------------------------------------
* 属性排序 / 条件刷新
* -------------------------------------------------------- */
getSortedProperties() {
return Object.keys(this.propertyCache)
.map((key) => ({
key,
def: this.propertyCache[key],
order: this.propertyCache[key]?.order ?? 0,
}))
.sort((a, b) => a.order - b.order);
}
refreshConditionalVisibility() {
for (const [key, def] of Object.entries(this.propertyCache)) {
const row = document.getElementById(`we-field-${key}`);
if (!row || !def.condition) continue;
const visible = this.conditionEvaluator.evaluate(def.condition);
row.classList.toggle("we-field--hidden", !visible);
}
}
/* --------------------------------------------------------
* 属性更新 / 通知
* -------------------------------------------------------- */
updateProperty(name, value) {
if (!this.properties[name]) return;
if (this.properties[name].value === value) return;
this.properties[name].value = value;
// 更新主题色
if (name === "schemecolor" && this.styleEl) {
const hex = WEUtils.colorToHex(value);
document.documentElement.style.setProperty("--we-theme-color", hex);
}
// 刷新条件显示
this.refreshConditionalVisibility();
// 通知监听器
this.notifyPropertyListeners(name, value);
}
notifyPropertyListeners(name, value) {
const payload = { [name]: { value } };
for (const listener of this.propertyListeners) {
try {
listener.applyUserProperties?.(payload);
} catch (err) {
console.error("[WallpaperEngine] applyUserProperties 抛错:", err);
}
}
}
/* --------------------------------------------------------
* 样式
* -------------------------------------------------------- */
getPanelStyles() {
const themeHex = WEUtils.colorToHex(
this.properties.schemecolor?.value || "0.388 0.149 0.62",
);
return `
:root { --we-theme-color: ${themeHex}; }
#wallpaper-settings-panel.we-panel {
position: fixed;
top: 0;
right: 0;
width: 320px;
height: 100vh;
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(22, 22, 30, 0.92);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-left: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.45);
color: #f0f0f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
z-index: 999999;
overflow-y: auto;
overflow-x: hidden;
will-change: transform;
}
#wallpaper-settings-panel.we-panel--visible {
transform: translateX(0);
}
/* 头部 */
.we-panel__header {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(10px);
z-index: 2;
}
.we-panel__title {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.we-panel__close {
width: 28px; height: 28px;
border: none;
background: transparent;
color: #f0f0f5;
font-size: 22px;
line-height: 1;
border-radius: 50%;
cursor: pointer;
transition: background 0.15s, transform 0.15s;
}
.we-panel__close:hover { background: rgba(255,255,255,0.1); transform: rotate(90deg); }
/* 内容区 */
.we-panel__content { padding: 16px 20px 40px; }
/* 每个属性块 */
.we-field { margin-bottom: 18px; }
.we-field--hidden { display: none !important; }
.we-field__label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #e6e6ee;
line-height: 1.4;
}
.we-field__text {
font-size: 12px;
color: #9a9aa8;
line-height: 1.55;
}
.we-field__text a {
color: var(--we-theme-color);
text-decoration: none;
transition: opacity 0.15s;
}
.we-field__text a:hover { opacity: 0.8; text-decoration: underline; }
.we-field__text small { font-size: 12px; }
.we-field__text ul,
.we-field__text ol { margin: 4px 0; padding-left: 20px; }
.we-field__text li { margin: 3px 0; }
.we-field__text p { margin: 4px 0; }
.we-field__text code {
background: rgba(255,255,255,0.08);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
}
/* 开关 */
.we-switch {
position: relative;
display: inline-block;
width: 44px; height: 24px;
cursor: pointer;
vertical-align: middle;
float: right;
margin-top: -30px;
}
.we-switch input { opacity: 0; width: 0; height: 0; }
.we-switch__slider {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.16);
border-radius: 24px;
transition: background 0.25s;
}
.we-switch__slider::before {
content: "";
position: absolute;
left: 3px; top: 3px;
width: 18px; height: 18px;
background: #fff;
border-radius: 50%;
transition: transform 0.25s;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
}
.we-switch input:checked + .we-switch__slider {
background: var(--we-theme-color);
}
.we-switch input:checked + .we-switch__slider::before {
transform: translateX(20px);
}
/* 下拉框 / 输入框 */
.we-select, .we-input {
width: 100%;
box-sizing: border-box;
padding: 9px 11px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #f0f0f5;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.we-select:hover, .we-input:hover { background: rgba(255,255,255,0.09); }
.we-select:focus, .we-input:focus {
border-color: var(--we-theme-color);
background: rgba(255,255,255,0.1);
}
.we-select option { background: #1b1b25; color: #f0f0f5; }
/* 滑块 */
.we-slider {
width: 100%;
margin: 6px 0 2px;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.14);
outline: none;
cursor: pointer;
}
.we-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--we-theme-color);
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
cursor: grab;
transition: transform 0.12s;
}
.we-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
.we-slider::-webkit-slider-thumb:active { cursor: grabbing; }
.we-slider::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: var(--we-theme-color);
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
cursor: grab;
}
.we-slider__value {
text-align: right;
font-size: 12px;
color: #9a9aa8;
font-variant-numeric: tabular-nums;
}
/* 颜色选择器 */
.we-color {
width: 100%;
height: 36px;
padding: 0;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
background: transparent;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.we-color::-webkit-color-swatch-wrapper { padding: 2px; }
.we-color::-webkit-color-swatch { border: none; border-radius: 4px; }
/* 滚动条 */
.we-panel::-webkit-scrollbar { width: 6px; }
.we-panel::-webkit-scrollbar-track { background: transparent; }
.we-panel::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.18);
border-radius: 3px;
}
.we-panel::-webkit-scrollbar-thumb:hover {
background: var(--we-theme-color);
}
`;
}
/* --------------------------------------------------------
* Wallpaper Engine 全局 API 注入
* -------------------------------------------------------- */
initializeAPI() {
// ---------- wallpaperPropertyListener ----------
if (!window.wallpaperPropertyListener) {
window.wallpaperPropertyListener = {
applyGeneralProperties: (properties) => {
this._emit("applyGeneralProperties", properties);
},
applyUserProperties: (properties) => {
this._emit("applyUserProperties", properties);
},
};
}
// ---------- wallpaperRegisterListener ----------
if (!window.wallpaperRegisterListener) {
window.wallpaperRegisterListener = (listener) => {
if (!listener || typeof listener !== "object") return;
if (typeof listener.applyGeneralProperties === "function") {
this.generalListeners.push(listener);
}
if (typeof listener.applyUserProperties === "function") {
this.propertyListeners.push(listener);
}
if (typeof listener.applyAudioProperties === "function") {
this.audioListeners.push(listener);
}
};
}
// ---------- 其它 API 占位 ----------
if (!window.wallpaperGetUserDataPath) {
window.wallpaperGetUserDataPath = () => this.userDataPath;
}
if (!window.wallpaperRequestFile) {
// Wallpaper Engine 原生:请求文件(steam 云 / 用户数据)
// 浏览器无法访问,仅返回 null 占位
window.wallpaperRequestFile = () => {
console.warn(
"[WallpaperEngine] wallpaperRequestFile 在浏览器环境中不可用",
);
return null;
};
}
if (!window.wallpaperRegisterAudioListener) {
window.wallpaperRegisterAudioListener = (cb) => {
if (typeof cb === "function")
this.audioListeners.push({ applyAudioProperties: cb });
};
}
if (!window.wallpaperGetWallpaperAuthor) {
window.wallpaperGetWallpaperAuthor = () =>
this.projectData?.general?.properties?.author?.value || "";
}
if (!window.wallpaperEngine) {
window.wallpaperEngine = {
setProperty: (name, value) => this.updateProperty(name, value),
getProperty: (name) => this.properties[name]?.value,
getUserDataPath: () => this.userDataPath,
getProjectData: () => this.projectData,
};
}
}
_emit(method, payload) {
const lists =
method === "applyGeneralProperties"
? this.generalListeners
: this.propertyListeners;
for (const listener of lists) {
try {
listener[method]?.(payload);
} catch (err) {
console.error(`[WallpaperEngine] ${method} 抛错:`, err);
}
}
}
/* --------------------------------------------------------
* 模拟初始属性推送(浏览器调试用)
* Wallpaper Engine 在壁纸加载完成后,会先调用 applyGeneralProperties,
* 再逐条调用 applyUserProperties。
* -------------------------------------------------------- */
simulateWallpaperEngine() {
const run = () => {
// 等待 index.js 中的 wallpaperRegisterListener 注册完成
setTimeout(() => {
// 1. 通用属性
this._emit("applyGeneralProperties", { fps: 60 });
// 2. 逐条推送用户属性(模拟 Wallpaper Engine 行为)
for (const [key, prop] of Object.entries(this.properties)) {
this._emit("applyUserProperties", { [key]: { value: prop.value } });
}
console.log("[WallpaperEngine] 已推送初始属性到监听器");
}, 100);
};
if (document.readyState === "complete") run();
else window.addEventListener("load", run, { once: true });
}
}
/* ============================================================
* 环境检测 & 启动
* ============================================================ */
function isWallpaperEngineEnvironment() {
return (
typeof window.wallpaperRegisterListener === "function" &&
typeof window.wallpaperPropertyListener === "object" &&
window.wallpaperPropertyListener !== null
);
}
function bootstrap() {
if (isWallpaperEngineEnvironment()) {
console.log(
"[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器",
);
return;
}
const simulator = new WallpaperEngineSimulator();
const check = setInterval(() => {
if (simulator.isInitialized) {
clearInterval(check);
simulator.simulateWallpaperEngine();
}
}, 50);
// 兜底:5 秒后仍未初始化则强制停止轮询
setTimeout(() => clearInterval(check), 5000);
// 便于调试
window.__WE_SIMULATOR__ = simulator;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
} else {
bootstrap();
}
export default {
isWallpaperEngineEnvironment,
WallpaperEngineSimulator,
WEUtils,
};