mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat(background): 新增壁纸切换时的平滑淡入过渡效果
- 新增0.6秒缓出的背景淡入动画及对应CSS类 - 为所有壁纸缓存文件添加唯一后缀,确保每次切换都触发资源重载 - 重写BackgroundLayer组件,使用双层背景实现平滑交叉过渡 - 修复缓存壁纸查找逻辑,改为按修改时间选取最新文件 - 优化相关注释与代码健壮性
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -43,7 +43,8 @@ const MIME_TO_EXT: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* 旧版配置若仍存 BASE64 dataURL 且 userData 里没有原始缓存文件时,
|
||||
* 直接把 dataURL 解码落盘为 `background-custom<ext>`,保证配置文件能迁移为「文件路径」存储。
|
||||
* 直接把 dataURL 解码落盘为 `background-custom-<唯一后缀><ext>`,
|
||||
* 保证配置文件能迁移为「文件路径」存储。
|
||||
*/
|
||||
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());
|
||||
@@ -54,7 +55,7 @@ export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: strin
|
||||
if (!buffer || buffer.length === 0) return null;
|
||||
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
|
||||
clearStaleBackgroundFiles(userDataDir, []);
|
||||
const rawPath = path.join(userDataDir, `background-custom${ext}`);
|
||||
const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
|
||||
fs.writeFileSync(rawPath, buffer);
|
||||
return rawPath;
|
||||
} 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 中列出的完整路径) */
|
||||
export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]): void {
|
||||
try {
|
||||
@@ -105,7 +111,8 @@ export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]):
|
||||
* 对已复制到 userData 的原始壁纸做「按需」降采样,结果直接落盘。
|
||||
* - 长边 ≤ maxEdge:不需要优化 → 直接返回原始缓存文件路径(零损耗,视觉 100% 一致);
|
||||
* - 长边 > maxEdge:等比 resize → JPEG(q0.9) 或 PNG(透明/动画不处理) 写为
|
||||
* `background-custom-opt.<ext>`,同时清理旧的其它格式 opt 文件;
|
||||
* `background-custom-opt-<唯一后缀>.<ext>`(文件名每次不同,便于渲染端感知变化并做渐入),
|
||||
* 同时清理旧的其它格式 opt 文件;
|
||||
* - 动画 GIF 或无法解析:原样返回原始路径(不破坏动画/内容)。
|
||||
*/
|
||||
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);
|
||||
|
||||
// 先清理旧 opt 文件(其它扩展名/旧尺寸),再原子写入新文件
|
||||
// 先清理旧文件,再原子写入新文件(文件名带唯一后缀,保证每次导入路径都不同)
|
||||
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`;
|
||||
fs.writeFileSync(tmpPath, buffer);
|
||||
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 {
|
||||
try {
|
||||
@@ -209,8 +217,8 @@ export function importUserBackground(srcPath: string, maxEdge = 4096): Optimized
|
||||
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
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, []);
|
||||
fs.copyFileSync(srcPath, rawPath);
|
||||
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 {
|
||||
try {
|
||||
const entries = fs.readdirSync(userDataDir);
|
||||
const match = entries
|
||||
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt.'))
|
||||
.sort()
|
||||
const files = entries
|
||||
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt'))
|
||||
.map((n) => path.join(userDataDir, n))
|
||||
.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
|
||||
return match ?? null;
|
||||
.filter((p) => {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import { estimateDataUrlBytes } from "@/resources/image";
|
||||
/** 可直接用于 CSS 的源(默认图/相对 URL/http(s)/file:/data:) */
|
||||
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
|
||||
|
||||
/** 壁纸切换渐入时长(ms);与 .bg-fade-in 动画时长保持一致 */
|
||||
const FADE_MS = 600;
|
||||
|
||||
export function BackgroundLayer() {
|
||||
const { type, image, blur, opacity } = useBackgroundStore();
|
||||
const parallax = useThemeStore((s) => s.parallax);
|
||||
@@ -18,8 +21,7 @@ export function BackgroundLayer() {
|
||||
const showContentBlur = route !== "home";
|
||||
|
||||
// bgImage:最终可显示的资源引用(CSS url 或 koring-res:// URL 或颜色)。
|
||||
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用,
|
||||
// 避免把文件路径直接当 CSS url 使用 / 闪默认图。
|
||||
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用。
|
||||
const [bgImage, setBgImage] = useState<string | null>(() => {
|
||||
if (type === "color" || !image) return image ?? null;
|
||||
return isCssSource(image) ? image : null;
|
||||
@@ -27,11 +29,17 @@ export function BackgroundLayer() {
|
||||
/** 解析后的文件字节(供资源注册表统计) */
|
||||
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);
|
||||
|
||||
// 解析背景源:默认图/颜色/data: 等直接用;
|
||||
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(流式,无 base64 副本)。
|
||||
// 代次守卫:防止慢的旧解析结果覆盖用户最新选择(行为不变,仅修竞态)。
|
||||
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(每次导入文件名唯一 → URL 变化)。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (type === "color" || !image) {
|
||||
@@ -64,12 +72,31 @@ export function BackgroundLayer() {
|
||||
};
|
||||
}, [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/引用不再被缓存层额外持有。
|
||||
const trackedKey = useMemo(() => {
|
||||
if (!bgImage) return null;
|
||||
if (bgImage.startsWith("data:")) {
|
||||
// 只取前缀 + 长度作为 key,避免把整段 dataURL 重复存进 Map key
|
||||
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
|
||||
}
|
||||
if (bgImage.startsWith("koring-res:")) {
|
||||
@@ -119,12 +146,11 @@ export function BackgroundLayer() {
|
||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [parallax, handleMouseMove]);
|
||||
|
||||
// 自定义壁纸在解析完成前不显示(等待 koring-res 引用),避免闪默认图
|
||||
const bgUrl = bgImage || DEFAULT_BG;
|
||||
const contentBlur = showContentBlur && !forceDisableContentBlur;
|
||||
|
||||
const getBackgroundStyle = (): React.CSSProperties => {
|
||||
const base: React.CSSProperties = {
|
||||
// 外层容器:定位/不透明度/模糊滤镜/视差变换;图片内容由内部双层承载
|
||||
const containerStyle = (): React.CSSProperties => {
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: parallax ? -20 : 0,
|
||||
zIndex: 0,
|
||||
@@ -132,31 +158,43 @@ export function BackgroundLayer() {
|
||||
opacity,
|
||||
transition: "filter 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.1s ease-out",
|
||||
};
|
||||
|
||||
const filters: string[] = [];
|
||||
if (blur > 0) filters.push(`blur(${blur}px)`);
|
||||
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") {
|
||||
return {
|
||||
...base,
|
||||
backgroundColor: bgUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = { ...base };
|
||||
if (bgImage) {
|
||||
style.backgroundImage = `url(${bgUrl})`;
|
||||
style.backgroundSize = "cover";
|
||||
style.backgroundPosition = "center";
|
||||
style.backgroundColor = active ?? image ?? DEFAULT_BG;
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<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
|
||||
className="content-blur-overlay"
|
||||
style={{ opacity: contentBlur ? 1 : 0 }}
|
||||
|
||||
@@ -293,6 +293,17 @@
|
||||
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 {
|
||||
--titlebar-bg: var(--background);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user