// Wallpaper Engine 网页壁纸 API 模拟器(开发用具,**不随壁纸发布**)
//
// 为什么它不在 dist/ 里:
// 1. dist/ 只应包含壁纸真正加载的东西。它没有任何入口引用,属于死重。
// 2. 更要紧的是它的启用判据不安全。isWallpaperEngineEnvironment() 要求
// window.wallpaperRegisterListener 是函数,但**真实 Wallpaper Engine 里并没有这个 API**
// (已实测:WE 2.8.0.485 / CEF 146 只注入 window.wallpaperPropertyListener)。
// 也就是说在真实 WE 中该判据会返回 false,模拟器会自我激活并覆盖真实 API。
// 所以绝不能按下面的旧说明把它挂进 dist/index.html。
// 3. 需要模拟环境时,用 tools/serve.mjs 的测试驱动(它注入的是可精确控制的桩)。
//
// 保留它是为了在浏览器里手工调试 WE 属性与音频可视化,加载与否由使用者显式决定。
//
// 原说明(已失效,勿照做):在 index.html 中以
//
// 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。
// 仅在非 Wallpaper Engine 环境下启用,尽可能还原原生处理方式
/* ============================================================
* 环境检测 —— 多维度判断,避免误判
* ============================================================ */
function isWallpaperEngineEnvironment() {
if (typeof window.wallpaperRegisterListener !== 'function') return false;
if (typeof window.wallpaperPropertyListener !== 'object' ||
window.wallpaperPropertyListener === null) return false;
const nativeAPIs = [
'wallpaperRequestFile',
'wallpaperGetUserDataPath',
'wallpaperRegisterAudioListener'
];
const hasNativeAPI = nativeAPIs.some(api => typeof window[api] === 'function');
if (!hasNativeAPI) return false;
const ua = navigator.userAgent.toLowerCase();
const isCEF = ua.includes('cef') || ua.includes('wallpaper');
if (!isCEF) return false;
return true;
}
/* ============================================================
* 工具函数
* ============================================================ */
const WEUtils = {
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('');
},
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}`;
},
filePathToUrl(path) {
if (!path) return '';
if (path.startsWith('file:///')) return path;
if (path.startsWith('http://') || path.startsWith('https://')) return path;
if (path.startsWith('blob:') || path.startsWith('data:')) return path;
let normalized = path.replace(/\\/g, '/');
if (!normalized.startsWith('/')) normalized = '/' + normalized;
return 'file:///' + normalized.replace(/^\//, '');
},
debounce(fn, wait = 150) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
},
createFpsLimiter(fps) {
let lastTime = 0;
const interval = 1000 / Math.max(1, fps);
return function (callback) {
return function loop(timestamp) {
if (timestamp - lastTime >= interval) {
lastTime = timestamp;
callback(timestamp);
}
requestAnimationFrame(loop);
};
};
},
/** 生成稳定唯一 ID(用于 group 元素) */
uid() {
return 'we_' + Math.random().toString(36).slice(2, 9);
}
};
/* ============================================================
* 条件表达式求值器
* ============================================================ */
class ConditionEvaluator {
constructor(getPropertyValue) {
this.getPropertyValue = getPropertyValue;
this._cache = new Map();
}
evaluate(condition) {
if (!condition || typeof condition !== 'string') return true;
try {
const expr = condition.replace(
/([A-Za-z_$][\w$]*)\.value/g,
(_, key) => {
const v = this.getPropertyValue(key);
if (v === undefined || v === null) return 'undefined';
if (typeof v === 'string') return JSON.stringify(v);
if (typeof v === 'boolean' || typeof v === 'number') {
return String(v);
}
return JSON.stringify(v);
}
);
let fn = this._cache.get(expr);
if (!fn) {
fn = 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} */
this.properties = {};
/** @type {Record} */
this.propertyCache = {};
this.propertyListeners = [];
this.generalListeners = [];
this.audioListeners = [];
this.userDataPath = '';
// FPS
this._fps = 60;
this._originalRAF = null;
this._rafInstalled = false;
// 音频
this._audioContext = null;
this._audioAnalyser = null;
this._audioData = null;
this._audioLoopId = null;
// DOM
this.panelEl = null;
this.styleEl = null;
this.fieldEls = new Map();
this.groupEls = new Map();
// 目录文件追踪(用于 filesRemoved)
this._directoryFiles = new Map();
// 鼠标
this._mouse = {
isOverPanel: false,
hideTimer: null,
EDGE_ZONE: 60,
HIDE_DELAY: 400
};
// 持久化 key
this._storageKey = '__we_properties__';
this.conditionEvaluator = new ConditionEvaluator(
key => this.properties[key]?.value
);
this.initialize();
}
/* --------------------------------------------------------
* 初始化
* -------------------------------------------------------- */
async initialize() {
try {
await this.loadProjectData();
this.initializeProperties();
this.restoreFromStorage();
this.initializeAPI();
this.setupMouseListeners();
this.setupPauseListener();
this.checkProtocol();
this.checkCookieAvailability();
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)
};
}
if (this.properties.schemecolor) {
const hex = WEUtils.colorToHex(this.properties.schemecolor.value);
document.documentElement.style.setProperty('--we-theme-color', hex);
}
}
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 'file': return '';
case 'directory': return '';
case 'texture': return '';
case 'group': return null;
case 'usershortcut': return null;
case 'text': return '';
default: return null;
}
}
/* --------------------------------------------------------
* localStorage 持久化
* 模拟 WE 保存/恢复用户属性
* -------------------------------------------------------- */
restoreFromStorage() {
try {
const raw = localStorage.getItem(this._storageKey);
if (!raw) return;
const saved = JSON.parse(raw);
let restoredCount = 0;
for (const [key, value] of Object.entries(saved)) {
if (this.properties[key] &&
this.propertyCache[key]?.type !== 'group' &&
this.propertyCache[key]?.type !== 'usershortcut') {
this.properties[key].value = value;
restoredCount++;
}
}
if (restoredCount > 0) {
console.log(
'[WallpaperEngine] 已从 localStorage 恢复',
restoredCount,
'个属性值'
);
}
// 更新主题色
if (this.properties.schemecolor) {
const hex = WEUtils.colorToHex(this.properties.schemecolor.value);
document.documentElement.style.setProperty('--we-theme-color', hex);
}
} catch (e) {
console.warn('[WallpaperEngine] 恢复持久化属性失败:', e);
}
}
saveToStorage(name, value) {
try {
const raw = localStorage.getItem(this._storageKey);
const saved = raw ? JSON.parse(raw) : {};
saved[name] = value;
localStorage.setItem(this._storageKey, JSON.stringify(saved));
} catch (e) {
// localStorage 可能被禁用或已满,静默忽略
}
}
clearStorage() {
try {
localStorage.removeItem(this._storageKey);
console.log('[WallpaperEngine] 已清除持久化属性');
} catch (e) { /* ignore */ }
}
/* --------------------------------------------------------
* 协议 / Cookie 检测
* -------------------------------------------------------- */
checkProtocol() {
const proto = location.protocol;
if (proto === 'file:') {
console.log(
'[WallpaperEngine] 当前为 file:// 协议:' +
'XMLHttpRequest 可以访问同目录文件,但 fetch 可能被阻止。'
);
} else {
console.log(
'[WallpaperEngine] 当前为 ' + proto + ' 协议:' +
'fetch / XHR 均可正常工作。' +
'若需读取本地文件,请通过 http 服务器运行。'
);
}
}
checkCookieAvailability() {
// Wallpaper Engine 的 CEF 容器中 document.cookie 通常不可用
// 浏览器中可用,此处仅作说明
try {
const testKey = '__we_cookie_test__';
document.cookie = testKey + '=1; path=/';
const available = document.cookie.includes(testKey + '=1');
// 清理测试 cookie
document.cookie = testKey + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
if (available) {
console.log(
'[WallpaperEngine] 浏览器中 document.cookie 可用;' +
'注意 WE 原生环境中不可用,请避免依赖 cookie。'
);
}
} catch (e) { /* ignore */ }
}
/* --------------------------------------------------------
* 鼠标监听
* -------------------------------------------------------- */
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);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && this.isPanelVisible) {
this.hideSettingsPanel();
}
});
}
setupPauseListener() {
document.addEventListener('visibilitychange', () => {
const isPaused = document.hidden;
this._emit('setPaused', isPaused);
});
}
_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;
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');
}
/* --------------------------------------------------------
* 面板构造(含 group 折叠支持)
* -------------------------------------------------------- */
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 = `
`;
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();
this.groupEls.clear();
// 当前激活的 group 容器
let currentGroup = null;
for (const { key, def } of sorted) {
// group 类型:创建折叠容器
if (def.type === 'group') {
const group = document.createElement('details');
group.className = 'we-group';
group.open = true;
const gid = WEUtils.uid();
group.dataset.groupKey = key;
const summary = document.createElement('summary');
summary.className = 'we-group__summary';
// 支持 HTML
summary.innerHTML = def.text || key;
group.appendChild(summary);
content.appendChild(group);
this.groupEls.set(key, group);
currentGroup = group;
continue;
}
const row = this.createFieldRow(key, def);
if (row) {
(currentGroup || content).appendChild(row);
const ctrl = row.querySelector('[data-we-key]');
if (ctrl) this.fieldEls.set(key, ctrl);
}
}
// 底部工具条
content.appendChild(this.buildToolsBar());
panel.appendChild(header);
panel.appendChild(content);
document.body.appendChild(panel);
this.panelEl = panel;
this.refreshConditionalVisibility();
}
/**
* 底部工具条:清除持久化 / 音频可视化开关
*/
buildToolsBar() {
const bar = document.createElement('div');
bar.className = 'we-tools';
// 清除持久化
const clearBtn = document.createElement('button');
clearBtn.className = 'we-tool-btn';
clearBtn.textContent = '清除持久化数据';
clearBtn.title = '清除 localStorage 中保存的属性值并刷新';
clearBtn.addEventListener('click', () => {
this.clearStorage();
location.reload();
});
// 音频可视化
const audioBtn = document.createElement('button');
audioBtn.className = 'we-tool-btn';
audioBtn.textContent = this._audioContext ? '关闭音频可视化' : '启用音频可视化';
audioBtn.title = '需要用户授权选择音频源';
audioBtn.addEventListener('click', async () => {
if (this._audioContext) {
this.disableAudioVisualization();
audioBtn.textContent = '启用音频可视化';
} else {
await this.enableAudioVisualization();
audioBtn.textContent = this._audioContext
? '关闭音频可视化'
: '启用音频可视化';
}
});
bar.appendChild(clearBtn);
bar.appendChild(audioBtn);
return bar;
}
/* --------------------------------------------------------
* 单属性控件
* -------------------------------------------------------- */
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';
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);
case 'file': return this.buildFileRow(row, key, def, label);
case 'directory': return this.buildDirectoryRow(row, key, def, label);
case 'texture': return this.buildTextureRow(row, key, def, label);
case 'usershortcut': return this.buildUserShortcutRow(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';
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 = `
`;
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);
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;
}
buildFileRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const wrap = document.createElement('div');
wrap.className = 'we-file-wrap';
const input = document.createElement('input');
input.type = 'file';
input.className = 'we-file-input';
input.id = key;
input.dataset.weKey = key;
input.accept = def.fileType || '';
const display = document.createElement('div');
display.className = 'we-file-display';
display.textContent = this.properties[key]?.value || '未选择文件';
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
display.textContent = file.name;
this.updateProperty(key, url);
});
wrap.appendChild(input);
wrap.appendChild(display);
row.appendChild(wrap);
return row;
}
buildDirectoryRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const wrap = document.createElement('div');
wrap.className = 'we-file-wrap';
const input = document.createElement('input');
input.type = 'file';
input.className = 'we-file-input';
input.id = key;
input.dataset.weKey = key;
input.webkitdirectory = true;
input.multiple = true;
const display = document.createElement('div');
display.className = 'we-file-display';
display.textContent = this.properties[key]?.value || '未选择目录';
input.addEventListener('change', () => {
const files = Array.from(input.files);
if (files.length === 0) return;
const dirPath = files[0].webkitRelativePath.split('/')[0];
display.textContent = `${dirPath} (${files.length} 个文件)`;
this.updateProperty(key, dirPath);
// 触发 AddedOrChanged / Removed 事件
const newFileList = files.map(f => f.name);
const oldFileList = this._directoryFiles.get(key) || [];
const added = newFileList.filter(f => !oldFileList.includes(f));
const removed = oldFileList.filter(f => !newFileList.includes(f));
if (added.length > 0) {
this._emitUserDirectoryEvent(
'userDirectoryFilesAddedOrChanged', key, added
);
}
if (removed.length > 0) {
this._emitUserDirectoryEvent(
'userDirectoryFilesRemoved', key, removed
);
}
this._directoryFiles.set(key, newFileList);
});
wrap.appendChild(input);
wrap.appendChild(display);
row.appendChild(wrap);
return row;
}
/**
* texture 类型:图像/视频纹理
* 浏览器降级:使用 + ObjectURL
* WE 原生:返回 file:/// 路径
*/
buildTextureRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const wrap = document.createElement('div');
wrap.className = 'we-file-wrap';
const input = document.createElement('input');
input.type = 'file';
input.className = 'we-file-input';
input.id = key;
input.dataset.weKey = key;
input.accept = 'image/*,video/*';
const display = document.createElement('div');
display.className = 'we-file-display';
const currentValue = this.properties[key]?.value || '';
display.textContent = currentValue
? decodeURIComponent(currentValue.split('/').pop() || '')
: '未选择纹理';
// 小预览
const preview = document.createElement('div');
preview.className = 'we-texture-preview';
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
display.textContent = file.name;
preview.innerHTML = '';
if (file.type.startsWith('video/')) {
const v = document.createElement('video');
v.src = url;
v.muted = true;
v.autoplay = true;
v.loop = true;
preview.appendChild(v);
} else {
const img = document.createElement('img');
img.src = url;
img.alt = file.name;
preview.appendChild(img);
}
this.updateProperty(key, url);
});
wrap.appendChild(input);
wrap.appendChild(display);
wrap.appendChild(preview);
row.appendChild(wrap);
return row;
}
/**
* usershortcut 类型:用户定义的快捷方式
* Web 壁纸无法触发,仅作只读展示
*/
buildUserShortcutRow(row, key, def, label) {
label.htmlFor = key;
row.appendChild(label);
const info = document.createElement('div');
info.className = 'we-field__text';
info.textContent = '用户快捷方式(仅 SceneScript 可用,Web 壁纸中不可触发)';
info.style.fontStyle = 'italic';
info.style.color = '#7a7a88';
row.appendChild(info);
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)) {
// group 元素的条件
const groupEl = this.groupEls.get(key);
if (groupEl && def.condition) {
const visible = this.conditionEvaluator.evaluate(def.condition);
groupEl.classList.toggle('we-field--hidden', !visible);
}
// 普通字段
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.saveToStorage(name, value);
// 通知监听器
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
);
}
}
}
/* --------------------------------------------------------
* FPS 限制:包装全局 requestAnimationFrame
* -------------------------------------------------------- */
installGlobalFpsLimiter(fps) {
if (this._rafInstalled) return;
this._originalRAF = window.requestAnimationFrame;
const interval = 1000 / Math.max(1, fps);
let lastTime = 0;
const originalRAF = this._originalRAF;
window.requestAnimationFrame = function (callback) {
return originalRAF.call(window, function (timestamp) {
if (timestamp - lastTime >= interval) {
lastTime = timestamp;
try {
callback(timestamp);
} catch (err) {
console.error(
'[WallpaperEngine] RAF 回调抛错:', err
);
}
}
// 注意:这里不再循环调用 RAF
// 由调用者自己决定是否继续请求下一帧
});
};
this._rafInstalled = true;
console.log('[WallpaperEngine] 全局 RAF 已限制为', fps, 'fps');
}
uninstallGlobalFpsLimiter() {
if (!this._rafInstalled || !this._originalRAF) return;
window.requestAnimationFrame = this._originalRAF;
this._rafInstalled = false;
console.log('[WallpaperEngine] 全局 RAF 已恢复');
}
setFpsLimit(fps) {
this._fps = fps;
if (fps > 0 && fps < 1000) {
this.installGlobalFpsLimiter(fps);
}
}
/* --------------------------------------------------------
* 样式
* -------------------------------------------------------- */
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;
}
/* group 折叠容器 */
.we-group {
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
padding: 8px 12px 0;
background: rgba(255,255,255,0.02);
}
.we-group__summary {
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #e6e6ee;
padding: 6px 0 8px;
user-select: none;
list-style: none;
position: relative;
padding-left: 18px;
}
.we-group__summary::-webkit-details-marker { display: none; }
.we-group__summary::before {
content: "▸";
position: absolute;
left: 0;
top: 6px;
transition: transform 0.2s;
color: var(--we-theme-color);
}
.we-group[open] > .we-group__summary::before {
transform: rotate(90deg);
}
.we-group > .we-field:last-child {
margin-bottom: 12px;
}
/* 开关 */
.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-file-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.we-file-input {
display: none;
}
.we-file-display {
padding: 9px 11px;
background: rgba(255,255,255,0.06);
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 6px;
font-size: 13px;
color: #9a9aa8;
cursor: pointer;
text-align: center;
transition: border-color 0.15s, color 0.15s;
word-break: break-all;
}
.we-file-display:hover {
border-color: var(--we-theme-color);
color: #f0f0f5;
}
.we-texture-preview {
border-radius: 6px;
overflow: hidden;
max-height: 120px;
display: flex;
justify-content: center;
background: rgba(0,0,0,0.2);
}
.we-texture-preview:empty { display: none; }
.we-texture-preview img,
.we-texture-preview video {
max-width: 100%;
max-height: 120px;
display: block;
}
/* 底部工具条 */
.we-tools {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(255,255,255,0.08);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.we-tool-btn {
flex: 1;
min-width: 120px;
padding: 8px 12px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #e6e6ee;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.we-tool-btn:hover {
background: rgba(255,255,255,0.1);
border-color: var(--we-theme-color);
color: #fff;
}
/* 滚动条 */
.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);
}
`;
}
/* --------------------------------------------------------
* 全局 API 注入
* -------------------------------------------------------- */
initializeAPI() {
if (!window.wallpaperPropertyListener) {
window.wallpaperPropertyListener = {};
}
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);
}
};
}
if (!window.wallpaperGetUserDataPath) {
window.wallpaperGetUserDataPath = () => this.userDataPath;
}
if (!window.wallpaperRequestFile) {
window.wallpaperRequestFile = (filename) => {
console.warn(
'[WallpaperEngine] wallpaperRequestFile 在浏览器中不可用:',
filename
);
return null;
};
}
if (!window.wallpaperRegisterAudioListener) {
window.wallpaperRegisterAudioListener = (callback) => {
if (typeof callback !== 'function') return;
this.audioListeners.push({ applyAudioProperties: callback });
};
}
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) {
let lists;
switch (method) {
case 'applyGeneralProperties':
lists = this.generalListeners;
break;
case 'applyUserProperties':
lists = this.propertyListeners;
break;
case 'setPaused':
lists = [...this.generalListeners, ...this.propertyListeners];
break;
default:
lists = this.propertyListeners;
}
for (const listener of lists) {
try {
listener[method]?.(payload);
} catch (err) {
console.error(
`[WallpaperEngine] ${method} 抛错:`, err
);
}
}
}
_emitUserDirectoryEvent(eventName, propertyName, files) {
const lists = [...this.generalListeners, ...this.propertyListeners];
for (const listener of lists) {
try {
listener[eventName]?.(propertyName, files);
} catch (err) {
console.error(
`[WallpaperEngine] ${eventName} 抛错:`, err
);
}
}
}
/* --------------------------------------------------------
* 音频可视化
* -------------------------------------------------------- */
async enableAudioVisualization() {
if (this._audioContext) return;
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
this._audioContext = new AudioContext();
const source = this._audioContext.createMediaStreamSource(stream);
this._audioAnalyser = this._audioContext.createAnalyser();
this._audioAnalyser.fftSize = 256;
source.connect(this._audioAnalyser);
this._audioData = new Uint8Array(
this._audioAnalyser.frequencyBinCount
);
const updateInterval = 1000 / 30;
let lastUpdate = 0;
const update = (timestamp) => {
if (timestamp - lastUpdate >= updateInterval) {
lastUpdate = timestamp;
this._audioAnalyser.getByteFrequencyData(this._audioData);
const normalized = Array.from(this._audioData).map(
v => Math.min(1, v / 255)
);
const left = normalized.slice(0, 64);
const right = normalized.slice(64, 128);
const audioArray = [...left, ...right];
for (const listener of this.audioListeners) {
try {
listener.applyAudioProperties?.(audioArray);
} catch (err) {
console.error(
'[WallpaperEngine] 音频回调抛错:', err
);
}
}
}
this._audioLoopId = requestAnimationFrame(update);
};
this._audioLoopId = requestAnimationFrame(update);
console.log('[WallpaperEngine] 音频可视化已启用');
} catch (err) {
console.warn(
'[WallpaperEngine] 音频可视化启用失败(需要用户授权):',
err
);
}
}
disableAudioVisualization() {
if (this._audioLoopId) {
cancelAnimationFrame(this._audioLoopId);
this._audioLoopId = null;
}
if (this._audioContext) {
this._audioContext.close();
this._audioContext = null;
this._audioAnalyser = null;
this._audioData = null;
}
console.log('[WallpaperEngine] 音频可视化已关闭');
}
/* --------------------------------------------------------
* 初始属性推送
* WE 行为:加载时先 applyGeneralProperties,再逐条 applyUserProperties
* -------------------------------------------------------- */
simulateWallpaperEngine() {
const run = () => {
setTimeout(() => {
// 1. 通用属性(FPS)
this.setFpsLimit(60);
this._emit('applyGeneralProperties', { fps: 60 });
// 2. 逐条推送用户属性
for (const [key, prop] of Object.entries(this.properties)) {
const def = this.propertyCache[key];
// group / usershortcut 不推送
if (def?.type === 'group' || def?.type === 'usershortcut') {
continue;
}
this._emit('applyUserProperties', {
[key]: { value: prop.value }
});
}
console.log('[WallpaperEngine] 已推送初始属性到监听器');
}, 100);
};
if (document.readyState === 'complete') run();
else window.addEventListener('load', run, { once: true });
}
}
/* ============================================================
* 启动
* ============================================================ */
function bootstrap() {
if (isWallpaperEngineEnvironment()) {
console.log(
'[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器'
);
return;
}
console.log('[WallpaperEngine] 未检测到原生环境,启动模拟器');
const simulator = new WallpaperEngineSimulator();
const check = setInterval(() => {
if (simulator.isInitialized) {
clearInterval(check);
simulator.simulateWallpaperEngine();
console.log('[WallpaperEngine] 模拟环境已启动');
}
}, 50);
setTimeout(() => clearInterval(check), 5000);
// 调试入口
window.__WE_SIMULATOR__ = simulator;
window.__WE_ENABLE_AUDIO__ = () => simulator.enableAudioVisualization();
window.__WE_DISABLE_AUDIO__ = () => simulator.disableAudioVisualization();
window.__WE_CLEAR_STORAGE__ = () => simulator.clearStorage();
window.__WE_SET_FPS__ = (fps) => simulator.setFpsLimit(fps);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else {
bootstrap();
}
export default {
isWallpaperEngineEnvironment,
WallpaperEngineSimulator,
WEUtils
};