fix: 初始化竞态、用户级覆盖语义与死代码(重构四个控制器)
初始化竞态(已实证):旧 changeConfig() 整个函数体都在 if (this.player) 里,含配置赋值, WE 在 window.load 之前下发属性时被静默丢弃,导致背景/音乐已切到新壁纸、立绘还是旧的。 改为「状态先写、应用后置」:index.js 用单一 state 记录(presetId/audioSource/audioVolume), render() 决定能否立刻应用;setConfig() 无条件记录配置,create() 用记下的配置创建。 - 删除 index.js 里重复的 wallpaperRegisterListener(该 API 在本机 WE 不存在) - 删除 adjustCanvas():播放器每帧自己做 renderer.resize(1) 且已注册 resize 监听, 而它用 getBoundingClientRect() 写 canvas.width 却不乘 DPR,属于有害的重复实现 - 用户级音源/音量覆盖:切壁纸不再清回预设默认值(旧 PresetController 的启发式已移除) - 同源设置改为 no-op:否则两档壁纸共用同一首曲子时一切换就从头播放 - 只在壁纸真的变了才重建播放器,否则拖动音量滑块会触发重新下载 3.6MB 骨架+贴图 - 未知 preset id 保持现状并告警,不再静默回退 验证:属性在 DOMContentLoaded 下发时玩家动画正确为 animation(kv37); 用户自定义音源 custom-track.mp3 与音量 0.2 在切壁纸后完整保留; 重发同一预设时音乐从 1.3s 连续到 1.9s 未重启;未知 id 不崩。 对拍:与重构前同强冻结设置 8 批次 6 配置逐像素完全相同(差异 0)。
This commit is contained in:
1 parent
a391021041
commit
4662856963
8 files changed
+1189
-220
No files matched your search
Vendored
+51
-51
@@ -1,64 +1,64 @@
|
|||||||
|
// 背景音乐。
|
||||||
|
//
|
||||||
|
// 两条交互约定:
|
||||||
|
// 1. 音源与音量是「用户级覆盖」:切换壁纸不得把用户自定义的音源/音量清回预设默认值。
|
||||||
|
// 2. 音源相同的设置是 no-op:否则两档壁纸共用同一首曲子时,一切换就把音乐从头开始放。
|
||||||
export default class AudioController {
|
export default class AudioController {
|
||||||
constructor(audioId, options = {}) {
|
constructor(audioId, options = {}) {
|
||||||
this.audio = document.getElementById(audioId);
|
this.audio = document.getElementById(audioId);
|
||||||
this.audio.src = options.source;
|
|
||||||
this.audio.volume = options.volume;
|
|
||||||
this.audio.loop = true;
|
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
this.isChangingSource = false;
|
this.retryTimer = undefined;
|
||||||
this.previousSource = null;
|
this.audio.loop = true;
|
||||||
|
|
||||||
|
if (options.source) this.setSource(options.source);
|
||||||
|
if (options.volume !== undefined) this.setVolume(options.volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// audio.src 会被解析成绝对 URL;预设里的 source 也是绝对 URL(preset.js 用 import.meta.url 推导)
|
||||||
|
get source() {
|
||||||
|
return this.audio.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
setSource(source) {
|
||||||
|
if (!source) return;
|
||||||
|
if (this.source === new URL(source, document.baseURI).href) return;
|
||||||
|
|
||||||
|
const wasPlaying = this.isPlaying;
|
||||||
|
this.audio.src = source;
|
||||||
|
this.audio.load();
|
||||||
|
if (wasPlaying) this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume) {
|
||||||
|
const value = Math.max(0, Math.min(1, Number(volume)));
|
||||||
|
this.audio.volume = Number.isFinite(value) ? value : 1;
|
||||||
|
|
||||||
|
if (this.audio.volume <= 0) this.pause();
|
||||||
|
else if (!this.isPlaying) this.play();
|
||||||
|
}
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
if (this.isPlaying) {
|
if (this.isPlaying) return;
|
||||||
return;
|
|
||||||
}
|
this.audio.play().then(
|
||||||
if (this.isChangingSource) {
|
() => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.audio) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this.audio.play().catch((error) => {
|
},
|
||||||
this.isPlaying = false;
|
(error) => {
|
||||||
this.changeSource(this.previousSource);
|
this.isPlaying = false;
|
||||||
});
|
// 只重试一次:自动播放被拒时重试多少次都没用,重试只是覆盖"音频尚未就绪"
|
||||||
}, 200);
|
if (this.retryTimer === undefined) {
|
||||||
}
|
this.retryTimer = setTimeout(() => {
|
||||||
|
this.retryTimer = undefined;
|
||||||
|
this.play();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
}
|
}
|
||||||
changeSource(newSource) {
|
|
||||||
if (this.isChangingSource) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.audio) {
|
|
||||||
this.isChangingSource = true;
|
|
||||||
this.pause();
|
|
||||||
this.previousSource = this.audio.src;
|
|
||||||
this.audio.src = newSource;
|
|
||||||
this.isChangingSource = false;
|
|
||||||
this.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changeVolume(newVolume) {
|
|
||||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
|
||||||
this.audio.volume = clampedVolume;
|
|
||||||
this.changeStateByVolume();
|
|
||||||
}
|
|
||||||
changeStateByVolume() {
|
|
||||||
const currentVolume = this.audio.volume;
|
|
||||||
if (currentVolume > 0 && !this.isPlaying) {
|
|
||||||
this.play();
|
|
||||||
} else if (currentVolume <= 0 && this.isPlaying) {
|
|
||||||
this.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changeOptions(newOptions) {
|
|
||||||
const newSource = newOptions.source;
|
|
||||||
const newVolume = newOptions.volume;
|
|
||||||
this.changeSource(newSource);
|
|
||||||
this.changeVolume(newVolume);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+9
-10
@@ -1,14 +1,13 @@
|
|||||||
|
// 背景图:只在真的换了图时写一次样式。
|
||||||
export default class BackgroundController {
|
export default class BackgroundController {
|
||||||
constructor(backgroundImage) {
|
constructor() {
|
||||||
this.backgroundImage = backgroundImage;
|
this.appliedImage = "";
|
||||||
}
|
}
|
||||||
setImage(backgroundImage = this.backgroundImage) {
|
|
||||||
this.backgroundImage = backgroundImage;
|
setImage(backgroundImage) {
|
||||||
document.getElementsByTagName(
|
if (!backgroundImage || backgroundImage === this.appliedImage) return;
|
||||||
"body"
|
this.appliedImage = backgroundImage;
|
||||||
)[0].style.backgroundImage = `url(${backgroundImage})`;
|
// 用引号包住,避免路径里出现空格等字符时被 CSS 解析成多个值
|
||||||
}
|
document.body.style.backgroundImage = `url("${backgroundImage}")`;
|
||||||
changeImage(newBackgroundImage) {
|
|
||||||
this.setImage(newBackgroundImage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+66
-67
@@ -5,83 +5,82 @@ import BackgroundController from "./background-controller.js";
|
|||||||
import SpineController from "./spine-controller.js";
|
import SpineController from "./spine-controller.js";
|
||||||
import AudioController from "./audio-controller.js";
|
import AudioController from "./audio-controller.js";
|
||||||
|
|
||||||
let presetController = new PresetController(Presets);
|
// 与 project.json 里属性的默认值保持一致
|
||||||
|
const DEFAULT_PRESET_ID = "xilian";
|
||||||
|
const DEFAULT_VOLUME = 0.5;
|
||||||
|
|
||||||
let { backgroundImage, spineConfig, audioOptions } =
|
const presets = new PresetController(Presets);
|
||||||
presetController.set("xilian");
|
|
||||||
|
|
||||||
let backgroundController = new BackgroundController(backgroundImage);
|
const backgroundController = new BackgroundController();
|
||||||
let spineController = new SpineController("spine-container", spineConfig);
|
const spineController = new SpineController("spine-container");
|
||||||
let audioController = new AudioController("background-music", audioOptions);
|
const audioController = new AudioController("background-music");
|
||||||
|
|
||||||
let currentAudioSource = "";
|
// 唯一的真相来源。WE 可能在 window.load 之前就下发用户属性,所以这里只记录状态;
|
||||||
let currentAudioVolume = 0;
|
// 渲染时再决定"能不能立刻应用"——只有播放器需要等到 load 之后才存在。
|
||||||
|
const state = {
|
||||||
|
ready: false,
|
||||||
|
presetId: "",
|
||||||
|
audioSource: "", // 空字符串 = 用当前预设的默认音源
|
||||||
|
audioVolume: DEFAULT_VOLUME,
|
||||||
|
};
|
||||||
|
|
||||||
|
let appliedPresetId = "";
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const presetId = state.presetId || DEFAULT_PRESET_ID;
|
||||||
|
const preset = presets.get(presetId);
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
// 只有壁纸真的换了才碰背景与骨架。否则拖动音量滑块都会重建播放器
|
||||||
|
// (重建 = 重新下载 3.4 MB 骨架与全部贴图)。
|
||||||
|
if (appliedPresetId !== presetId) {
|
||||||
|
appliedPresetId = presetId;
|
||||||
|
backgroundController.setImage(preset.backgroundImage);
|
||||||
|
spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户自定义音源优先;用户没给就用当前壁纸的默认音源
|
||||||
|
audioController.setSource(state.audioSource || preset.audioOptions.source);
|
||||||
|
audioController.setVolume(state.audioVolume);
|
||||||
|
if (state.ready) audioController.play();
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("load", function () {
|
window.addEventListener("load", function () {
|
||||||
backgroundController.setImage();
|
state.ready = true;
|
||||||
spineController.create();
|
render(); // 先把状态落到各控制器(此时播放器尚不存在,只写配置)
|
||||||
audioController.play();
|
spineController.create(); // 再用写好的配置创建播放器
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
// 必须在模块顶层注册:属性事件可能在 window.load 之前到达
|
||||||
spineController.adjustCanvas();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.wallpaperRegisterListener = {
|
|
||||||
applyGeneralProperties: function (properties) {
|
|
||||||
if (properties.fps) {
|
|
||||||
// wallpaperSettings.fps = properties.fps;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
applyUserProperties: function (properties) {
|
|
||||||
if (properties.preset) {
|
|
||||||
const preset = properties.preset.value;
|
|
||||||
let { backgroundImage, spineConfig, audioOptions } =
|
|
||||||
presetController.set(preset);
|
|
||||||
backgroundController.changeImage(backgroundImage);
|
|
||||||
spineController.changeConfig(spineConfig);
|
|
||||||
audioController.changeOptions(audioOptions);
|
|
||||||
}
|
|
||||||
if (properties.audio_file) {
|
|
||||||
const audio_file = properties.audio_file.value;
|
|
||||||
currentAudioSource = audio_file;
|
|
||||||
audioController.changeSource(audio_file);
|
|
||||||
}
|
|
||||||
if (properties.audio_volume) {
|
|
||||||
const audio_volume = properties.audio_volume.value;
|
|
||||||
currentAudioVolume = audio_volume;
|
|
||||||
audioController.changeVolume(audio_volume);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
window.wallpaperPropertyListener = {
|
window.wallpaperPropertyListener = {
|
||||||
|
applyUserProperties: function (properties) {
|
||||||
|
// 先写状态:无条件记录,与播放器是否存在、是否加载完成无关
|
||||||
|
if (properties.preset) {
|
||||||
|
const presetId = properties.preset.value;
|
||||||
|
if (presets.has(presetId)) {
|
||||||
|
state.presetId = presetId;
|
||||||
|
} else if (presetId) {
|
||||||
|
console.warn(`忽略未知的预设 id:${presetId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.audio_file) {
|
||||||
|
state.audioSource = String(properties.audio_file.value || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.audio_volume) {
|
||||||
|
const volume = Number(properties.audio_volume.value);
|
||||||
|
if (Number.isFinite(volume)) {
|
||||||
|
state.audioVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
applyGeneralProperties: function (properties) {
|
applyGeneralProperties: function (properties) {
|
||||||
if (properties.fps) {
|
if (properties.fps) {
|
||||||
// wallpaperSettings.fps = properties.fps;
|
// fps 限制在第 05 步接入:WE 不替壁纸限流,需要自己按 properties.fps 节流绘制
|
||||||
}
|
|
||||||
},
|
|
||||||
applyUserProperties: function (properties) {
|
|
||||||
if (properties.preset) {
|
|
||||||
const preset = properties.preset.value;
|
|
||||||
let { backgroundImage, spineConfig, audioOptions } = presetController.set(
|
|
||||||
preset,
|
|
||||||
currentAudioSource,
|
|
||||||
currentAudioVolume
|
|
||||||
);
|
|
||||||
backgroundController.changeImage(backgroundImage);
|
|
||||||
spineController.changeConfig(spineConfig);
|
|
||||||
audioController.changeOptions(audioOptions);
|
|
||||||
}
|
|
||||||
if (properties.audio_file) {
|
|
||||||
const audio_file = properties.audio_file.value;
|
|
||||||
currentAudioSource = audio_file;
|
|
||||||
audioController.changeSource(audio_file);
|
|
||||||
}
|
|
||||||
if (properties.audio_volume) {
|
|
||||||
const audio_volume = properties.audio_volume.value;
|
|
||||||
currentAudioVolume = audio_volume;
|
|
||||||
audioController.changeVolume(audio_volume);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Vendored
+20
-30
@@ -1,35 +1,25 @@
|
|||||||
|
// 预设查表:id → 预设数据。
|
||||||
|
// 预设数据是 ES module 里的模块级常量,返回深拷贝,避免调用方改坏它。
|
||||||
|
// 注意 id 必须与 project.json 里 combo 选项的 value 一致(见 docs/adr/0001)。
|
||||||
export default class PresetController {
|
export default class PresetController {
|
||||||
constructor(presetStore) {
|
constructor(presets) {
|
||||||
this.presetStore = presetStore;
|
this.presets = presets;
|
||||||
this.previousPreset = "";
|
this.currentId = "";
|
||||||
this.currentPreset = "";
|
|
||||||
}
|
}
|
||||||
getName() {
|
|
||||||
return Object.keys(this.presetStore);
|
get ids() {
|
||||||
|
return Object.keys(this.presets);
|
||||||
}
|
}
|
||||||
set(newPreset, currentAudioSource, currentAudioVolume) {
|
|
||||||
let newPresetData = JSON.parse(JSON.stringify(this.presetStore[newPreset]));
|
has(id) {
|
||||||
if (this.previousPreset) {
|
return Boolean(this.presets[id]);
|
||||||
if (
|
}
|
||||||
currentAudioSource &&
|
|
||||||
this.presetStore[this.previousPreset].audioOptions.source !==
|
// 未知 id 返回 null,由调用方决定是保持现状还是回退——不在这里悄悄改成别的预设
|
||||||
currentAudioSource
|
get(id) {
|
||||||
) {
|
const preset = this.presets[id];
|
||||||
newPresetData.audioOptions.source = currentAudioSource;
|
if (!preset) return null;
|
||||||
}
|
this.currentId = id;
|
||||||
if (
|
return JSON.parse(JSON.stringify(preset));
|
||||||
currentAudioVolume ||
|
|
||||||
(currentAudioVolume === 0 &&
|
|
||||||
this.presetStore[this.previousPreset].audioOptions.volume !==
|
|
||||||
currentAudioVolume)
|
|
||||||
) {
|
|
||||||
newPresetData.audioOptions.volume = currentAudioVolume;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.currentPreset) {
|
|
||||||
this.previousPreset = this.currentPreset;
|
|
||||||
}
|
|
||||||
this.currentPreset = newPreset;
|
|
||||||
return newPresetData;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+32
-42
@@ -1,60 +1,50 @@
|
|||||||
|
// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上。
|
||||||
|
//
|
||||||
|
// 核心约定:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
|
||||||
|
// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃,
|
||||||
|
// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。
|
||||||
|
// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。
|
||||||
export default class SpineController {
|
export default class SpineController {
|
||||||
constructor(containerId, config = {}) {
|
constructor(containerId, baseConfig = {}) {
|
||||||
this.containerId = containerId;
|
this.containerId = containerId;
|
||||||
this.config = {
|
this.baseConfig = {
|
||||||
alpha: true,
|
alpha: true,
|
||||||
premultipliedAlpha: false,
|
premultipliedAlpha: false,
|
||||||
showControls: false,
|
showControls: false,
|
||||||
showLoading: false,
|
showLoading: false,
|
||||||
...config,
|
...baseConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.previousConfig = {};
|
this.presetConfig = {};
|
||||||
|
|
||||||
this.container = document.getElementById(containerId);
|
this.container = document.getElementById(containerId);
|
||||||
this.player = undefined;
|
this.player = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只写状态:与播放器是否存在无关,随时可调用
|
||||||
|
setConfig(presetConfig = {}) {
|
||||||
|
this.presetConfig = presetConfig;
|
||||||
|
if (this.player) this.rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
if (!this.player) {
|
if (this.player) return;
|
||||||
const containerId = this.containerId;
|
// 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留
|
||||||
const config = this.config;
|
this.player = new spine.SpinePlayer(this.containerId, {
|
||||||
this.player = new spine.SpinePlayer(containerId, config);
|
...this.baseConfig,
|
||||||
}
|
...this.presetConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换骨架没有捷径,只能销毁重建
|
||||||
|
rebuild() {
|
||||||
|
this.dispose();
|
||||||
|
this.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.player) {
|
if (!this.player) return;
|
||||||
const container = this.container;
|
this.player.dispose();
|
||||||
this.player.dispose();
|
this.container.innerHTML = "";
|
||||||
container.innerHTML = "";
|
this.player = undefined;
|
||||||
this.player = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adjustCanvas() {
|
|
||||||
if (this.player) {
|
|
||||||
const container = this.container;
|
|
||||||
const canvas = container.querySelector("canvas");
|
|
||||||
if (canvas) {
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
canvas.style.width = "100%";
|
|
||||||
canvas.style.height = "100%";
|
|
||||||
canvas.width = rect.width;
|
|
||||||
canvas.height = rect.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changeConfig(newConfig = {}) {
|
|
||||||
if (this.player) {
|
|
||||||
this.previousConfig = this.config;
|
|
||||||
this.config = {
|
|
||||||
...this.config,
|
|
||||||
...newConfig,
|
|
||||||
};
|
|
||||||
this.dispose();
|
|
||||||
this.create();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+974
@@ -0,0 +1,974 @@
|
|||||||
|
// wallpaper-engine.js
|
||||||
|
// Wallpaper Engine 网页壁纸 API 模拟器(浏览器调试环境)
|
||||||
|
// 完全兼容 Wallpaper Engine 网页壁纸 API 规范
|
||||||
|
//
|
||||||
|
// 使用方式:在 index.html 中以 <script type="module" src="scripts/wallpaper-engine.js"></script>
|
||||||
|
// 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 工具
|
||||||
|
* ============================================================ */
|
||||||
|
const WEUtils = {
|
||||||
|
/** Wallpaper Engine 颜色值(归一化浮点 "r g b")→ HEX */
|
||||||
|
colorToHex(value) {
|
||||||
|
if (typeof value !== "string") return "#000000";
|
||||||
|
const parts = value.trim().split(/\s+/).map(Number);
|
||||||
|
if (parts.length < 3 || parts.some(isNaN)) return "#000000";
|
||||||
|
return (
|
||||||
|
"#" +
|
||||||
|
parts
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((n) => {
|
||||||
|
const v = Math.max(0, Math.min(255, Math.round(n * 255)));
|
||||||
|
return v.toString(16).padStart(2, "0");
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** HEX → Wallpaper Engine 颜色值 */
|
||||||
|
hexToColor(hex) {
|
||||||
|
if (typeof hex !== "string" || hex[0] !== "#" || hex.length < 7)
|
||||||
|
return "0 0 0";
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Wallpaper Engine 文件路径 → 可用 URL */
|
||||||
|
filePathToUrl(path) {
|
||||||
|
if (!path) return "";
|
||||||
|
if (path.startsWith("file:///")) return path;
|
||||||
|
if (path.startsWith("file://")) return "file:///" + path.slice(7);
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 简单 debounce */
|
||||||
|
debounce(fn, wait = 150) {
|
||||||
|
let timer = null;
|
||||||
|
return function (...args) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn.apply(this, args), wait);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 条件表达式求值器
|
||||||
|
* 支持:value == x / value != x / && / || / ! / ()
|
||||||
|
* ============================================================ */
|
||||||
|
class ConditionEvaluator {
|
||||||
|
constructor(getPropertyValue) {
|
||||||
|
this.getPropertyValue = getPropertyValue;
|
||||||
|
this._cache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} condition 例如 "show_author_info.value == true"
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
evaluate(condition) {
|
||||||
|
if (!condition || typeof condition !== "string") return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 把 "xxx.value" 替换为对应的字面量
|
||||||
|
const expr = condition.replace(/([A-Za-z_$][\w$]*)\.value/g, (_, key) => {
|
||||||
|
const v = this.getPropertyValue(key);
|
||||||
|
if (typeof v === "string") return JSON.stringify(v);
|
||||||
|
if (v === undefined || v === null) return "undefined";
|
||||||
|
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
||||||
|
return JSON.stringify(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Function 仅在纯布尔表达式上求值
|
||||||
|
// 该表达式来源可信(project.json 由壁纸作者提供),且已做 key 白名单替换
|
||||||
|
const fn =
|
||||||
|
this._cache.get(expr) ||
|
||||||
|
new Function(`"use strict"; return Boolean(${expr});`);
|
||||||
|
this._cache.set(expr, fn);
|
||||||
|
return fn();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[WallpaperEngine] 条件求值失败:", condition, err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 主模拟器
|
||||||
|
* ============================================================ */
|
||||||
|
class WallpaperEngineSimulator {
|
||||||
|
constructor() {
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.isPanelVisible = false;
|
||||||
|
this.projectData = null;
|
||||||
|
|
||||||
|
/** @type {Record<string, {value:any}>} */
|
||||||
|
this.properties = {};
|
||||||
|
/** @type {Record<string, any>} 属性原始定义 */
|
||||||
|
this.propertyCache = {};
|
||||||
|
|
||||||
|
this.propertyListeners = [];
|
||||||
|
this.generalListeners = [];
|
||||||
|
this.audioListeners = [];
|
||||||
|
|
||||||
|
this.userDataPath = "";
|
||||||
|
|
||||||
|
// DOM 元素缓存
|
||||||
|
this.panelEl = null;
|
||||||
|
this.styleEl = null;
|
||||||
|
this.fieldEls = new Map();
|
||||||
|
|
||||||
|
// 鼠标状态
|
||||||
|
this._mouse = {
|
||||||
|
isOverPanel: false,
|
||||||
|
hideTimer: null,
|
||||||
|
EDGE_ZONE: 60,
|
||||||
|
HIDE_DELAY: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.conditionEvaluator = new ConditionEvaluator(
|
||||||
|
(key) => this.properties[key]?.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 初始化
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
await this.loadProjectData();
|
||||||
|
this.initializeProperties();
|
||||||
|
this.initializeAPI();
|
||||||
|
this.setupMouseListeners();
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log(
|
||||||
|
"[WallpaperEngine] 模拟器已初始化,共",
|
||||||
|
Object.keys(this.properties).length,
|
||||||
|
"个属性",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WallpaperEngine] 初始化失败:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadProjectData() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("./project.json", { cache: "no-cache" });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.projectData = await res.json();
|
||||||
|
console.log("[WallpaperEngine] project.json 已加载");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"[WallpaperEngine] 无法加载 project.json,使用内置默认配置:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
this.projectData = this.getFallbackProjectData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFallbackProjectData() {
|
||||||
|
return {
|
||||||
|
title: "壁纸设置",
|
||||||
|
general: { properties: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeProperties() {
|
||||||
|
const props = this.projectData?.general?.properties;
|
||||||
|
if (!props) {
|
||||||
|
console.warn("[WallpaperEngine] project.json 缺少 general.properties");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, def] of Object.entries(props)) {
|
||||||
|
this.propertyCache[key] = def;
|
||||||
|
this.properties[key] = {
|
||||||
|
value:
|
||||||
|
def.value !== undefined ? def.value : this.getDefaultValue(def.type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultValue(type) {
|
||||||
|
switch (type) {
|
||||||
|
case "bool":
|
||||||
|
return false;
|
||||||
|
case "combo":
|
||||||
|
return "";
|
||||||
|
case "slider":
|
||||||
|
return 0;
|
||||||
|
case "textinput":
|
||||||
|
return "";
|
||||||
|
case "color":
|
||||||
|
return "0 0 0";
|
||||||
|
case "text":
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 鼠标监听
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
setupMouseListeners() {
|
||||||
|
document.addEventListener("mousemove", (e) => {
|
||||||
|
const { EDGE_ZONE } = this._mouse;
|
||||||
|
const nearRightEdge = e.clientX >= window.innerWidth - EDGE_ZONE;
|
||||||
|
|
||||||
|
if (nearRightEdge && !this.isPanelVisible) {
|
||||||
|
this.showSettingsPanel();
|
||||||
|
} else if (
|
||||||
|
!nearRightEdge &&
|
||||||
|
!this._mouse.isOverPanel &&
|
||||||
|
this.isPanelVisible
|
||||||
|
) {
|
||||||
|
this._scheduleHide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 悬停在面板内部时取消隐藏
|
||||||
|
document.addEventListener("mouseover", (e) => {
|
||||||
|
const inside = this.panelEl && this.panelEl.contains(e.target);
|
||||||
|
if (inside !== this._mouse.isOverPanel) {
|
||||||
|
this._mouse.isOverPanel = inside;
|
||||||
|
if (inside) this._cancelHide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 鼠标离开窗口
|
||||||
|
document.addEventListener("mouseleave", () => {
|
||||||
|
this._mouse.isOverPanel = false;
|
||||||
|
this._scheduleHide(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && this.isPanelVisible) {
|
||||||
|
this.hideSettingsPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleHide(delay = this._mouse.HIDE_DELAY) {
|
||||||
|
this._cancelHide();
|
||||||
|
this._mouse.hideTimer = setTimeout(() => {
|
||||||
|
if (!this._mouse.isOverPanel) this.hideSettingsPanel();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelHide() {
|
||||||
|
if (this._mouse.hideTimer) {
|
||||||
|
clearTimeout(this._mouse.hideTimer);
|
||||||
|
this._mouse.hideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 面板显示 / 隐藏
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
showSettingsPanel() {
|
||||||
|
if (!this.projectData?.general?.properties) return;
|
||||||
|
|
||||||
|
// 单例:首次创建
|
||||||
|
if (!this.panelEl) {
|
||||||
|
this.createSettingsPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isPanelVisible) {
|
||||||
|
this.isPanelVisible = true;
|
||||||
|
// 强制重排以触发 transition
|
||||||
|
void this.panelEl.offsetWidth;
|
||||||
|
this.panelEl.classList.add("we-panel--visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSettingsPanel() {
|
||||||
|
if (!this.isPanelVisible || !this.panelEl) return;
|
||||||
|
this.isPanelVisible = false;
|
||||||
|
this.panelEl.classList.remove("we-panel--visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 面板构造
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
createSettingsPanel() {
|
||||||
|
if (this.styleEl) this.styleEl.remove();
|
||||||
|
this.styleEl = document.createElement("style");
|
||||||
|
this.styleEl.id = "wallpaper-settings-style";
|
||||||
|
this.styleEl.textContent = this.getPanelStyles();
|
||||||
|
document.head.appendChild(this.styleEl);
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.id = "wallpaper-settings-panel";
|
||||||
|
panel.className = "we-panel";
|
||||||
|
|
||||||
|
// 头部
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "we-panel__header";
|
||||||
|
header.innerHTML = `
|
||||||
|
<span class="we-panel__title"></span>
|
||||||
|
<button class="we-panel__close" type="button" aria-label="关闭">×</button>
|
||||||
|
`;
|
||||||
|
header.querySelector(".we-panel__title").textContent =
|
||||||
|
this.projectData.title || "壁纸设置";
|
||||||
|
header.querySelector(".we-panel__close").addEventListener("click", () => {
|
||||||
|
this.hideSettingsPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 内容
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "we-panel__content";
|
||||||
|
|
||||||
|
const sorted = this.getSortedProperties();
|
||||||
|
this.fieldEls.clear();
|
||||||
|
|
||||||
|
for (const { key, def } of sorted) {
|
||||||
|
const row = this.createFieldRow(key, def);
|
||||||
|
if (row) {
|
||||||
|
content.appendChild(row);
|
||||||
|
// 缓存可交互的控件
|
||||||
|
const ctrl = row.querySelector("[data-we-key]");
|
||||||
|
if (ctrl) this.fieldEls.set(key, ctrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.appendChild(header);
|
||||||
|
panel.appendChild(content);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
this.panelEl = panel;
|
||||||
|
|
||||||
|
// 首次渲染后套用条件显示
|
||||||
|
this.refreshConditionalVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单个属性控件的外层容器
|
||||||
|
*/
|
||||||
|
createFieldRow(key, def) {
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "we-field";
|
||||||
|
row.id = `we-field-${key}`;
|
||||||
|
row.dataset.condition = def.condition || "";
|
||||||
|
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "we-field__label";
|
||||||
|
// 支持 text 属性里的 <br/>
|
||||||
|
label.innerHTML = def.text || key;
|
||||||
|
|
||||||
|
switch (def.type) {
|
||||||
|
case "text":
|
||||||
|
return this.buildTextRow(row, key, def);
|
||||||
|
case "bool":
|
||||||
|
return this.buildBoolRow(row, key, def, label);
|
||||||
|
case "combo":
|
||||||
|
return this.buildComboRow(row, key, def, label);
|
||||||
|
case "slider":
|
||||||
|
return this.buildSliderRow(row, key, def, label);
|
||||||
|
case "textinput":
|
||||||
|
return this.buildTextInputRow(row, key, def, label);
|
||||||
|
case "color":
|
||||||
|
return this.buildColorRow(row, key, def, label);
|
||||||
|
default:
|
||||||
|
return this.buildTextRow(row, key, def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTextRow(row, key, def) {
|
||||||
|
const text = document.createElement("div");
|
||||||
|
text.className = "we-field__text";
|
||||||
|
// Wallpaper Engine 支持在 text 属性内嵌 HTML
|
||||||
|
text.innerHTML = def.text || "";
|
||||||
|
row.appendChild(text);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBoolRow(row, key, def, label) {
|
||||||
|
label.htmlFor = key;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const wrap = document.createElement("label");
|
||||||
|
wrap.className = "we-switch";
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<input type="checkbox" data-we-key="${key}" id="${key}">
|
||||||
|
<span class="we-switch__slider"></span>
|
||||||
|
`;
|
||||||
|
const input = wrap.querySelector("input");
|
||||||
|
input.checked = Boolean(this.properties[key]?.value);
|
||||||
|
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
this.updateProperty(key, input.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(wrap);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildComboRow(row, key, def, label) {
|
||||||
|
label.htmlFor = key;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.className = "we-select";
|
||||||
|
select.id = key;
|
||||||
|
select.dataset.weKey = key;
|
||||||
|
|
||||||
|
const cur = this.properties[key]?.value;
|
||||||
|
for (const opt of def.options || []) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt.value;
|
||||||
|
o.textContent = opt.label ?? opt.value;
|
||||||
|
if (opt.value === cur) o.selected = true;
|
||||||
|
select.appendChild(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
this.updateProperty(key, select.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(select);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSliderRow(row, key, def, label) {
|
||||||
|
label.htmlFor = key;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const min = def.min ?? 0;
|
||||||
|
const max = def.max ?? 1;
|
||||||
|
const step = def.step ?? 0.01;
|
||||||
|
|
||||||
|
const slider = document.createElement("input");
|
||||||
|
slider.type = "range";
|
||||||
|
slider.className = "we-slider";
|
||||||
|
slider.id = key;
|
||||||
|
slider.dataset.weKey = key;
|
||||||
|
slider.min = min;
|
||||||
|
slider.max = max;
|
||||||
|
slider.step = step;
|
||||||
|
slider.value = this.properties[key]?.value ?? min;
|
||||||
|
|
||||||
|
const valueLabel = document.createElement("div");
|
||||||
|
valueLabel.className = "we-slider__value";
|
||||||
|
valueLabel.textContent = this._formatSliderValue(slider.value, min, max);
|
||||||
|
|
||||||
|
// 实时更新显示 + 触发属性更新(debounce 写入属性,避免频繁触发监听器)
|
||||||
|
const pushValue = WEUtils.debounce(() => {
|
||||||
|
this.updateProperty(key, parseFloat(slider.value));
|
||||||
|
}, 60);
|
||||||
|
|
||||||
|
slider.addEventListener("input", () => {
|
||||||
|
valueLabel.textContent = this._formatSliderValue(slider.value, min, max);
|
||||||
|
pushValue();
|
||||||
|
});
|
||||||
|
// 松开后强制刷新一次
|
||||||
|
slider.addEventListener("change", () => {
|
||||||
|
this.updateProperty(key, parseFloat(slider.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(slider);
|
||||||
|
row.appendChild(valueLabel);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatSliderValue(v, min, max) {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (max - min === 1 && min === 0) {
|
||||||
|
return `${Math.round(n * 100)}%`;
|
||||||
|
}
|
||||||
|
return `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTextInputRow(row, key, def, label) {
|
||||||
|
label.htmlFor = key;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.className = "we-input";
|
||||||
|
input.id = key;
|
||||||
|
input.dataset.weKey = key;
|
||||||
|
input.value = this.properties[key]?.value ?? "";
|
||||||
|
input.placeholder = def.placeholder || "";
|
||||||
|
|
||||||
|
const pushValue = WEUtils.debounce(() => {
|
||||||
|
this.updateProperty(key, input.value);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
input.addEventListener("input", pushValue);
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
this.updateProperty(key, input.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(input);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildColorRow(row, key, def, label) {
|
||||||
|
label.htmlFor = key;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "color";
|
||||||
|
input.className = "we-color";
|
||||||
|
input.id = key;
|
||||||
|
input.dataset.weKey = key;
|
||||||
|
input.value = WEUtils.colorToHex(this.properties[key]?.value || "0 0 0");
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
this.updateProperty(key, WEUtils.hexToColor(input.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(input);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 属性排序 / 条件刷新
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
getSortedProperties() {
|
||||||
|
return Object.keys(this.propertyCache)
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
def: this.propertyCache[key],
|
||||||
|
order: this.propertyCache[key]?.order ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshConditionalVisibility() {
|
||||||
|
for (const [key, def] of Object.entries(this.propertyCache)) {
|
||||||
|
const row = document.getElementById(`we-field-${key}`);
|
||||||
|
if (!row || !def.condition) continue;
|
||||||
|
|
||||||
|
const visible = this.conditionEvaluator.evaluate(def.condition);
|
||||||
|
row.classList.toggle("we-field--hidden", !visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 属性更新 / 通知
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
updateProperty(name, value) {
|
||||||
|
if (!this.properties[name]) return;
|
||||||
|
if (this.properties[name].value === value) return;
|
||||||
|
|
||||||
|
this.properties[name].value = value;
|
||||||
|
|
||||||
|
// 更新主题色
|
||||||
|
if (name === "schemecolor" && this.styleEl) {
|
||||||
|
const hex = WEUtils.colorToHex(value);
|
||||||
|
document.documentElement.style.setProperty("--we-theme-color", hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新条件显示
|
||||||
|
this.refreshConditionalVisibility();
|
||||||
|
|
||||||
|
// 通知监听器
|
||||||
|
this.notifyPropertyListeners(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyPropertyListeners(name, value) {
|
||||||
|
const payload = { [name]: { value } };
|
||||||
|
for (const listener of this.propertyListeners) {
|
||||||
|
try {
|
||||||
|
listener.applyUserProperties?.(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WallpaperEngine] applyUserProperties 抛错:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 样式
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
getPanelStyles() {
|
||||||
|
const themeHex = WEUtils.colorToHex(
|
||||||
|
this.properties.schemecolor?.value || "0.388 0.149 0.62",
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
:root { --we-theme-color: ${themeHex}; }
|
||||||
|
|
||||||
|
#wallpaper-settings-panel.we-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
height: 100vh;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: rgba(22, 22, 30, 0.92);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.45);
|
||||||
|
color: #f0f0f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 999999;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
#wallpaper-settings-panel.we-panel--visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.we-panel__header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.we-panel__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.we-panel__close {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #f0f0f5;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.we-panel__close:hover { background: rgba(255,255,255,0.1); transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* 内容区 */
|
||||||
|
.we-panel__content { padding: 16px 20px 40px; }
|
||||||
|
|
||||||
|
/* 每个属性块 */
|
||||||
|
.we-field { margin-bottom: 18px; }
|
||||||
|
.we-field--hidden { display: none !important; }
|
||||||
|
|
||||||
|
.we-field__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6e6ee;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.we-field__text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9a9aa8;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.we-field__text a {
|
||||||
|
color: var(--we-theme-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.we-field__text a:hover { opacity: 0.8; text-decoration: underline; }
|
||||||
|
.we-field__text small { font-size: 12px; }
|
||||||
|
.we-field__text ul,
|
||||||
|
.we-field__text ol { margin: 4px 0; padding-left: 20px; }
|
||||||
|
.we-field__text li { margin: 3px 0; }
|
||||||
|
.we-field__text p { margin: 4px 0; }
|
||||||
|
.we-field__text code {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 开关 */
|
||||||
|
.we-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px; height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
float: right;
|
||||||
|
margin-top: -30px;
|
||||||
|
}
|
||||||
|
.we-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.we-switch__slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255,255,255,0.16);
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: background 0.25s;
|
||||||
|
}
|
||||||
|
.we-switch__slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 3px; top: 3px;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.25s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
.we-switch input:checked + .we-switch__slider {
|
||||||
|
background: var(--we-theme-color);
|
||||||
|
}
|
||||||
|
.we-switch input:checked + .we-switch__slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉框 / 输入框 */
|
||||||
|
.we-select, .we-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f0f0f5;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.we-select:hover, .we-input:hover { background: rgba(255,255,255,0.09); }
|
||||||
|
.we-select:focus, .we-input:focus {
|
||||||
|
border-color: var(--we-theme-color);
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.we-select option { background: #1b1b25; color: #f0f0f5; }
|
||||||
|
|
||||||
|
/* 滑块 */
|
||||||
|
.we-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.we-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--we-theme-color);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.we-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
|
||||||
|
.we-slider::-webkit-slider-thumb:active { cursor: grabbing; }
|
||||||
|
.we-slider::-moz-range-thumb {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--we-theme-color);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.we-slider__value {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9a9aa8;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 颜色选择器 */
|
||||||
|
.we-color {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.we-color::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||||
|
.we-color::-webkit-color-swatch { border: none; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
.we-panel::-webkit-scrollbar { width: 6px; }
|
||||||
|
.we-panel::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.we-panel::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.we-panel::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--we-theme-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Wallpaper Engine 全局 API 注入
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
initializeAPI() {
|
||||||
|
// ---------- wallpaperPropertyListener ----------
|
||||||
|
if (!window.wallpaperPropertyListener) {
|
||||||
|
window.wallpaperPropertyListener = {
|
||||||
|
applyGeneralProperties: (properties) => {
|
||||||
|
this._emit("applyGeneralProperties", properties);
|
||||||
|
},
|
||||||
|
applyUserProperties: (properties) => {
|
||||||
|
this._emit("applyUserProperties", properties);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- wallpaperRegisterListener ----------
|
||||||
|
if (!window.wallpaperRegisterListener) {
|
||||||
|
window.wallpaperRegisterListener = (listener) => {
|
||||||
|
if (!listener || typeof listener !== "object") return;
|
||||||
|
if (typeof listener.applyGeneralProperties === "function") {
|
||||||
|
this.generalListeners.push(listener);
|
||||||
|
}
|
||||||
|
if (typeof listener.applyUserProperties === "function") {
|
||||||
|
this.propertyListeners.push(listener);
|
||||||
|
}
|
||||||
|
if (typeof listener.applyAudioProperties === "function") {
|
||||||
|
this.audioListeners.push(listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 其它 API 占位 ----------
|
||||||
|
if (!window.wallpaperGetUserDataPath) {
|
||||||
|
window.wallpaperGetUserDataPath = () => this.userDataPath;
|
||||||
|
}
|
||||||
|
if (!window.wallpaperRequestFile) {
|
||||||
|
// Wallpaper Engine 原生:请求文件(steam 云 / 用户数据)
|
||||||
|
// 浏览器无法访问,仅返回 null 占位
|
||||||
|
window.wallpaperRequestFile = () => {
|
||||||
|
console.warn(
|
||||||
|
"[WallpaperEngine] wallpaperRequestFile 在浏览器环境中不可用",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!window.wallpaperRegisterAudioListener) {
|
||||||
|
window.wallpaperRegisterAudioListener = (cb) => {
|
||||||
|
if (typeof cb === "function")
|
||||||
|
this.audioListeners.push({ applyAudioProperties: cb });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!window.wallpaperGetWallpaperAuthor) {
|
||||||
|
window.wallpaperGetWallpaperAuthor = () =>
|
||||||
|
this.projectData?.general?.properties?.author?.value || "";
|
||||||
|
}
|
||||||
|
if (!window.wallpaperEngine) {
|
||||||
|
window.wallpaperEngine = {
|
||||||
|
setProperty: (name, value) => this.updateProperty(name, value),
|
||||||
|
getProperty: (name) => this.properties[name]?.value,
|
||||||
|
getUserDataPath: () => this.userDataPath,
|
||||||
|
getProjectData: () => this.projectData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(method, payload) {
|
||||||
|
const lists =
|
||||||
|
method === "applyGeneralProperties"
|
||||||
|
? this.generalListeners
|
||||||
|
: this.propertyListeners;
|
||||||
|
|
||||||
|
for (const listener of lists) {
|
||||||
|
try {
|
||||||
|
listener[method]?.(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[WallpaperEngine] ${method} 抛错:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* 模拟初始属性推送(浏览器调试用)
|
||||||
|
* Wallpaper Engine 在壁纸加载完成后,会先调用 applyGeneralProperties,
|
||||||
|
* 再逐条调用 applyUserProperties。
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
simulateWallpaperEngine() {
|
||||||
|
const run = () => {
|
||||||
|
// 等待 index.js 中的 wallpaperRegisterListener 注册完成
|
||||||
|
setTimeout(() => {
|
||||||
|
// 1. 通用属性
|
||||||
|
this._emit("applyGeneralProperties", { fps: 60 });
|
||||||
|
|
||||||
|
// 2. 逐条推送用户属性(模拟 Wallpaper Engine 行为)
|
||||||
|
for (const [key, prop] of Object.entries(this.properties)) {
|
||||||
|
this._emit("applyUserProperties", { [key]: { value: prop.value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[WallpaperEngine] 已推送初始属性到监听器");
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "complete") run();
|
||||||
|
else window.addEventListener("load", run, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 环境检测 & 启动
|
||||||
|
* ============================================================ */
|
||||||
|
function isWallpaperEngineEnvironment() {
|
||||||
|
return (
|
||||||
|
typeof window.wallpaperRegisterListener === "function" &&
|
||||||
|
typeof window.wallpaperPropertyListener === "object" &&
|
||||||
|
window.wallpaperPropertyListener !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrap() {
|
||||||
|
if (isWallpaperEngineEnvironment()) {
|
||||||
|
console.log(
|
||||||
|
"[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulator = new WallpaperEngineSimulator();
|
||||||
|
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (simulator.isInitialized) {
|
||||||
|
clearInterval(check);
|
||||||
|
simulator.simulateWallpaperEngine();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// 兜底:5 秒后仍未初始化则强制停止轮询
|
||||||
|
setTimeout(() => clearInterval(check), 5000);
|
||||||
|
|
||||||
|
// 便于调试
|
||||||
|
window.__WE_SIMULATOR__ = simulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
|
||||||
|
} else {
|
||||||
|
bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isWallpaperEngineEnvironment,
|
||||||
|
WallpaperEngineSimulator,
|
||||||
|
WEUtils,
|
||||||
|
};
|
||||||
+29
-19
@@ -1,12 +1,19 @@
|
|||||||
// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。
|
// 两张图的数值对拍:PSNR / SSIM / 平均绝对差 / 最大绝对差(均为 0-255 尺度)。
|
||||||
// 用法:node tools/diff.mjs <a.png> <b.png> [diff-out.png]
|
// 用法:node tools/diff.mjs <a> <b> [diff-out.png]
|
||||||
// 判读:平均差 < 1 视为肉眼无差异;1-3 为细微;> 8 通常意味着构图/内容变了。
|
//
|
||||||
|
// 判读:
|
||||||
|
// mean|diff| ≈ 0 且 max|diff| = 0 → 逐像素相同
|
||||||
|
// mean|diff| 极小 但 max|diff| 很大 → 只有零星像素变了(多半是高对比边缘的抗锯齿抖动)
|
||||||
|
// mean|diff| 明显变大 → 整体变化(构图/内容/色调)
|
||||||
|
//
|
||||||
|
// 注意:ffmpeg 对很小的数值会用科学计数法(YAVG=4.96721e-05),
|
||||||
|
// 解析时必须带上指数,否则会把 0.00005 误读成 4.97——这个坑踩过一次。
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
const [a, b, outPng] = process.argv.slice(2);
|
const [a, b, outPng] = process.argv.slice(2);
|
||||||
if (!a || !b) {
|
if (!a || !b) {
|
||||||
console.error("usage: node tools/diff.mjs <a.png> <b.png> [diff-out.png]");
|
console.error("usage: node tools/diff.mjs <a> <b> [diff-out.png]");
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,28 +27,31 @@ function ff(args) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]);
|
const NUM = "([-+0-9.eE]+)";
|
||||||
const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]);
|
|
||||||
const yavg = await ff([
|
|
||||||
"-i", a, "-i", b,
|
|
||||||
"-lavfi", "blend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG",
|
|
||||||
"-f", "null", "-",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const num = (text, re) => {
|
const num = (text, re) => {
|
||||||
const m = text.match(re);
|
const m = text.match(re);
|
||||||
return m ? Number(m[1]) : NaN;
|
return m ? Number(m[1]) : NaN;
|
||||||
};
|
};
|
||||||
|
|
||||||
const yavgs = [...yavg.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1]));
|
const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]);
|
||||||
|
const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]);
|
||||||
|
const stats = await ff([
|
||||||
|
"-i", a, "-i", b,
|
||||||
|
"-lavfi", "blend=all_mode=difference,format=gray,signalstats,metadata=print",
|
||||||
|
"-f", "null", "-",
|
||||||
|
]);
|
||||||
|
|
||||||
const report = {
|
const frames = [...stats.matchAll(/frame:(\d+)/g)].length;
|
||||||
psnr_avg: /average:inf/.test(psnr) ? Infinity : num(psnr, /average:([\d.]+)/),
|
const yavg = num(stats, new RegExp(`YAVG=${NUM}`));
|
||||||
ssim_all: num(ssim, /All:([\d.]+)/),
|
const ymax = num(stats, new RegExp(`YMAX=${NUM}`));
|
||||||
mean_abs_diff: yavgs.length ? yavgs[yavgs.length - 1] : NaN,
|
const inf = /average:inf/.test(psnr);
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`psnr=${report.psnr_avg} dB ssim=${report.ssim_all} mean|diff|=${report.mean_abs_diff}/255`);
|
console.log(
|
||||||
|
`psnr=${inf ? "∞" : num(psnr, new RegExp(`average:${NUM}`))} dB ` +
|
||||||
|
`ssim=${num(ssim, new RegExp(`All:${NUM}`))} ` +
|
||||||
|
`mean|diff|=${yavg}/255 max|diff|=${ymax}` +
|
||||||
|
(frames > 1 ? ` [${frames} 帧,取首帧]` : ""),
|
||||||
|
);
|
||||||
|
|
||||||
if (outPng) {
|
if (outPng) {
|
||||||
await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]);
|
await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]);
|
||||||
|
|||||||
+8
-1
@@ -88,6 +88,8 @@ const DRIVER = (q) => `<script>
|
|||||||
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
|
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
|
||||||
if (freeze) {
|
if (freeze) {
|
||||||
try {
|
try {
|
||||||
|
this.paused = true;
|
||||||
|
this.speed = 0;
|
||||||
var cur = this.animationState && this.animationState.getCurrent(0);
|
var cur = this.animationState && this.animationState.getCurrent(0);
|
||||||
if (cur) cur.trackTime = 0;
|
if (cur) cur.trackTime = 0;
|
||||||
if (this.skeleton) this.skeleton.updateWorldTransform(2);
|
if (this.skeleton) this.skeleton.updateWorldTransform(2);
|
||||||
@@ -100,7 +102,12 @@ const DRIVER = (q) => `<script>
|
|||||||
|
|
||||||
function freezePlayer(p) {
|
function freezePlayer(p) {
|
||||||
try {
|
try {
|
||||||
p.pause();
|
// 冻结姿势。关键在 paused=true:drawFrame 里 playDelta = paused ? 0 : delta * speed,
|
||||||
|
// 于是 animationState.update(0) 不会推进相位。只把 trackTime 置 0 是不够的——
|
||||||
|
// 那是在播放器 update(playDelta) 之前设的,渲染出来的仍是 t=playDelta 的姿势,
|
||||||
|
// 而 playDelta 随帧时长浮动,会留下"边缘亚像素抖动"式的伪差异。
|
||||||
|
p.paused = true;
|
||||||
|
p.speed = 0;
|
||||||
var cur = p.animationState && p.animationState.getCurrent(0);
|
var cur = p.animationState && p.animationState.getCurrent(0);
|
||||||
if (cur) cur.trackTime = 0;
|
if (cur) cur.trackTime = 0;
|
||||||
if (p.skeleton) p.skeleton.updateWorldTransform(2);
|
if (p.skeleton) p.skeleton.updateWorldTransform(2);
|
||||||
|
|||||||
Reference in new issue
Block a user