Files
SpineWallpaper/tools/wallpaper-engine-simulator.js
T
Shuery eb56efbd9e feat: 比例适配、bgm 音源选择与 fps 限流,打包 v3
取景(issue 04)
- 新增 viewport-fitter.js:让立绘层缩放跟随背景图的 cover 缩放——背景放大多少倍、
  特效就放大多少倍。原先背景走 cover、立绘层走 contain,比例一变两者就分叉。
  16:9 下与作者设定逐像素一致(kv37 为 0 差异)。实测证伪了"contain 会裁掉作者视口"
  这个最初的判断:contain 在任何比例下都完整含住作者视口。
- 应用方式为原地改写 config.viewport 字段后调 setViewport(),不重建播放器;
  维护挂在 config.frame 上按画布比例增量进行(config.success 早于 setViewport,拿不到视口)。

WE 属性(issue 05)
- project.json 新增 bgm combo(所有壁纸音源的并集 + 「随预设」);所选项不属于当前壁纸时
  音频回落该壁纸默认,选择本身保留。
- fps 限流包裹实例 drawFrame 并接管排程。修掉两个实测缺陷:朴素丢帧会把 rAF 抖动放大成
  整拍丢失(目标 30fps 实测只有 22.5fps);fps:0 被真值判断吞掉导致无法取消限流。

打包(issue 06)
- project.json version 升至 3 并更新描述;preset 的 combo 取值保持 xilian/kv37 不变。
- preview.gif 821 KB → 347 KB(每 2 帧取 1 帧 + 128 色不抖动,逐帧与源帧像素完全相同)。
- index.html 加内联 favicon,消除 CEF 自动请求 /favicon.ico 造成的 404 报错。

其它
- dist/scripts/wallpaper-engine.js 移到 tools/:它没有任何入口引用,且环境判据依赖真实 WE
  并不存在的 wallpaperRegisterListener,一旦按它自己的说明挂进 index.html 会在生产环境自我激活。
- 修正死文件检查:原检查会拿文件名匹配到文件自身的内容,使这类文件漏网。
- 新增离线算式验证与 resize / 属性 / 全量验收三套浏览器测试脚本。
2026-09-19 20:29:25 +08:00

1589 lines
52 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 网页壁纸 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 中以
// <script type="module" src="scripts/wallpaper-engine.js"></script>
// 优先加载本脚本即可。若检测到真实的 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<string, {value:any}>} */
this.properties = {};
/** @type {Record<string, any>} */
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 = `
<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();
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 = `
<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);
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 类型:图像/视频纹理
* 浏览器降级:使用 <input type="file"> + 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
};