From 287cbc76aed16a0f7471343c070e04c4499e6312 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:19:12 +0800 Subject: [PATCH] feat(glass): refine material response and rendering lifecycle (#596) --- src/@core/utils/corsImage.ts | 27 + src/@core/utils/image.ts | 29 +- src/App.vue | 303 +++-- src/components/dialog/GlassSettingsDialog.vue | 110 +- src/components/dialog/PluginConfigDialog.vue | 9 +- src/components/dialog/PluginDataDialog.vue | 9 +- .../__tests__/GlassSettingsDialog.spec.ts | 149 ++- src/components/theme/GlassOpticalLayer.vue | 179 ++- .../theme/__tests__/GlassOpticalLayer.spec.ts | 323 ++++- .../__tests__/useGlassOpticalRenderer.spec.ts | 1013 +++++++++++++++- .../useGlassWallpaperTransaction.spec.ts | 134 +++ .../usePagePresentationMotion.spec.ts | 28 + .../__tests__/useThemeCustomizer.spec.ts | 237 +++- src/composables/useGlassOpticalRenderer.ts | 1037 +++++++++++++---- .../useGlassWallpaperTransaction.ts | 132 +++ src/composables/usePagePresentationMotion.ts | 11 +- src/composables/useThemeCustomizer.ts | 212 +++- .../default/components/UserProfile.vue | 6 +- src/locales/en-US.ts | 5 +- src/locales/zh-CN.ts | 2 +- src/locales/zh-TW.ts | 2 +- .../__tests__/glassOverlayMaterial.spec.ts | 67 ++ src/styles/__tests__/pluginCardAccent.spec.ts | 18 + src/styles/common.scss | 5 +- src/styles/themes/glass.scss | 131 ++- src/utils/__tests__/glassOptics.spec.ts | 179 ++- .../__tests__/glassWallpaperTone.spec.ts | 137 ++- src/utils/glassOptics.ts | 165 ++- src/utils/glassWallpaperTone.ts | 120 +- 29 files changed, 4146 insertions(+), 633 deletions(-) create mode 100644 src/@core/utils/corsImage.ts create mode 100644 src/composables/__tests__/useGlassWallpaperTransaction.spec.ts create mode 100644 src/composables/useGlassWallpaperTransaction.ts create mode 100644 src/styles/__tests__/glassOverlayMaterial.spec.ts create mode 100644 src/styles/__tests__/pluginCardAccent.spec.ts diff --git a/src/@core/utils/corsImage.ts b/src/@core/utils/corsImage.ts new file mode 100644 index 00000000..9712e722 --- /dev/null +++ b/src/@core/utils/corsImage.ts @@ -0,0 +1,27 @@ +/** 建立带 CORS 响应头的浏览器缓存,必要时修复旧的非 CORS 缓存条目。 */ +export async function preloadCorsImage(url: string): Promise { + const request = async (cache: RequestCache) => { + const source = new URL(url, window.location.href) + const response = await fetch(source, { + cache, + credentials: source.origin === window.location.origin ? 'same-origin' : 'omit', + mode: 'cors', + }) + if (!response.ok) return false + + await response.blob() + return true + } + + try { + if (await request('force-cache')) return true + } catch { + // 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。 + } + + try { + return await request('reload') + } catch { + return false + } +} diff --git a/src/@core/utils/image.ts b/src/@core/utils/image.ts index 171cd85f..d6a82600 100644 --- a/src/@core/utils/image.ts +++ b/src/@core/utils/image.ts @@ -1,4 +1,5 @@ import ColorThief from 'colorthief' +export { preloadCorsImage } from './corsImage' const DEFAULT_DOMINANT_COLOR = '#28A9E1' const DOMINANT_COLOR_CACHE_LIMIT = 100 @@ -96,31 +97,3 @@ export async function preloadImage(url: string): Promise { } }) } - -/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */ -export async function preloadCorsImage(url: string): Promise { - const request = async (cache: RequestCache) => { - const source = new URL(url, window.location.href) - const response = await fetch(source, { - cache, - credentials: source.origin === window.location.origin ? 'same-origin' : 'omit', - mode: 'cors', - }) - if (!response.ok) return false - - await response.blob() - return true - } - - try { - if (await request('force-cache')) return true - } catch { - // 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。 - } - - try { - return await request('reload') - } catch { - return false - } -} diff --git a/src/App.vue b/src/App.vue index f1c94e32..71ba758f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores' import { getBrowserLocale, setI18nLanguage } from './plugins/i18n' import { SupportedLocale } from '@/types/i18n' import { checkAndEmitUnreadMessages } from '@/utils/badge' -import { preloadCorsImage, preloadImage } from './@core/utils/image' +import { preloadImage } from './@core/utils/image' import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' @@ -27,6 +27,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils' import { configureApexChartsTheme } from '@/utils/apexCharts' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' +import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction' import { BACKGROUND_ROTATION_GRACE_MS, createBackgroundCandidateOrderResolver, @@ -45,7 +46,7 @@ import { } from '@/utils/loginPresentation' import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, - loadGlassWallpaperToneProfile, + loadGlassWallpaperTone, type GlassWallpaperToneProfile, } from '@/utils/glassWallpaperTone' @@ -97,6 +98,17 @@ function resolveInitialThemeName(themePreference: string) { return resolveThemeName(themePreference) } +function recordGlassLaunchTiming(stage: string, detail?: string) { + const timingWindow = window as typeof window & { + __glassPerformanceProbeEnabled?: boolean + __glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }> + } + if (!import.meta.env.DEV || !timingWindow.__glassPerformanceProbeEnabled) return + + timingWindow.__glassLaunchTimings ??= [] + timingWindow.__glassLaunchTimings.push({ detail, stage, time: performance.now() }) +} + // 生效主题 const vuetifyTheme = useTheme() const { global: globalTheme } = vuetifyTheme @@ -135,12 +147,22 @@ const globalSettingsStore = useGlobalSettingsStore() // 背景图片 const backgroundImages = ref([]) const backgroundLayers = ref(createLoginBackgroundLayers()) +const backgroundDisplayImages = ref>({}) const backgroundToneProfiles = ref>({}) const activeImageIndex = ref(0) const previousImageIndex = ref(null) const isBackgroundCrossfading = ref(false) const backgroundCrossfadeStartedAt = ref(0) -const pendingOpticalBackgroundImage = ref('') +const { + acknowledgeActivated: acknowledgeOpticalWallpaperActivated, + acknowledgePrepared: acknowledgeOpticalWallpaperPrepared, + activationRevision: activateOpticalWallpaperRevision, + cancel: cancelOpticalWallpaperTransaction, + requestedRevision: pendingOpticalWallpaperRevision, + requestedUrl: pendingOpticalBackgroundImage, + requestActivation: requestOpticalWallpaperActivation, + requestPreparation: requestOpticalWallpaperPreparation, +} = useGlassWallpaperTransaction() const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver() const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle() const preferredMotion = usePreferredReducedMotion() @@ -213,13 +235,16 @@ const shouldLoadBackgroundImages = computed( ) const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '') const renderedBackgroundLayers = computed(() => backgroundLayers.value) -const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value)) -const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value)) +const getOpticalBackgroundImage = (imageUrl: string) => imageUrl +const getPreparedBackgroundImage = (imageUrl: string) => backgroundDisplayImages.value[imageUrl] ?? imageUrl +const getPreparedOpticalBackgroundImage = (imageUrl: string) => + backgroundDisplayImages.value[imageUrl] ?? getOpticalBackgroundImage(imageUrl) +const activeOpticalBackgroundImage = computed(() => getPreparedOpticalBackgroundImage(activeBackgroundImage.value)) const previousOpticalBackgroundImage = computed(() => { const previousIndex = previousImageIndex.value if (previousIndex === null) return '' - return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '') + return getPreparedOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '') }) const shouldRenderGlassOpticalLayer = computed( () => @@ -242,12 +267,12 @@ const shouldRenderGlobalBlurLayer = computed( let backgroundRetryTimer: number | null = null let backgroundRequestController: AbortController | null = null let backgroundCrossfadeTimer: number | null = null -let pendingOpticalWallpaperTimer: number | null = null -let pendingOpticalWallpaperResolve: ((ready: boolean) => void) | null = null let authenticatedStateTimer: number | null = null let backgroundLoadVersion = 0 let backgroundRecoveryAttemptedVersion = -1 let backgroundRotationVersion = 0 +let backgroundPreloadIdleHandle: number | null = null +let backgroundPreloadTimer: number | null = null // 读取并同步透明主题背景设置到根组件响应式状态。 function applyTransparentBackgroundSettings() { @@ -265,27 +290,15 @@ function handleTransparencySettingsChanged(event: Event) { transparencyGlassQuality.value = glassQuality } -/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile,不阻断 CSS 背景。 */ -async function ensureBackgroundToneProfile(imageUrl: string) { - if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE - - const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl)) - backgroundToneProfiles.value = { - ...backgroundToneProfiles.value, - [imageUrl]: profile, - } - - return profile -} - /** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */ function getBackgroundLayerStyle(layer: LoginBackgroundLayer) { const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE const appearance = effectiveGlassSettings.value.glassAppearance const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86 + const displayUrl = isGlassTheme.value ? getPreparedBackgroundImage(layer.url) : layer.url return { - 'backgroundImage': layer.url ? `url(${layer.url})` : undefined, + 'backgroundImage': displayUrl ? `url(${displayUrl})` : undefined, '--glass-wallpaper-brightness': String(materialExposure * profile.exposure), } } @@ -512,35 +525,39 @@ function clearBackgroundCrossfadeTimer() { } } -/** 结束当前 GPU 纹理预备等待,旧请求不得继续提交壁纸切换。 */ -function settlePendingOpticalWallpaper(ready: boolean) { - if (pendingOpticalWallpaperTimer !== null) { - window.clearTimeout(pendingOpticalWallpaperTimer) - pendingOpticalWallpaperTimer = null - } - pendingOpticalBackgroundImage.value = '' - pendingOpticalWallpaperResolve?.(ready) - pendingOpticalWallpaperResolve = null -} - /** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */ -function prepareOpticalWallpaper(url: string) { +async function prepareOpticalWallpaper(url: string) { if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) { - return Promise.resolve(true) + return { ready: true, revision: 0 } } - settlePendingOpticalWallpaper(false) - pendingOpticalBackgroundImage.value = url + const ready = requestOpticalWallpaperPreparation(url) + const revision = pendingOpticalWallpaperRevision.value - return new Promise(resolve => { - pendingOpticalWallpaperResolve = resolve - pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000) - }) + return { ready: await ready, revision } } /** 只接受当前待切换 URL 的 renderer 就绪回执。 */ -function handleOpticalWallpaperPrepared(url: string) { - if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true) +function handleOpticalWallpaperPrepared(url: string, revision: number) { + acknowledgeOpticalWallpaperPrepared(url, revision) +} + +/** 任一 context 无法准备当前候选时取消 revision,禁止后续材质刷新重复加载失效 URL。 */ +function handleOpticalWallpaperPreparationFailed(_url: string, revision: number) { + cancelOpticalWallpaperTransaction(revision) +} + +/** 两个 context 均已原子消费 prepared 资源后,以同一时钟提交 DOM 壁纸。 */ +function handleOpticalWallpaperActivated(url: string, revision: number, startedAt: number) { + const activation = acknowledgeOpticalWallpaperActivated(url, revision, startedAt) + if (!activation) return + + activateBackgroundImage(activation.payload, activation.startedAt) +} + +/** 任一 context 提交失败时取消整个 revision,禁止另一 context 继续持有半提交状态。 */ +function handleOpticalWallpaperActivationFailed(_url: string, revision: number) { + cancelOpticalWallpaperTransaction(revision) } // 重置背景图交叉淡入淡出状态。 @@ -553,22 +570,23 @@ function resetBackgroundCrossfade() { } // 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。 -function activateBackgroundImage(nextIndex: number) { +function activateBackgroundImage(nextIndex: number, startedAt = performance.now()) { if (nextIndex === activeImageIndex.value) return clearBackgroundCrossfadeTimer() backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '') previousImageIndex.value = activeImageIndex.value isBackgroundCrossfading.value = true - backgroundCrossfadeStartedAt.value = performance.now() + backgroundCrossfadeStartedAt.value = startedAt activeImageIndex.value = nextIndex backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value) + const remainingDuration = Math.max(0, BACKGROUND_CROSSFADE_DURATION_MS - (performance.now() - startedAt)) backgroundCrossfadeTimer = window.setTimeout(() => { previousImageIndex.value = null isBackgroundCrossfading.value = false backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value) backgroundCrossfadeTimer = null - }, BACKGROUND_CROSSFADE_DURATION_MS) + }, remainingDuration) } // 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。 @@ -592,31 +610,86 @@ function preloadNextBackgroundImage() { void preloadBackgroundCandidate(backgroundImages.value[nextIndex]) } -/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */ +function cancelNextBackgroundPreload() { + const idleWindow = window as typeof window & { + cancelIdleCallback?: (handle: number) => void + } + if (backgroundPreloadIdleHandle !== null) { + idleWindow.cancelIdleCallback?.(backgroundPreloadIdleHandle) + backgroundPreloadIdleHandle = null + } + if (backgroundPreloadTimer !== null) { + window.clearTimeout(backgroundPreloadTimer) + backgroundPreloadTimer = null + } +} + +/** 首屏稳定后才预备轮播候选,避免第二张完整壁纸与 Dashboard 和首张纹理争抢资源。 */ +function scheduleNextBackgroundPreload() { + cancelNextBackgroundPreload() + if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1 || document.getElementById('loading-bg')) { + return + } + + const idleWindow = window as typeof window & { + requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number + } + if (typeof idleWindow.requestIdleCallback === 'function') { + backgroundPreloadIdleHandle = idleWindow.requestIdleCallback( + () => { + backgroundPreloadIdleHandle = null + preloadNextBackgroundImage() + }, + { timeout: 3000 }, + ) + return + } + + backgroundPreloadTimer = window.setTimeout(() => { + backgroundPreloadTimer = null + preloadNextBackgroundImage() + }, 1200) +} + +/** 玻璃壁纸以一次匿名解码同时完成可读性和 tone 分析,失败时仍允许 CSS 回退。 */ async function preloadBackgroundCandidate(imageUrl: string) { - const toneProfile = isGlassTheme.value - ? ensureBackgroundToneProfile(imageUrl) - : Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE) - if (!shouldRenderGlassOpticalLayer.value) { - const [available] = await Promise.all([preloadImage(imageUrl), toneProfile]) + recordGlassLaunchTiming('wallpaper-source-requested', imageUrl) + if (!isGlassTheme.value) return preloadImage(imageUrl) - return available + let opticalUrl = getOpticalBackgroundImage(imageUrl) + let tone = await loadGlassWallpaperTone(opticalUrl) + if (!tone.corsReady && isLogin.value) { + const proxyUrl = getDisplayImageUrl(imageUrl, true) + if (proxyUrl !== opticalUrl) { + const proxyTone = await loadGlassWallpaperTone(proxyUrl) + if (proxyTone.corsReady) { + opticalUrl = proxyUrl + tone = proxyTone + } + } + } + backgroundToneProfiles.value = { + ...backgroundToneProfiles.value, + [imageUrl]: tone.profile, + } + if (tone.corsReady) { + // DOM、tone 与 renderer 共享完全相同的像素源,避免首屏重复下载和解码。 + backgroundDisplayImages.value = { + ...backgroundDisplayImages.value, + [imageUrl]: opticalUrl, + } + recordGlassLaunchTiming('wallpaper-source-ready', imageUrl) + return true } - const opticalUrl = getOpticalBackgroundImage(imageUrl) - const opticalReady = await preloadCorsImage(opticalUrl) - if (!opticalReady) { - const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile]) - - return displayReady + backgroundDisplayImages.value = { + ...backgroundDisplayImages.value, + [imageUrl]: imageUrl, } - const [displayReady] = await Promise.all([ - opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl), - toneProfile, - ]) - - return displayReady + const ready = await preloadImage(imageUrl) + recordGlassLaunchTiming(ready ? 'wallpaper-source-ready' : 'wallpaper-source-failed', imageUrl) + return ready } // 背景图片轮换函数 @@ -630,20 +703,35 @@ async function rotateBackgroundImage() { const nextIndex = (activeIndex + offset) % backgroundImages.value.length const nextImage = backgroundImages.value[nextIndex] - const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined - if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue - const imagesReady = await preloadBackgroundRotationImages({ - displayUrl: nextImage, - opticalUrl: opticalImage, - preload: preloadImage, - }) - if (!imagesReady) continue - await ensureBackgroundToneProfile(nextImage) - if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return + let opticalRevision = 0 + try { + if (!(await preloadBackgroundCandidate(nextImage))) continue + const opticalImage = shouldRenderGlassOpticalLayer.value + ? getPreparedOpticalBackgroundImage(nextImage) + : undefined + if (opticalImage) { + const preparation = await prepareOpticalWallpaper(opticalImage) + opticalRevision = preparation.revision + if (!preparation.ready) continue + } + const imagesReady = await preloadBackgroundRotationImages({ + displayUrl: isGlassTheme.value ? getPreparedBackgroundImage(nextImage) : nextImage, + opticalUrl: opticalImage, + preload: preloadImage, + }) + if (!imagesReady) continue + if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return - activateBackgroundImage(nextIndex) - preloadNextBackgroundImage() - return + if (opticalImage) { + if (!(await requestOpticalWallpaperActivation(nextIndex, opticalRevision))) continue + } else { + activateBackgroundImage(nextIndex) + } + scheduleNextBackgroundPreload() + return + } finally { + if (opticalRevision > 0) cancelOpticalWallpaperTransaction(opticalRevision) + } } if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) { @@ -657,8 +745,9 @@ async function rotateBackgroundImage() { // 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。 function stopBackgroundRotation() { backgroundRotationVersion += 1 + cancelNextBackgroundPreload() removeBackgroundTimer('background-rotation') - settlePendingOpticalWallpaper(false) + cancelOpticalWallpaperTransaction() } function clearBackgroundRotationGrace() { @@ -684,7 +773,7 @@ function startBackgroundRotation() { stopBackgroundRotation() if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) { - preloadNextBackgroundImage() + scheduleNextBackgroundPreload() // 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。 addBackgroundTimer( 'background-rotation', @@ -784,11 +873,15 @@ async function animateAndRemoveLoader() { document.documentElement.style.removeProperty('overflow') document.body.style.removeProperty('overflow') completeLaunchLoading() + recordGlassLaunchTiming('loader-removed') + scheduleNextBackgroundPreload() resolve() }, LAUNCH_EXIT_DURATION_MS) }) } else { completeLaunchLoading() + recordGlassLaunchTiming('loader-removed') + scheduleNextBackgroundPreload() } } @@ -860,6 +953,7 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) { } backgroundRecoveryAttemptedVersion = -1 resetBackgroundCrossfade() + recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value) startBackgroundRotation() } catch (error: any) { if (loadVersion !== backgroundLoadVersion) return @@ -877,6 +971,28 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) { } } +// 登录前后复用同一壁纸列表和活动项;请求与主题样式并行,避免玻璃 CSS 阻塞首张壁纸准备。 +watch( + shouldLoadBackgroundImages, + shouldLoad => { + stopBackgroundLoading() + if (shouldLoad) { + void loadBackgroundImages(backgroundLoadVersion) + } else if (!isBackdropTheme.value) { + backgroundImages.value = [] + } + }, + { immediate: true }, +) + +watch(isGlassTheme, enabled => { + if (!enabled) return + + void Promise.all( + renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => preloadBackgroundCandidate(layer.url)), + ) +}) + onMounted(async () => { // 移除URL中的时间戳参数 const url = new URL(window.location.href) @@ -915,28 +1031,6 @@ onMounted(async () => { window.addEventListener('focus', handlePageShowThemeSync) window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) - // 登录前后复用同一壁纸列表和活动项,主题变化只改变呈现方式。 - watch( - shouldLoadBackgroundImages, - shouldLoad => { - stopBackgroundLoading() - if (shouldLoad) { - void loadBackgroundImages(backgroundLoadVersion) - } else if (!isBackdropTheme.value) { - backgroundImages.value = [] - } - }, - { immediate: true }, - ) - - watch(isGlassTheme, enabled => { - if (!enabled) return - - void Promise.all( - renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)), - ) - }) - // 使用优化后的加载界面移除逻辑 ensureRenderComplete(() => { nextTick(removeLoadingWithStateCheck) @@ -1031,6 +1125,11 @@ onUnmounted(() => { :wallpaper-url="activeOpticalBackgroundImage" :previous-wallpaper-url="previousOpticalBackgroundImage" :pending-wallpaper-url="pendingOpticalBackgroundImage" + :pending-wallpaper-revision="pendingOpticalWallpaperRevision" + :activate-wallpaper-revision="activateOpticalWallpaperRevision" + @wallpaper-activation-failed="handleOpticalWallpaperActivationFailed" + @wallpaper-activated="handleOpticalWallpaperActivated" + @wallpaper-preparation-failed="handleOpticalWallpaperPreparationFailed" @wallpaper-prepared="handleOpticalWallpaperPrepared" /> diff --git a/src/components/dialog/GlassSettingsDialog.vue b/src/components/dialog/GlassSettingsDialog.vue index 297be150..d9c158d1 100644 --- a/src/components/dialog/GlassSettingsDialog.vue +++ b/src/components/dialog/GlassSettingsDialog.vue @@ -11,10 +11,15 @@ import { GLASS_OPTICAL_STRENGTH_MAX, GLASS_OPTICAL_STRENGTH_MIN, getAvailableGlassOpticalPresets, + getGlassOpticalPresetKey, getGlassOpticalPresetParameters, + getGlassOpticalPresetParametersWithOverrides, normalizeGlassOpticalStrength, + type GlassOpticalParameters, type GlassOpticalPreset, + type GlassOpticalPresetOverrides, } from '@/utils/glassOptics' +import { useDisplay } from 'vuetify' import { useI18n } from 'vue-i18n' const props = withDefaults( @@ -32,11 +37,13 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const display = useDisplay() const { settings } = useThemeCustomizer() const draftAppearance = ref(settings.value.glassAppearance) const draftDeformationStrength = ref(settings.value.glassDeformationStrength) const draftFlowStrength = ref(settings.value.glassFlowStrength) const draftPreset = ref(settings.value.glassPreset) +const draftPresetOverrides = ref({ ...settings.value.glassPresetOverrides }) const draftQuality = ref(settings.value.glassQuality) const draftReflectionStrength = ref(settings.value.glassReflectionStrength) const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength) @@ -68,6 +75,7 @@ watch( draftDeformationStrength.value = settings.value.glassDeformationStrength draftFlowStrength.value = settings.value.glassFlowStrength draftPreset.value = settings.value.glassPreset + draftPresetOverrides.value = { ...settings.value.glassPresetOverrides } draftQuality.value = settings.value.glassQuality draftReflectionStrength.value = settings.value.glassReflectionStrength draftTransmissionStrength.value = settings.value.glassTransmissionStrength @@ -112,7 +120,7 @@ function updateAppearance(value: unknown) { if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return draftAppearance.value = value - previewGlassSettings({ glassAppearance: value }) + applyPreset(activePreset.value) } /** 仅允许面板声明的质量档位进入待保存设置。 */ @@ -121,15 +129,18 @@ function updateQuality(value: unknown) { if (!option) return draftQuality.value = option.value - previewGlassSettings({ glassQuality: option.value }) + applyPreset(activePreset.value) } -/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */ +/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */ function previewDraftParameters() { previewGlassSettings({ + glassAppearance: draftAppearance.value, glassDeformationStrength: draftDeformationStrength.value, glassFlowStrength: draftFlowStrength.value, glassPreset: draftPreset.value, + glassPresetOverrides: draftPresetOverrides.value, + glassQuality: draftQuality.value, glassReflectionStrength: draftReflectionStrength.value, glassTransmissionStrength: draftTransmissionStrength.value, glassTranslationStrength: draftTranslationStrength.value, @@ -137,13 +148,29 @@ function previewDraftParameters() { }) } -/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */ +/** 读取草稿中当前显示的六参数。 */ +function getDraftParameters(): GlassOpticalParameters { + return { + deformation: draftDeformationStrength.value, + flow: draftFlowStrength.value, + reflection: draftReflectionStrength.value, + transmission: draftTransmissionStrength.value, + translation: draftTranslationStrength.value, + transparency: draftTransparencyStrength.value, + } +} + +/** 切换方案时优先恢复该组合的草稿覆盖,没有覆盖才使用矩阵。 */ function applyPreset(value: unknown) { if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return - if (!availablePresets.value.includes(value)) return - - const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value) - draftPreset.value = value + const effectivePreset = availablePresets.value.includes(value) ? value : 'natural' + const parameters = getGlassOpticalPresetParametersWithOverrides( + draftAppearance.value, + draftQuality.value, + effectivePreset, + draftPresetOverrides.value, + ) + draftPreset.value = effectivePreset draftDeformationStrength.value = parameters.deformation draftFlowStrength.value = parameters.flow draftReflectionStrength.value = parameters.reflection @@ -153,45 +180,66 @@ function applyPreset(value: unknown) { previewDraftParameters() } +/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */ +function updateDraftPresetOverride() { + const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value) + draftPresetOverrides.value = { + ...draftPresetOverrides.value, + [key]: getDraftParameters(), + } + previewDraftParameters() +} + /** 将采样平移限制为 renderer 支持的稳定范围。 */ function updateTranslationStrength(value: unknown) { draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) - previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value }) + updateDraftPresetOverride() } /** 将局部形变限制为质量档软上限所消费的用户范围。 */ function updateDeformationStrength(value: unknown) { draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) - previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value }) + updateDraftPresetOverride() } /** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */ function updateFlowStrength(value: unknown) { draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) - previewGlassSettings({ glassFlowStrength: draftFlowStrength.value }) + updateDraftPresetOverride() } /** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */ function updateReflectionStrength(value: unknown) { draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) - previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value }) + updateDraftPresetOverride() } /** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */ function updateTransmissionStrength(value: unknown) { draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) - previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value }) + updateDraftPresetOverride() } /** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */ function updateTransparencyStrength(value: unknown) { draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) - previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value }) + updateDraftPresetOverride() } -/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */ +/** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */ function resetSettings() { - applyPreset(activePreset.value) + const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value) + const nextOverrides = { ...draftPresetOverrides.value } + delete nextOverrides[key] + draftPresetOverrides.value = nextOverrides + const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, draftPreset.value) + draftDeformationStrength.value = parameters.deformation + draftFlowStrength.value = parameters.flow + draftReflectionStrength.value = parameters.reflection + draftTransmissionStrength.value = parameters.transmission + draftTranslationStrength.value = parameters.translation + draftTransparencyStrength.value = parameters.transparency + previewDraftParameters() } /** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */ @@ -206,6 +254,7 @@ async function saveSettings() { glassDeformationStrength: draftDeformationStrength.value, glassFlowStrength: draftFlowStrength.value, glassPreset: draftPreset.value, + glassPresetOverrides: draftPresetOverrides.value, glassQuality: draftQuality.value, glassReflectionStrength: draftReflectionStrength.value, glassTransmissionStrength: draftTransmissionStrength.value, @@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview) @@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview) padding: 20px 24px 24px; } +.glass-settings-dialog__actions { + flex: none; + padding: 16px 24px; +} + .glass-settings-dialog__label { margin: 0 0 10px; color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); diff --git a/src/components/dialog/PluginConfigDialog.vue b/src/components/dialog/PluginConfigDialog.vue index 290cda0d..7b0d6048 100644 --- a/src/components/dialog/PluginConfigDialog.vue +++ b/src/components/dialog/PluginConfigDialog.vue @@ -173,12 +173,7 @@ onBeforeMount(async () => {