diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 9308dbd5..30925023 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -54,9 +54,6 @@ "@typescript-eslint/no-unused-vars": { "count": 1 }, - "no-useless-catch": { - "count": 1 - }, "sonarjs/no-ignored-exceptions": { "count": 1 } diff --git a/src/App.vue b/src/App.vue index 593c00cc..93096a66 100644 --- a/src/App.vue +++ b/src/App.vue @@ -29,9 +29,8 @@ import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composab import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' import { BACKGROUND_ROTATION_GRACE_MS, - commitPreloadedBackgroundRotation, + findFirstAvailableBackground, preloadBackgroundRotationImages, - preloadBackgroundSequence, shouldAllowBackgroundRotation, } from '@/utils/backgroundRotation' import { @@ -163,7 +162,6 @@ const wallpaperRequestMode = computed(() => getLoginWallpaperRequestMode(loginVi const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '') const renderedBackgroundLayers = computed(() => backgroundLayers.value) const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value)) -// 玻璃 profile 使用同源 catalog;旧后端返回外链时仍由 renderer 的纹理失败路径安全回退。 const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value)) const previousOpticalBackgroundImage = computed(() => { const previousIndex = previousImageIndex.value @@ -195,8 +193,9 @@ 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 backgroundPreloadVersion = 0 // 读取并同步透明主题背景设置到根组件响应式状态。 function applyTransparentBackgroundSettings() { @@ -480,66 +479,65 @@ function activateBackgroundImage(nextIndex: number) { }, BACKGROUND_CROSSFADE_DURATION_MS) } -// 获取背景图片 +// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。 async function fetchBackgroundImages(requestMode: LoginWallpaperRequestMode) { + backgroundRequestController?.abort() + const controller = new AbortController() + backgroundRequestController = controller try { - backgroundRequestController?.abort() - backgroundRequestController = new AbortController() - backgroundImages.value = await api.get(`/login/wallpapers`, { + return await api.get(`/login/wallpapers`, { params: requestMode === 'same-origin' ? { same_origin: true } : undefined, - signal: backgroundRequestController.signal, + signal: controller.signal, }) - activeImageIndex.value = 0 - resetBackgroundCrossfade() - } catch (e) { - throw e + } finally { + if (backgroundRequestController === controller) backgroundRequestController = null } } +/** 仅提前加载当前图的下一项,不建立全目录预载队列。 */ +function preloadNextBackgroundImage() { + if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return + const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length + void preloadImage(backgroundImages.value[nextIndex]) +} + // 背景图片轮换函数 -function rotateBackgroundImage() { - if (!allowsBackgroundRotation.value) return +async function rotateBackgroundImage() { + if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return - if (backgroundImages.value.length > 1) { - // 计算下一个图片索引 - const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length - const requestVersion = ++backgroundRotationVersion + const requestVersion = ++backgroundRotationVersion + const activeIndex = activeImageIndex.value + for (let offset = 1; offset < backgroundImages.value.length; offset += 1) { + if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return + + const nextIndex = (activeIndex + offset) % backgroundImages.value.length const nextImage = backgroundImages.value[nextIndex] const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined - - void commitPreloadedBackgroundRotation({ - canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion, - commit: () => activateBackgroundImage(nextIndex), - preload: () => - preloadBackgroundRotationImages({ - displayUrl: nextImage, - opticalUrl: opticalImage, - preload: preloadImage, - }).then(imagesReady => (imagesReady && opticalImage ? prepareOpticalWallpaper(opticalImage) : imagesReady)), + const imagesReady = await preloadBackgroundRotationImages({ + displayUrl: nextImage, + opticalUrl: opticalImage, + preload: preloadImage, }) + if (!imagesReady) continue + if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue + if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return + + activateBackgroundImage(nextIndex) + preloadNextBackgroundImage() + return + } + + if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) { + stopBackgroundRotation() + const recoveryVersion = ++backgroundLoadVersion + backgroundRecoveryAttemptedVersion = recoveryVersion + void loadBackgroundImages(wallpaperRequestMode.value, recoveryVersion) } } -/** 当前图稳定后按轮播顺序串行预加载其余壁纸,队列失效时不再发起新请求。 */ -async function preloadRemainingBackgroundImages() { - if (backgroundImages.value.length <= 1) return - - const version = ++backgroundPreloadVersion - const orderedImages = backgroundImages.value - .slice(activeImageIndex.value + 1) - .concat(backgroundImages.value.slice(0, activeImageIndex.value)) - - await preloadBackgroundSequence({ - canContinue: () => version === backgroundPreloadVersion && shouldLoadBackgroundImages.value, - preload: preloadImage, - urls: orderedImages, - }) -} - -// 停止轮询并使已经发起的壁纸预加载失效,避免非活动状态收到迟到提交。 +// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。 function stopBackgroundRotation() { backgroundRotationVersion += 1 - backgroundPreloadVersion += 1 removeBackgroundTimer('background-rotation') settlePendingOpticalWallpaper(false) } @@ -567,12 +565,11 @@ function startBackgroundRotation() { stopBackgroundRotation() if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) { - // 宽限期结束会使预载队列失效;恢复活动状态时从当前图继续补齐剩余壁纸。 - void preloadRemainingBackgroundImages() + preloadNextBackgroundImage() // 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。 addBackgroundTimer( 'background-rotation', - rotateBackgroundImage, + () => void rotateBackgroundImage(), 10000, // 每10秒切换一次 { runInBackground: true, @@ -614,6 +611,7 @@ watch(allowsBackgroundRotation, allowsRotation => { // 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。 function stopBackgroundLoading() { + backgroundLoadVersion += 1 backgroundRequestController?.abort() backgroundRequestController = null @@ -710,21 +708,43 @@ async function removeLoadingWithStateCheck() { } // 加载背景图片 -async function loadBackgroundImages(requestMode: LoginWallpaperRequestMode, retryCount = 0) { +async function loadBackgroundImages(requestMode: LoginWallpaperRequestMode, loadVersion: number, retryCount = 0) { const maxRetries = 3 try { - await fetchBackgroundImages(requestMode) - const activeImage = activeBackgroundImage.value - if (activeImage && !(await preloadImage(activeImage))) throw new Error('登录壁纸首图预加载失败') + const images = await fetchBackgroundImages(requestMode) + if (loadVersion !== backgroundLoadVersion) return + + const firstAvailableIndex = await findFirstAvailableBackground({ + urls: images, + canContinue: () => loadVersion === backgroundLoadVersion, + preload: preloadImage, + }) + if (firstAvailableIndex === null) throw new Error('没有可用的登录壁纸') + if (loadVersion !== backgroundLoadVersion) return + + const currentImage = activeBackgroundImage.value + const currentIndex = images.indexOf(currentImage) + if (currentImage && currentIndex < 0) { + backgroundImages.value = [currentImage, ...images] + activeImageIndex.value = 0 + } else { + backgroundImages.value = images + activeImageIndex.value = currentIndex >= 0 ? currentIndex : firstAvailableIndex + } + backgroundRecoveryAttemptedVersion = -1 + resetBackgroundCrossfade() startBackgroundRotation() } catch (error: any) { + if (loadVersion !== backgroundLoadVersion) return const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED' if (retryCount < maxRetries) { const baseDelay = isAbortError ? 1000 : 3000 const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000) backgroundRetryTimer = window.setTimeout(() => { backgroundRetryTimer = null - loadBackgroundImages(requestMode, retryCount + 1) + if (loadVersion === backgroundLoadVersion) { + void loadBackgroundImages(requestMode, loadVersion, retryCount + 1) + } }, retryDelay) } } @@ -771,10 +791,12 @@ onMounted(async () => { // 背景范围或玻璃同源能力变化时重新加载;登录前后玻璃主题保持同一请求模式和活动壁纸。 watch( () => [shouldLoadBackgroundImages.value, wallpaperRequestMode.value] as const, - ([shouldLoad, requestMode]) => { + ([shouldLoad, requestMode], previous) => { + if (previous && shouldLoad === previous[0] && requestMode === previous[1]) return + stopBackgroundLoading() if (shouldLoad) { - loadBackgroundImages(requestMode) + void loadBackgroundImages(requestMode, backgroundLoadVersion) } else if (!isBackdropTheme.value) { backgroundImages.value = [] } diff --git a/src/utils/__tests__/backgroundRotation.spec.ts b/src/utils/__tests__/backgroundRotation.spec.ts index f52dae9e..c3e53ac8 100644 --- a/src/utils/__tests__/backgroundRotation.spec.ts +++ b/src/utils/__tests__/backgroundRotation.spec.ts @@ -1,8 +1,7 @@ import { BACKGROUND_ROTATION_GRACE_MS, - commitPreloadedBackgroundRotation, + findFirstAvailableBackground, preloadBackgroundRotationImages, - preloadBackgroundSequence, shouldAllowBackgroundRotation, } from '@/utils/backgroundRotation' import { describe, expect, it, vi } from 'vitest' @@ -28,56 +27,6 @@ describe('background rotation lifecycle', () => { }) }) -describe('commitPreloadedBackgroundRotation', () => { - it('drops a successful preload when the rotation becomes inactive before completion', async () => { - const preload = deferred() - const commit = vi.fn() - let active = true - const result = commitPreloadedBackgroundRotation({ - canCommit: () => active, - commit, - preload: () => preload.promise, - }) - - active = false - preload.resolve(true) - - await expect(result).resolves.toBe(false) - expect(commit).not.toHaveBeenCalled() - }) - - it('commits a successful preload while the request remains current', async () => { - const commit = vi.fn() - - await expect( - commitPreloadedBackgroundRotation({ - canCommit: () => true, - commit, - preload: async () => true, - }), - ).resolves.toBe(true) - expect(commit).toHaveBeenCalledOnce() - }) - - it('drops an obsolete preload even when decorative motion becomes active again', async () => { - const preload = deferred() - const commit = vi.fn() - const requestVersion = 1 - let currentVersion = requestVersion - const result = commitPreloadedBackgroundRotation({ - canCommit: () => requestVersion === currentVersion, - commit, - preload: () => preload.promise, - }) - - currentVersion += 1 - preload.resolve(true) - - await expect(result).resolves.toBe(false) - expect(commit).not.toHaveBeenCalled() - }) -}) - describe('preloadBackgroundRotationImages', () => { it('does not let an unused optical texture block the visible wallpaper', async () => { const preload = vi.fn(async (url: string) => url === 'display.jpg') @@ -106,37 +55,34 @@ describe('preloadBackgroundRotationImages', () => { }) }) -describe('preloadBackgroundSequence', () => { - it('preloads remaining wallpapers sequentially in rotation order', async () => { - const calls: string[] = [] +describe('findFirstAvailableBackground', () => { + it('skips invalid entries without changing the original sequence', async () => { + const preload = vi.fn(async (url: string) => url === 'two.jpg') await expect( - preloadBackgroundSequence({ - canContinue: () => true, - preload: async url => { - calls.push(url) - return url !== 'two.jpg' - }, + findFirstAvailableBackground({ urls: ['one.jpg', 'two.jpg', 'three.jpg'], + canContinue: () => true, + preload, }), - ).resolves.toEqual([true, false, true]) - expect(calls).toEqual(['one.jpg', 'two.jpg', 'three.jpg']) + ).resolves.toBe(1) + expect(preload.mock.calls.map(([url]) => url)).toEqual(['one.jpg', 'two.jpg']) }) - it('stops an obsolete queue before starting the next image', async () => { - let active = true - const calls: string[] = [] - - await preloadBackgroundSequence({ - canContinue: () => active, - preload: async url => { - calls.push(url) - active = false - return true - }, + it('drops an obsolete batch before trying another image', async () => { + const pending = deferred() + let current = true + const preload = vi.fn(() => pending.promise) + const result = findFirstAvailableBackground({ urls: ['one.jpg', 'two.jpg'], + canContinue: () => current, + preload, }) - expect(calls).toEqual(['one.jpg']) + current = false + pending.resolve(true) + + await expect(result).resolves.toBeNull() + expect(preload).toHaveBeenCalledOnce() }) }) diff --git a/src/utils/backgroundRotation.ts b/src/utils/backgroundRotation.ts index 6bc8dbad..ba16f9ca 100644 --- a/src/utils/backgroundRotation.ts +++ b/src/utils/backgroundRotation.ts @@ -8,15 +8,6 @@ export function shouldAllowBackgroundRotation(state: AppActivityState, graceActi return !reducedMotion && (state === 'active' || graceActive) } -interface PreloadedBackgroundRotationOptions { - /** 提交前重新判断当前生命周期和请求版本是否仍允许切换。 */ - canCommit: () => boolean - /** 将已经完成预加载的壁纸切换为活动背景。 */ - commit: () => void - /** 预加载目标壁纸,并以布尔值表示是否可安全显示。 */ - preload: () => Promise -} - interface BackgroundRotationImagePreloadOptions { /** 外层背景实际显示的壁纸地址。 */ displayUrl: string @@ -26,25 +17,25 @@ interface BackgroundRotationImagePreloadOptions { preload: (url: string) => Promise } -interface BackgroundSequencePreloadOptions { - /** 每张图片完成后重新判断队列是否仍属于当前页面与请求代次。 */ - canContinue: () => boolean - /** 按实际轮播顺序排列的待预加载地址。 */ +interface FirstAvailableBackgroundOptions { + /** 保持后端返回顺序的候选壁纸。 */ urls: string[] + /** 当前加载批次仍可提交时返回 true。 */ + canContinue: () => boolean /** 执行单张图片预加载并返回可用状态。 */ preload: (url: string) => Promise } -/** - * 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。 - */ -export async function commitPreloadedBackgroundRotation(options: PreloadedBackgroundRotationOptions) { - const succeeded = await options.preload() +/** 按来源顺序寻找首张可用壁纸,单项失败或过期批次不会提交可见状态。 */ +export async function findFirstAvailableBackground(options: FirstAvailableBackgroundOptions) { + for (let index = 0; index < options.urls.length; index += 1) { + if (!options.canContinue()) return null + const available = await options.preload(options.urls[index]) + if (!options.canContinue()) return null + if (available) return index + } - if (!succeeded || !options.canCommit()) return false - - options.commit() - return true + return null } /** @@ -59,15 +50,3 @@ export async function preloadBackgroundRotationImages(options: BackgroundRotatio return results.every(Boolean) } - -/** 当前壁纸稳定后串行预加载剩余轮播项,避免并发争抢首屏带宽。 */ -export async function preloadBackgroundSequence(options: BackgroundSequencePreloadOptions) { - const results: boolean[] = [] - - for (const url of options.urls) { - if (!options.canContinue()) break - results.push(await options.preload(url)) - } - - return results -} diff --git a/src/utils/glassOptics.ts b/src/utils/glassOptics.ts index c5d587ad..862c61c3 100644 --- a/src/utils/glassOptics.ts +++ b/src/utils/glassOptics.ts @@ -111,7 +111,6 @@ export interface GlassOpticalRenderProfile { springFrequency: number /** 活动壁纸进入 GPU 前的最长边限制。 */ textureLimit: number - /** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */ textureSource: 'auto' | 'procedural' | 'wallpaper' /** 参与液态方向计算的最近输入采样数量。 */ trailCount: number