// 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 };