feat(background): 新增壁纸切换时的平滑淡入过渡效果

- 新增0.6秒缓出的背景淡入动画及对应CSS类
- 为所有壁纸缓存文件添加唯一后缀,确保每次切换都触发资源重载
- 重写BackgroundLayer组件,使用双层背景实现平滑交叉过渡
- 修复缓存壁纸查找逻辑,改为按修改时间选取最新文件
- 优化相关注释与代码健壮性
This commit is contained in:
2026-09-04 21:36:05 +08:00
parent bcac166a47
commit 744494576e
4 changed files with 110 additions and 40 deletions
File diff suppressed because one or more lines are too long
+36 -15
View File
@@ -43,7 +43,8 @@ const MIME_TO_EXT: Record<string, string> = {
/** /**
* 旧版配置若仍存 BASE64 dataURL 且 userData 里没有原始缓存文件时, * 旧版配置若仍存 BASE64 dataURL 且 userData 里没有原始缓存文件时,
* 直接把 dataURL 解码落盘为 `background-custom<ext>`保证配置文件能迁移为「文件路径」存储。 * 直接把 dataURL 解码落盘为 `background-custom-<唯一后缀><ext>`
* 保证配置文件能迁移为「文件路径」存储。
*/ */
export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: string): string | null { export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: string): string | null {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl.trim()); const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl.trim());
@@ -54,7 +55,7 @@ export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: strin
if (!buffer || buffer.length === 0) return null; if (!buffer || buffer.length === 0) return null;
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true }); if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
clearStaleBackgroundFiles(userDataDir, []); clearStaleBackgroundFiles(userDataDir, []);
const rawPath = path.join(userDataDir, `background-custom${ext}`); const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
fs.writeFileSync(rawPath, buffer); fs.writeFileSync(rawPath, buffer);
return rawPath; return rawPath;
} catch { } catch {
@@ -81,6 +82,11 @@ function statSize(p: string): number {
} }
} }
/** 生成唯一后缀:每次导入的文件名都不同,保证渲染端 URL/配置路径变化以触发实时刷新与渐入动效 */
function uniqueSuffix(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
/** 删除 userData 目录里旧的壁纸缓存文件(保留 keep 中列出的完整路径) */ /** 删除 userData 目录里旧的壁纸缓存文件(保留 keep 中列出的完整路径) */
export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]): void { export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]): void {
try { try {
@@ -105,7 +111,8 @@ export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]):
* 对已复制到 userData 的原始壁纸做「按需」降采样,结果直接落盘。 * 对已复制到 userData 的原始壁纸做「按需」降采样,结果直接落盘。
* - 长边 ≤ maxEdge:不需要优化 → 直接返回原始缓存文件路径(零损耗,视觉 100% 一致); * - 长边 ≤ maxEdge:不需要优化 → 直接返回原始缓存文件路径(零损耗,视觉 100% 一致);
* - 长边 > maxEdge:等比 resize → JPEG(q0.9) 或 PNG(透明/动画不处理) 写为 * - 长边 > maxEdge:等比 resize → JPEG(q0.9) 或 PNG(透明/动画不处理) 写为
* `background-custom-opt.<ext>`,同时清理旧的其它格式 opt 文件; * `background-custom-opt-<唯一后缀>.<ext>`(文件名每次不同,便于渲染端感知变化并做渐入),
* 同时清理旧的其它格式 opt 文件;
* - 动画 GIF 或无法解析:原样返回原始路径(不破坏动画/内容)。 * - 动画 GIF 或无法解析:原样返回原始路径(不破坏动画/内容)。
*/ */
export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): OptimizedBackground { export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): OptimizedBackground {
@@ -174,9 +181,9 @@ export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): Opt
} }
if (!buffer || buffer.length === 0) return fail(rawFilePath); if (!buffer || buffer.length === 0) return fail(rawFilePath);
// 先清理旧 opt 文件(其它扩展名/旧尺寸),再原子写入新文件 // 先清理旧文件,再原子写入新文件(文件名带唯一后缀,保证每次导入路径都不同)
clearStaleBackgroundFiles(outDir, [rawFilePath]); clearStaleBackgroundFiles(outDir, [rawFilePath]);
const optPath = path.join(outDir, `background-custom-opt${outExt}`); const optPath = path.join(outDir, `background-custom-opt-${uniqueSuffix()}${outExt}`);
const tmpPath = `${optPath}.${process.pid}.tmp`; const tmpPath = `${optPath}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, buffer); fs.writeFileSync(tmpPath, buffer);
try { try {
@@ -199,8 +206,9 @@ export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): Opt
} }
/** /**
* 复制用户选择的图片到 userData(原始缓存,命名 background-custom<ext>), * 复制用户选择的图片到 userData(原始缓存,命名 background-custom-<唯一后缀><ext>),
* 清理旧的其它扩展名缓存,然后返回优化结果。 * 清理旧缓存,然后返回优化结果。每次导入文件名都不同 → 配置里的路径必然变化,
* 渲染端据此实时刷新并触发切换渐入动效。
*/ */
export function importUserBackground(srcPath: string, maxEdge = 4096): OptimizedBackground | null { export function importUserBackground(srcPath: string, maxEdge = 4096): OptimizedBackground | null {
try { try {
@@ -209,8 +217,8 @@ export function importUserBackground(srcPath: string, maxEdge = 4096): Optimized
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true }); if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
const ext = path.extname(srcPath).toLowerCase() || '.png'; const ext = path.extname(srcPath).toLowerCase() || '.png';
const rawPath = path.join(userDataDir, `background-custom${ext}`); const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
// 删除旧的原始缓存(其它扩展名) // 删除旧的原始缓存
clearStaleBackgroundFiles(userDataDir, []); clearStaleBackgroundFiles(userDataDir, []);
fs.copyFileSync(srcPath, rawPath); fs.copyFileSync(srcPath, rawPath);
return optimizeBackgroundFile(rawPath, maxEdge); return optimizeBackgroundFile(rawPath, maxEdge);
@@ -219,16 +227,29 @@ export function importUserBackground(srcPath: string, maxEdge = 4096): Optimized
} }
} }
/** 找到 userData 中现有的壁纸缓存原始文件(不含 opt 变体) */ /** 找到 userData 中最近的壁纸缓存原始文件(不含 opt 变体) */
export function findCachedBackgroundRaw(userDataDir: string): string | null { export function findCachedBackgroundRaw(userDataDir: string): string | null {
try { try {
const entries = fs.readdirSync(userDataDir); const entries = fs.readdirSync(userDataDir);
const match = entries const files = entries
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt.')) .filter((n) => n.startsWith('background-custom') && !n.includes('-opt'))
.sort()
.map((n) => path.join(userDataDir, n)) .map((n) => path.join(userDataDir, n))
.find((p) => fs.existsSync(p) && fs.statSync(p).isFile()); .filter((p) => {
return match ?? null; try {
return fs.statSync(p).isFile();
} catch {
return false;
}
});
if (files.length === 0) return null;
files.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});
return files[0];
} catch { } catch {
return null; return null;
} }
+62 -24
View File
@@ -10,6 +10,9 @@ import { estimateDataUrlBytes } from "@/resources/image";
/** 可直接用于 CSS 的源(默认图/相对 URL/http(s)/file:/data: */ /** 可直接用于 CSS 的源(默认图/相对 URL/http(s)/file:/data: */
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v); const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
/** 壁纸切换渐入时长(ms);与 .bg-fade-in 动画时长保持一致 */
const FADE_MS = 600;
export function BackgroundLayer() { export function BackgroundLayer() {
const { type, image, blur, opacity } = useBackgroundStore(); const { type, image, blur, opacity } = useBackgroundStore();
const parallax = useThemeStore((s) => s.parallax); const parallax = useThemeStore((s) => s.parallax);
@@ -18,8 +21,7 @@ export function BackgroundLayer() {
const showContentBlur = route !== "home"; const showContentBlur = route !== "home";
// bgImage:最终可显示的资源引用(CSS url 或 koring-res:// URL 或颜色)。 // bgImage:最终可显示的资源引用(CSS url 或 koring-res:// URL 或颜色)。
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用 // 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用
// 避免把文件路径直接当 CSS url 使用 / 闪默认图。
const [bgImage, setBgImage] = useState<string | null>(() => { const [bgImage, setBgImage] = useState<string | null>(() => {
if (type === "color" || !image) return image ?? null; if (type === "color" || !image) return image ?? null;
return isCssSource(image) ? image : null; return isCssSource(image) ? image : null;
@@ -27,11 +29,17 @@ export function BackgroundLayer() {
/** 解析后的文件字节(供资源注册表统计) */ /** 解析后的文件字节(供资源注册表统计) */
const [bgBytes, setBgBytes] = useState(0); const [bgBytes, setBgBytes] = useState(0);
// 双层背景做切换渐入:previous 为旧壁纸(保留至淡出结束),active 为新壁纸(带 fade-in
const [active, setActive] = useState<string | null>(bgImage);
const [previous, setPrevious] = useState<string | null>(null);
const [fading, setFading] = useState(false);
const committedRef = useRef<string | null>(bgImage);
const activeRef = useRef<string | null>(bgImage);
const bgRef = useRef<HTMLDivElement>(null); const bgRef = useRef<HTMLDivElement>(null);
// 解析背景源:默认图/颜色/data: 等直接用; // 解析背景源:默认图/颜色/data: 等直接用;
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(流式,无 base64 副本)。 // 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(每次导入文件名唯一 → URL 变化)。
// 代次守卫:防止慢的旧解析结果覆盖用户最新选择(行为不变,仅修竞态)。
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (type === "color" || !image) { if (type === "color" || !image) {
@@ -64,12 +72,31 @@ export function BackgroundLayer() {
}; };
}, [image, type]); }, [image, type]);
// 提交新背景到「双层交叉淡化」状态机(相同 URL 忽略;旧背景保留至淡出结束)
useEffect(() => {
if (bgImage === committedRef.current) return;
committedRef.current = bgImage;
const oldActive = activeRef.current;
setPrevious(oldActive);
setActive(bgImage);
activeRef.current = bgImage;
// 已有旧背景且确实发生了内容切换(首次出现不渐入)
if (oldActive != null && bgImage != null && oldActive !== bgImage) {
setFading(true);
}
}, [bgImage]);
useEffect(() => {
if (!fading) return;
const timer = setTimeout(() => setFading(false), FADE_MS + 120);
return () => clearTimeout(timer);
}, [fading]);
// 当前生效的背景登记进资源注册表(估算字节、单持有者语义): // 当前生效的背景登记进资源注册表(估算字节、单持有者语义):
// 背景切换时旧条目被释放丢弃,大 dataURL/引用不再被缓存层额外持有。 // 背景切换时旧条目被释放丢弃,大 dataURL/引用不再被缓存层额外持有。
const trackedKey = useMemo(() => { const trackedKey = useMemo(() => {
if (!bgImage) return null; if (!bgImage) return null;
if (bgImage.startsWith("data:")) { if (bgImage.startsWith("data:")) {
// 只取前缀 + 长度作为 key,避免把整段 dataURL 重复存进 Map key
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`; return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
} }
if (bgImage.startsWith("koring-res:")) { if (bgImage.startsWith("koring-res:")) {
@@ -119,12 +146,11 @@ export function BackgroundLayer() {
return () => window.removeEventListener("mousemove", handleMouseMove); return () => window.removeEventListener("mousemove", handleMouseMove);
}, [parallax, handleMouseMove]); }, [parallax, handleMouseMove]);
// 自定义壁纸在解析完成前不显示(等待 koring-res 引用),避免闪默认图
const bgUrl = bgImage || DEFAULT_BG;
const contentBlur = showContentBlur && !forceDisableContentBlur; const contentBlur = showContentBlur && !forceDisableContentBlur;
const getBackgroundStyle = (): React.CSSProperties => { // 外层容器:定位/不透明度/模糊滤镜/视差变换;图片内容由内部双层承载
const base: React.CSSProperties = { const containerStyle = (): React.CSSProperties => {
const style: React.CSSProperties = {
position: "fixed", position: "fixed",
inset: parallax ? -20 : 0, inset: parallax ? -20 : 0,
zIndex: 0, zIndex: 0,
@@ -132,31 +158,43 @@ export function BackgroundLayer() {
opacity, opacity,
transition: "filter 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.1s ease-out", transition: "filter 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.1s ease-out",
}; };
const filters: string[] = []; const filters: string[] = [];
if (blur > 0) filters.push(`blur(${blur}px)`); if (blur > 0) filters.push(`blur(${blur}px)`);
if (contentBlur) filters.push("blur(24px) saturate(120%)"); if (contentBlur) filters.push("blur(24px) saturate(120%)");
if (filters.length) base.filter = filters.join(" "); if (filters.length) style.filter = filters.join(" ");
if (type === "color") { if (type === "color") {
return { style.backgroundColor = active ?? image ?? DEFAULT_BG;
...base,
backgroundColor: bgUrl,
};
}
const style: React.CSSProperties = { ...base };
if (bgImage) {
style.backgroundImage = `url(${bgUrl})`;
style.backgroundSize = "cover";
style.backgroundPosition = "center";
} }
return style; return style;
}; };
const imageLayerStyle = (url: string): React.CSSProperties => ({
position: "absolute",
inset: 0,
backgroundImage: `url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
});
const showPrevious = fading && previous != null && previous !== active;
const isImageType = type !== "color";
return ( return (
<> <>
<div ref={bgRef} style={getBackgroundStyle()} /> <div ref={bgRef} style={containerStyle()}>
{isImageType && (
<>
{showPrevious && previous && <div style={imageLayerStyle(previous)} />}
{active && (
<div
key={active}
className={showPrevious ? "bg-fade-in" : undefined}
style={imageLayerStyle(active)}
/>
)}
</>
)}
</div>
<div <div
className="content-blur-overlay" className="content-blur-overlay"
style={{ opacity: contentBlur ? 1 : 0 }} style={{ opacity: contentBlur ? 1 : 0 }}
+11
View File
@@ -293,6 +293,17 @@
animation: none !important; animation: none !important;
} }
/* ============================================
壁纸切换渐入(BackgroundLayer 双层淡入)
============================================ */
@keyframes bg-crossfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.bg-fade-in {
animation: bg-crossfade-in 0.6s ease-out both;
}
.reduce-transparency { .reduce-transparency {
--titlebar-bg: var(--background); --titlebar-bg: var(--background);
} }