mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-22 00:42:04 +08:00
perf(glass): complete phase three optimization (#580)
This commit is contained in:
49
src/App.vue
49
src/App.vue
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
@@ -30,7 +31,6 @@ import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
@@ -75,12 +75,13 @@ const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
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 isGlassTheme = computed(() => globalTheme.name.value === 'glass')
|
||||
const effectiveGlassSettings = useEffectiveGlassSettings()
|
||||
const isInitialRouteReady = ref(false)
|
||||
const isGlassOpticalLayerDeferred = ref(true)
|
||||
let glassOpticalLayerDeferTimer: number | null = null
|
||||
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
@@ -100,7 +101,7 @@ const shouldRenderGlassOpticalLayer = computed(
|
||||
() =>
|
||||
isGlassTheme.value &&
|
||||
effectiveGlassSettings.value.glassQuality !== 'css' &&
|
||||
!isGlassOpticalLayerDeferred.value &&
|
||||
isInitialRouteReady.value &&
|
||||
!isRenderThrottled.value &&
|
||||
Boolean(activeBackgroundImage.value),
|
||||
)
|
||||
@@ -139,35 +140,6 @@ function handleTransparencySettingsChanged(event: Event) {
|
||||
|
||||
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(() => {
|
||||
isInitialRouteReady.value = true
|
||||
})
|
||||
@@ -412,7 +384,7 @@ async function fetchBackgroundImages() {
|
||||
|
||||
// 背景图片轮换函数
|
||||
function rotateBackgroundImage() {
|
||||
if (!allowsDecorativeMotion.value) return
|
||||
if (!allowsBackgroundRotation.value) return
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 计算下一个图片索引
|
||||
@@ -420,7 +392,7 @@ function rotateBackgroundImage() {
|
||||
const requestVersion = ++backgroundRotationVersion
|
||||
|
||||
void commitPreloadedBackgroundRotation({
|
||||
canCommit: () => allowsDecorativeMotion.value && requestVersion === backgroundRotationVersion,
|
||||
canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
|
||||
commit: () => activateBackgroundImage(nextIndex),
|
||||
preload: () => preloadImage(backgroundImages.value[nextIndex]),
|
||||
})
|
||||
@@ -437,7 +409,7 @@ function stopBackgroundRotation() {
|
||||
function startBackgroundRotation() {
|
||||
stopBackgroundRotation()
|
||||
|
||||
if (allowsDecorativeMotion.value && backgroundImages.value.length > 1) {
|
||||
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
|
||||
// 使用优化的定时器管理器,后台时自动暂停
|
||||
addBackgroundTimer(
|
||||
'background-rotation',
|
||||
@@ -451,10 +423,10 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(appActivityState, state => {
|
||||
watch(allowsBackgroundRotation, allowsRotation => {
|
||||
resetBackgroundCrossfade()
|
||||
|
||||
if (state === 'active') {
|
||||
if (allowsRotation) {
|
||||
startBackgroundRotation()
|
||||
} else {
|
||||
stopBackgroundRotation()
|
||||
@@ -655,7 +627,6 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer)
|
||||
// 清除背景轮换定时器
|
||||
stopBackgroundLoading()
|
||||
if (authenticatedStateTimer) {
|
||||
|
||||
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'media-card--image-loaded': isImageLoaded,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
|
||||
@@ -520,8 +520,10 @@ onMounted(() => {
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
id="global-media-search"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:aria-label="t('dialog.searchBar.searchPlaceholder')"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
@@ -539,8 +541,10 @@ onMounted(() => {
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
id="global-media-search"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:aria-label="t('dialog.searchBar.searchPlaceholder')"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
|
||||
@@ -43,6 +43,9 @@ describe('SearchBarDialog media source selection', () => {
|
||||
const { router } = await renderSearchBar()
|
||||
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 waitFor(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { findNearestScrollTarget, invalidateScrollTargetCache, type ScrollTarget } from '@/utils/scrollTarget'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
type ItemKey = string | number
|
||||
type ScrollTarget = Window | HTMLElement
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -513,19 +513,7 @@ function getItemRef(key: ItemKey) {
|
||||
}
|
||||
|
||||
function findScrollTarget(): ScrollTarget {
|
||||
let parent = containerRef.value?.parentElement ?? null
|
||||
|
||||
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
|
||||
return findNearestScrollTarget(containerRef.value)
|
||||
}
|
||||
|
||||
function addScrollListener(target: ScrollTarget) {
|
||||
@@ -552,6 +540,11 @@ function refreshScrollTarget() {
|
||||
addScrollListener(scrollTarget)
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
invalidateScrollTargetCache()
|
||||
queueLayoutSync()
|
||||
}
|
||||
|
||||
function syncLayoutWidth() {
|
||||
const element = trackRef.value
|
||||
|
||||
@@ -780,10 +773,7 @@ function invalidateMeasurementsForLayoutChange() {
|
||||
const nextColumnCount = columnCount.value
|
||||
const nextColumnWidth = columnWidth.value
|
||||
|
||||
if (
|
||||
lastMeasuredColumnCount === nextColumnCount &&
|
||||
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
|
||||
) {
|
||||
if (lastMeasuredColumnCount === nextColumnCount && Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -817,7 +807,7 @@ onMounted(() => {
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', queueLayoutSync, { passive: true })
|
||||
window.addEventListener('resize', handleWindowResize, { passive: true })
|
||||
|
||||
queueLayoutSync()
|
||||
})
|
||||
@@ -840,7 +830,7 @@ onUnmounted(() => {
|
||||
removeScrollListener(scrollTarget)
|
||||
scrollTarget = null
|
||||
|
||||
window.removeEventListener('resize', queueLayoutSync)
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
itemResizeObserver?.disconnect()
|
||||
@@ -881,13 +871,10 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[columnCount, columnWidth],
|
||||
() => {
|
||||
invalidateMeasurementsForLayoutChange()
|
||||
queueViewportSync()
|
||||
},
|
||||
)
|
||||
watch([columnCount, columnWidth], () => {
|
||||
invalidateMeasurementsForLayoutChange()
|
||||
queueViewportSync()
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.scrollToIndex, () => props.items.length, columnCount],
|
||||
|
||||
@@ -167,4 +167,78 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -379,12 +379,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
resources.uniforms.uCoverScale.value.set(cover.x, cover.y)
|
||||
}
|
||||
|
||||
function getRenderProfile(routeKey = toValue(options.routeKey)) {
|
||||
return getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
|
||||
}
|
||||
|
||||
function resizeRenderer() {
|
||||
if (!resources) return
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
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)
|
||||
resources.renderer.setSize(buffer.width, buffer.height, false)
|
||||
resources.uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
|
||||
@@ -392,6 +396,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scheduleSurfaceUpdate()
|
||||
}
|
||||
|
||||
function profileRequiresTextureReload(
|
||||
previousProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
|
||||
nextProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
|
||||
) {
|
||||
return (
|
||||
previousProfile.textureLimit !== nextProfile.textureLimit ||
|
||||
previousProfile.textureSource !== nextProfile.textureSource
|
||||
)
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!resources || matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
if (
|
||||
@@ -555,7 +569,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
async function loadWallpaper(url: string, version: number) {
|
||||
if (!resources || !three || !url) return
|
||||
|
||||
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey))
|
||||
const profile = getRenderProfile()
|
||||
const shouldUseProceduralTexture =
|
||||
profile.textureSource === 'procedural' ||
|
||||
(profile.textureSource === 'auto' && !canUseGlassWallpaperTexture(url, window.location.href))
|
||||
@@ -784,17 +798,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
watch(
|
||||
() => toValue(options.routeKey),
|
||||
async (routeKey, previousRouteKey) => {
|
||||
const previousProfile = getRenderProfile(previousRouteKey ?? '')
|
||||
await nextTick()
|
||||
if (resources) {
|
||||
const previousProfile = getGlassOpticalRenderProfile(toValue(options.quality), previousRouteKey ?? '')
|
||||
const nextProfile = getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
|
||||
const nextProfile = getRenderProfile(routeKey)
|
||||
resizeRenderer()
|
||||
|
||||
if (
|
||||
previousProfile.bufferQuality !== nextProfile.bufferQuality ||
|
||||
previousProfile.textureLimit !== nextProfile.textureLimit ||
|
||||
previousProfile.textureSource !== nextProfile.textureSource
|
||||
) {
|
||||
if (profileRequiresTextureReload(previousProfile, nextProfile)) {
|
||||
const version = ++loadVersion
|
||||
setGlassRendererState(state, 'loading')
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ html[data-theme='glass'] {
|
||||
--glass-blur: 22px;
|
||||
--glass-blur-raised: 32px;
|
||||
--glass-saturate: 145%;
|
||||
--glass-content-reveal-duration: 160ms;
|
||||
--glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--app-card-rest-shadow: var(--glass-shadow);
|
||||
--app-card-hover-shadow: var(--glass-shadow-hover);
|
||||
@@ -322,6 +323,21 @@ html[data-theme='glass'] {
|
||||
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 {
|
||||
border-color: var(--glass-border);
|
||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
@@ -336,10 +352,7 @@ html[data-theme='glass'] {
|
||||
backdrop-filter: var(--glass-dashboard-backdrop-filter);
|
||||
}
|
||||
|
||||
:where(
|
||||
.layout-navbar,
|
||||
.Vue-Toastification__toast
|
||||
) {
|
||||
:where(.layout-navbar, .Vue-Toastification__toast) {
|
||||
border-color: var(--glass-border-raised) !important;
|
||||
-webkit-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;
|
||||
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 {
|
||||
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) {
|
||||
.glass-optical-layer {
|
||||
transition: none;
|
||||
|
||||
@@ -22,15 +22,20 @@ describe('glass optics geometry', () => {
|
||||
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({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
textureSource: 'wallpaper',
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
|
||||
bufferQuality: 'balanced',
|
||||
textureLimit: 2048,
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
textureSource: 'wallpaper',
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
textureSource: 'wallpaper',
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
|
||||
|
||||
54
src/utils/__tests__/scrollTarget.spec.ts
Normal file
54
src/utils/__tests__/scrollTarget.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -24,7 +24,7 @@ export interface GlassOpticalBufferSize {
|
||||
}
|
||||
|
||||
export interface GlassOpticalRenderProfile {
|
||||
/** 实际内部缓冲档位;媒体密集场景可保留高质量光学但降低合成分辨率。 */
|
||||
/** 光学层内部缓冲使用的质量档位。 */
|
||||
bufferQuality: GlassOpticalQuality
|
||||
/** 活动壁纸进入 GPU 前的最长边限制。 */
|
||||
textureLimit: number
|
||||
@@ -32,16 +32,14 @@ export interface GlassOpticalRenderProfile {
|
||||
textureSource: 'auto' | 'procedural' | 'wallpaper'
|
||||
}
|
||||
|
||||
/** 按质量与场景分配合成预算,避免推荐页海报解码与高分辨率光学层争抢资源。 */
|
||||
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
|
||||
export function getGlassOpticalRenderProfile(
|
||||
quality: GlassOpticalQuality,
|
||||
routeKey: string,
|
||||
): GlassOpticalRenderProfile {
|
||||
const mediaDenseRoute = routeKey.startsWith('/recommend')
|
||||
|
||||
return {
|
||||
bufferQuality: quality === 'high' && !mediaDenseRoute ? 'high' : 'balanced',
|
||||
textureLimit: quality === 'high' && !mediaDenseRoute ? 3072 : 2048,
|
||||
bufferQuality: quality,
|
||||
textureLimit: quality === 'high' ? 3072 : 2048,
|
||||
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
|
||||
}
|
||||
}
|
||||
|
||||
46
src/utils/scrollTarget.ts
Normal file
46
src/utils/scrollTarget.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user