perf(glass): complete phase three optimization (#580)

This commit is contained in:
InfinityPacer
2026-07-23 20:26:28 +08:00
committed by GitHub
parent 31caf8f5f8
commit 1bf6c1f1d8
12 changed files with 277 additions and 89 deletions
+10 -39
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { usePreferredReducedMotion } from '@vueuse/core'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { ensureRenderComplete, removeEl } from './@core/utils/dom' import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api, { type ConnectionAwareRequestConfig } from '@/api' import api, { type ConnectionAwareRequestConfig } from '@/api'
@@ -30,7 +31,6 @@ import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
const LOGIN_WALLPAPER_ROUTE = '/login' const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500 const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
// 生效主题 // 生效主题
const vuetifyTheme = useTheme() const vuetifyTheme = useTheme()
@@ -75,12 +75,13 @@ const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null) const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false) const isBackgroundCrossfading = ref(false)
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle() const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion()
// 壁纸轮播同时服从应用活动状态与系统动态效果偏好。
const allowsBackgroundRotation = computed(() => allowsDecorativeMotion.value && preferredMotion.value !== 'reduce')
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass') const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
const effectiveGlassSettings = useEffectiveGlassSettings() const effectiveGlassSettings = useEffectiveGlassSettings()
const isInitialRouteReady = ref(false) const isInitialRouteReady = ref(false)
const isGlassOpticalLayerDeferred = ref(true)
let glassOpticalLayerDeferTimer: number | null = null
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value) const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value) const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
@@ -100,7 +101,7 @@ const shouldRenderGlassOpticalLayer = computed(
() => () =>
isGlassTheme.value && isGlassTheme.value &&
effectiveGlassSettings.value.glassQuality !== 'css' && effectiveGlassSettings.value.glassQuality !== 'css' &&
!isGlassOpticalLayerDeferred.value && isInitialRouteReady.value &&
!isRenderThrottled.value && !isRenderThrottled.value &&
Boolean(activeBackgroundImage.value), Boolean(activeBackgroundImage.value),
) )
@@ -139,35 +140,6 @@ function handleTransparencySettingsChanged(event: Event) {
applyTransparentBackgroundSettings() applyTransparentBackgroundSettings()
/** 推荐页高质量光学层让首屏海报优先完成解码与合成。 */
watch(
() => [route.fullPath, effectiveGlassSettings.value.glassQuality, isInitialRouteReady.value] as const,
([, quality, routeReady]) => {
if (glassOpticalLayerDeferTimer !== null) {
window.clearTimeout(glassOpticalLayerDeferTimer)
glassOpticalLayerDeferTimer = null
}
// 首次导航完成前 route 仍可能是重定向来源,禁止 renderer 按错误页面短暂挂载。
if (!routeReady) {
isGlassOpticalLayerDeferred.value = true
return
}
if (route.path !== '/recommend' || quality !== 'high') {
isGlassOpticalLayerDeferred.value = false
return
}
isGlassOpticalLayerDeferred.value = true
glassOpticalLayerDeferTimer = window.setTimeout(() => {
isGlassOpticalLayerDeferred.value = false
glassOpticalLayerDeferTimer = null
}, MEDIA_DENSE_OPTICAL_DEFER_MS)
},
{ immediate: true },
)
void router.isReady().then(() => { void router.isReady().then(() => {
isInitialRouteReady.value = true isInitialRouteReady.value = true
}) })
@@ -412,7 +384,7 @@ async function fetchBackgroundImages() {
// 背景图片轮换函数 // 背景图片轮换函数
function rotateBackgroundImage() { function rotateBackgroundImage() {
if (!allowsDecorativeMotion.value) return if (!allowsBackgroundRotation.value) return
if (backgroundImages.value.length > 1) { if (backgroundImages.value.length > 1) {
// 计算下一个图片索引 // 计算下一个图片索引
@@ -420,7 +392,7 @@ function rotateBackgroundImage() {
const requestVersion = ++backgroundRotationVersion const requestVersion = ++backgroundRotationVersion
void commitPreloadedBackgroundRotation({ void commitPreloadedBackgroundRotation({
canCommit: () => allowsDecorativeMotion.value && requestVersion === backgroundRotationVersion, canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
commit: () => activateBackgroundImage(nextIndex), commit: () => activateBackgroundImage(nextIndex),
preload: () => preloadImage(backgroundImages.value[nextIndex]), preload: () => preloadImage(backgroundImages.value[nextIndex]),
}) })
@@ -437,7 +409,7 @@ function stopBackgroundRotation() {
function startBackgroundRotation() { function startBackgroundRotation() {
stopBackgroundRotation() stopBackgroundRotation()
if (allowsDecorativeMotion.value && backgroundImages.value.length > 1) { if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
// 使用优化的定时器管理器,后台时自动暂停 // 使用优化的定时器管理器,后台时自动暂停
addBackgroundTimer( addBackgroundTimer(
'background-rotation', 'background-rotation',
@@ -451,10 +423,10 @@ function startBackgroundRotation() {
} }
} }
watch(appActivityState, state => { watch(allowsBackgroundRotation, allowsRotation => {
resetBackgroundCrossfade() resetBackgroundCrossfade()
if (state === 'active') { if (allowsRotation) {
startBackgroundRotation() startBackgroundRotation()
} else { } else {
stopBackgroundRotation() stopBackgroundRotation()
@@ -655,7 +627,6 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer)
// 清除背景轮换定时器 // 清除背景轮换定时器
stopBackgroundLoading() stopBackgroundLoading()
if (authenticatedStateTimer) { if (authenticatedStateTimer) {
+1
View File
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
class="app-hover-lift-card outline-none ring-gray-500 media-card" class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{ :class="{
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering), 'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
'media-card--image-loaded': isImageLoaded,
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
@click.stop="handleMediaCardClick(hover.isHovering)" @click.stop="handleMediaCardClick(hover.isHovering)"
@@ -520,8 +520,10 @@ onMounted(() => {
<input <input
ref="searchWordInput" ref="searchWordInput"
v-model="searchWord" v-model="searchWord"
id="global-media-search"
type="text" type="text"
class="search-native-input" class="search-native-input"
:aria-label="t('dialog.searchBar.searchPlaceholder')"
:placeholder="t('dialog.searchBar.searchPlaceholder')" :placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')" @keydown.enter="searchMedia('media')"
@keydown.escape.stop="closeSearch" @keydown.escape.stop="closeSearch"
@@ -539,8 +541,10 @@ onMounted(() => {
<input <input
ref="searchWordInput" ref="searchWordInput"
v-model="searchWord" v-model="searchWord"
id="global-media-search"
type="text" type="text"
class="search-native-input" class="search-native-input"
:aria-label="t('dialog.searchBar.searchPlaceholder')"
:placeholder="t('dialog.searchBar.searchPlaceholder')" :placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')" @keydown.enter="searchMedia('media')"
@keydown.escape.stop="closeSearch" @keydown.escape.stop="closeSearch"
@@ -43,6 +43,9 @@ describe('SearchBarDialog media source selection', () => {
const { router } = await renderSearchBar() const { router } = await renderSearchBar()
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...') const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
expect(input.getAttribute('id')).toBe('global-media-search')
expect(input.getAttribute('aria-label')).toBe('搜索电影、剧集以及更多...')
await user.type(input, '流浪地球{Enter}') await user.type(input, '流浪地球{Enter}')
await waitFor(() => { await waitFor(() => {
+14 -27
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { findNearestScrollTarget, invalidateScrollTargetCache, type ScrollTarget } from '@/utils/scrollTarget'
import type { ComponentPublicInstance } from 'vue' import type { ComponentPublicInstance } from 'vue'
type ItemKey = string | number type ItemKey = string | number
type ScrollTarget = Window | HTMLElement
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -513,19 +513,7 @@ function getItemRef(key: ItemKey) {
} }
function findScrollTarget(): ScrollTarget { function findScrollTarget(): ScrollTarget {
let parent = containerRef.value?.parentElement ?? null return findNearestScrollTarget(containerRef.value)
while (parent && parent !== document.body && parent !== document.documentElement) {
const overflowY = window.getComputedStyle(parent).overflowY
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent
}
parent = parent.parentElement
}
return window
} }
function addScrollListener(target: ScrollTarget) { function addScrollListener(target: ScrollTarget) {
@@ -552,6 +540,11 @@ function refreshScrollTarget() {
addScrollListener(scrollTarget) addScrollListener(scrollTarget)
} }
function handleWindowResize() {
invalidateScrollTargetCache()
queueLayoutSync()
}
function syncLayoutWidth() { function syncLayoutWidth() {
const element = trackRef.value const element = trackRef.value
@@ -780,10 +773,7 @@ function invalidateMeasurementsForLayoutChange() {
const nextColumnCount = columnCount.value const nextColumnCount = columnCount.value
const nextColumnWidth = columnWidth.value const nextColumnWidth = columnWidth.value
if ( if (lastMeasuredColumnCount === nextColumnCount && Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1) {
lastMeasuredColumnCount === nextColumnCount &&
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
) {
return return
} }
@@ -817,7 +807,7 @@ onMounted(() => {
}) })
} }
window.addEventListener('resize', queueLayoutSync, { passive: true }) window.addEventListener('resize', handleWindowResize, { passive: true })
queueLayoutSync() queueLayoutSync()
}) })
@@ -840,7 +830,7 @@ onUnmounted(() => {
removeScrollListener(scrollTarget) removeScrollListener(scrollTarget)
scrollTarget = null scrollTarget = null
window.removeEventListener('resize', queueLayoutSync) window.removeEventListener('resize', handleWindowResize)
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
itemResizeObserver?.disconnect() itemResizeObserver?.disconnect()
@@ -881,13 +871,10 @@ watch(
}, },
) )
watch( watch([columnCount, columnWidth], () => {
[columnCount, columnWidth], invalidateMeasurementsForLayoutChange()
() => { queueViewportSync()
invalidateMeasurementsForLayoutChange() })
queueViewportSync()
},
)
watch( watch(
[() => props.scrollToIndex, () => props.items.length, columnCount], [() => props.scrollToIndex, () => props.items.length, columnCount],
@@ -167,4 +167,78 @@ describe('glass optical surface discovery', () => {
scope.stop() scope.stop()
}) })
it('releases renderer resources before restoring a single active instance', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const active = ref(true)
const scope = effectScope()
let frameId = 0
const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => ++frameId)
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame')
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
const resizeDisconnect = vi.spyOn(ResizeObserverMock.prototype, 'disconnect')
const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const addWindowListener = vi.spyOn(window, 'addEventListener')
const addDocumentListener = vi.spyOn(document, 'addEventListener')
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
calls.filter(([event]) => String(event) === eventName).length
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active,
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/recommend'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
expect(requestFrame).toHaveBeenCalled()
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
active.value = false
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(1)
expect(contextLoss).toHaveBeenCalledTimes(1)
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
expect(cancelFrame).toHaveBeenCalledTimes(2)
addWindowListener.mockClear()
addDocumentListener.mockClear()
addCanvasListener.mockClear()
active.value = true
await nextTick()
await vi.waitFor(() => expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1))
expect(renderer?.state.value).toBe('ready')
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
active.value = false
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(2)
expect(contextLoss).toHaveBeenCalledTimes(2)
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
expect(cancelFrame).toHaveBeenCalledTimes(4)
scope.stop()
})
}) })
+19 -9
View File
@@ -379,12 +379,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
resources.uniforms.uCoverScale.value.set(cover.x, cover.y) resources.uniforms.uCoverScale.value.set(cover.x, cover.y)
} }
function getRenderProfile(routeKey = toValue(options.routeKey)) {
return getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
}
function resizeRenderer() { function resizeRenderer() {
if (!resources) return if (!resources) return
const viewportWidth = window.innerWidth const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey)) const profile = getRenderProfile()
const buffer = getGlassOpticalBufferSize(viewportWidth, viewportHeight, viewportWidth <= 600, profile.bufferQuality) const buffer = getGlassOpticalBufferSize(viewportWidth, viewportHeight, viewportWidth <= 600, profile.bufferQuality)
resources.renderer.setSize(buffer.width, buffer.height, false) resources.renderer.setSize(buffer.width, buffer.height, false)
resources.uniforms.uViewportSize.value.set(viewportWidth, viewportHeight) resources.uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
@@ -392,6 +396,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scheduleSurfaceUpdate() scheduleSurfaceUpdate()
} }
function profileRequiresTextureReload(
previousProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
nextProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
) {
return (
previousProfile.textureLimit !== nextProfile.textureLimit ||
previousProfile.textureSource !== nextProfile.textureSource
)
}
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
if (!resources || matchMedia('(prefers-reduced-motion: reduce)').matches) return if (!resources || matchMedia('(prefers-reduced-motion: reduce)').matches) return
if ( if (
@@ -555,7 +569,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
async function loadWallpaper(url: string, version: number) { async function loadWallpaper(url: string, version: number) {
if (!resources || !three || !url) return if (!resources || !three || !url) return
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey)) const profile = getRenderProfile()
const shouldUseProceduralTexture = const shouldUseProceduralTexture =
profile.textureSource === 'procedural' || profile.textureSource === 'procedural' ||
(profile.textureSource === 'auto' && !canUseGlassWallpaperTexture(url, window.location.href)) (profile.textureSource === 'auto' && !canUseGlassWallpaperTexture(url, window.location.href))
@@ -784,17 +798,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
watch( watch(
() => toValue(options.routeKey), () => toValue(options.routeKey),
async (routeKey, previousRouteKey) => { async (routeKey, previousRouteKey) => {
const previousProfile = getRenderProfile(previousRouteKey ?? '')
await nextTick() await nextTick()
if (resources) { if (resources) {
const previousProfile = getGlassOpticalRenderProfile(toValue(options.quality), previousRouteKey ?? '') const nextProfile = getRenderProfile(routeKey)
const nextProfile = getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
resizeRenderer() resizeRenderer()
if ( if (profileRequiresTextureReload(previousProfile, nextProfile)) {
previousProfile.bufferQuality !== nextProfile.bufferQuality ||
previousProfile.textureLimit !== nextProfile.textureLimit ||
previousProfile.textureSource !== nextProfile.textureSource
) {
const version = ++loadVersion const version = ++loadVersion
setGlassRendererState(state, 'loading') setGlassRendererState(state, 'loading')
+40 -5
View File
@@ -64,6 +64,7 @@ html[data-theme='glass'] {
--glass-blur: 22px; --glass-blur: 22px;
--glass-blur-raised: 32px; --glass-blur-raised: 32px;
--glass-saturate: 145%; --glass-saturate: 145%;
--glass-content-reveal-duration: 160ms;
--glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1); --glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
--app-card-rest-shadow: var(--glass-shadow); --app-card-rest-shadow: var(--glass-shadow);
--app-card-hover-shadow: var(--glass-shadow-hover); --app-card-hover-shadow: var(--glass-shadow-hover);
@@ -322,6 +323,21 @@ html[data-theme='glass'] {
background-color: var(--glass-surface) !important; background-color: var(--glass-surface) !important;
} }
// 海报完成绘制后已完全遮住卡片底面,释放不可见的实时背景采样。
.media-card.media-card--image-loaded {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.media-card .v-img__img {
opacity: 0;
transition: opacity var(--glass-content-reveal-duration) cubic-bezier(0.2, 0.8, 0.2, 1);
}
.media-card--image-loaded .v-img__img {
opacity: 1;
}
.v-toolbar { .v-toolbar {
border-color: var(--glass-border); border-color: var(--glass-border);
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter); -webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -336,10 +352,7 @@ html[data-theme='glass'] {
backdrop-filter: var(--glass-dashboard-backdrop-filter); backdrop-filter: var(--glass-dashboard-backdrop-filter);
} }
:where( :where(.layout-navbar, .Vue-Toastification__toast) {
.layout-navbar,
.Vue-Toastification__toast
) {
border-color: var(--glass-border-raised) !important; border-color: var(--glass-border-raised) !important;
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter); -webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: var(--glass-raised-backdrop-filter); backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -896,7 +909,19 @@ html[data-theme='glass'] {
) { ) {
-webkit-backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
backdrop-filter: none !important; backdrop-filter: none !important;
background-color: rgba(11, 19, 34, 94%) !important; background-color: rgb(11, 19, 34) !important;
}
.layout-vertical-nav::before {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background-color: rgb(11, 19, 34) !important;
}
.playing-card__percent {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background-color: rgba(11, 19, 34, 88%) !important;
} }
} }
@@ -907,6 +932,10 @@ html[data-theme='glass'] {
.background-image { .background-image {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
.media-card .v-img__img {
transition-duration: 0.01ms;
}
} }
} }
@@ -1129,6 +1158,12 @@ html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass
} }
} }
html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-within {
border-color: rgba(var(--v-theme-primary), 70%);
background: var(--glass-control-prominent-focus);
box-shadow: var(--glass-control-prominent-focus-shadow);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.glass-optical-layer { .glass-optical-layer {
transition: none; transition: none;
+8 -3
View File
@@ -22,15 +22,20 @@ describe('glass optics geometry', () => {
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 }) expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
}) })
it('keeps high quality optics while reducing the media-dense recommendation budget', () => { it('keeps the selected optical quality on every route', () => {
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({ expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
bufferQuality: 'high', bufferQuality: 'high',
textureLimit: 3072, textureLimit: 3072,
textureSource: 'wallpaper', textureSource: 'wallpaper',
}) })
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({ expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
bufferQuality: 'balanced', bufferQuality: 'high',
textureLimit: 2048, textureLimit: 3072,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'wallpaper', textureSource: 'wallpaper',
}) })
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({ expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
+54
View File
@@ -0,0 +1,54 @@
import { findNearestScrollTarget, invalidateScrollTargetCache } from '@/utils/scrollTarget'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('findNearestScrollTarget', () => {
beforeEach(() => {
invalidateScrollTargetCache()
document.body.innerHTML = ''
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the nearest scrollable ancestor', () => {
const scrollable = document.createElement('div')
const wrapper = document.createElement('div')
const grid = document.createElement('div')
scrollable.style.overflowY = 'auto'
scrollable.append(wrapper)
wrapper.append(grid)
document.body.append(scrollable)
expect(findNearestScrollTarget(grid)).toBe(scrollable)
})
it('reuses ancestor results across sibling grids', () => {
const getComputedStyle = vi.spyOn(window, 'getComputedStyle')
const wrapper = document.createElement('div')
const firstGrid = document.createElement('div')
const secondGrid = document.createElement('div')
wrapper.append(firstGrid, secondGrid)
document.body.append(wrapper)
expect(findNearestScrollTarget(firstGrid)).toBe(window)
const callsAfterFirstGrid = getComputedStyle.mock.calls.length
expect(findNearestScrollTarget(secondGrid)).toBe(window)
expect(getComputedStyle).toHaveBeenCalledTimes(callsAfterFirstGrid)
})
it('recomputes targets after cache invalidation', () => {
const wrapper = document.createElement('div')
const grid = document.createElement('div')
wrapper.append(grid)
document.body.append(wrapper)
expect(findNearestScrollTarget(grid)).toBe(window)
wrapper.style.overflowY = 'auto'
invalidateScrollTargetCache()
expect(findNearestScrollTarget(grid)).toBe(wrapper)
})
})
+4 -6
View File
@@ -24,7 +24,7 @@ export interface GlassOpticalBufferSize {
} }
export interface GlassOpticalRenderProfile { export interface GlassOpticalRenderProfile {
/** 实际内部缓冲档位;媒体密集场景可保留高质量光学但降低合成分辨率。 */ /** 光学层内部缓冲使用的质量档位。 */
bufferQuality: GlassOpticalQuality bufferQuality: GlassOpticalQuality
/** 活动壁纸进入 GPU 前的最长边限制。 */ /** 活动壁纸进入 GPU 前的最长边限制。 */
textureLimit: number textureLimit: number
@@ -32,16 +32,14 @@ export interface GlassOpticalRenderProfile {
textureSource: 'auto' | 'procedural' | 'wallpaper' textureSource: 'auto' | 'procedural' | 'wallpaper'
} }
/** 质量与场景分配合成预算,避免推荐页海报解码与高分辨率光学层争抢资源。 */ /** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
export function getGlassOpticalRenderProfile( export function getGlassOpticalRenderProfile(
quality: GlassOpticalQuality, quality: GlassOpticalQuality,
routeKey: string, routeKey: string,
): GlassOpticalRenderProfile { ): GlassOpticalRenderProfile {
const mediaDenseRoute = routeKey.startsWith('/recommend')
return { return {
bufferQuality: quality === 'high' && !mediaDenseRoute ? 'high' : 'balanced', bufferQuality: quality,
textureLimit: quality === 'high' && !mediaDenseRoute ? 3072 : 2048, textureLimit: quality === 'high' ? 3072 : 2048,
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper', textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
} }
} }
+46
View File
@@ -0,0 +1,46 @@
export type ScrollTarget = Window | HTMLElement
let targetCache = new WeakMap<HTMLElement, ScrollTarget>()
/**
* 清除祖先滚动容器缓存。响应式布局或 overlay 状态改变后必须重新解析。
*/
export function invalidateScrollTargetCache() {
targetCache = new WeakMap<HTMLElement, ScrollTarget>()
}
function isScrollableOverflow(overflowY: string) {
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
}
/**
* 解析元素最近的纵向滚动容器,并让同一祖先链上的网格复用样式查询结果。
*/
export function findNearestScrollTarget(element: HTMLElement | null): ScrollTarget {
if (!element) return window
let parent = element.parentElement
const visited: HTMLElement[] = []
let target: ScrollTarget = window
while (parent && parent !== document.body && parent !== document.documentElement) {
const cachedTarget = targetCache.get(parent)
if (cachedTarget) {
target = cachedTarget
break
}
visited.push(parent)
if (isScrollableOverflow(window.getComputedStyle(parent).overflowY)) {
target = parent
break
}
parent = parent.parentElement
}
visited.forEach(ancestor => targetCache.set(ancestor, target))
return target
}