取景(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 / 属性 / 全量验收三套浏览器测试脚本。
1589 lines
52 KiB
JavaScript
1589 lines
52 KiB
JavaScript
// 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
|
||
}; |