feat(glass): add selectable dynamics modes (#639)

This commit is contained in:
InfinityPacer
2026-08-04 16:34:39 +08:00
committed by GitHub
parent 084b950fec
commit a545f88b3c
17 changed files with 3073 additions and 379 deletions
+61 -7
View File
@@ -1,5 +1,10 @@
<script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePreferredReducedMotion } from '@vueuse/core'
import type {
ThemeCustomizerGlassAppearance,
ThemeCustomizerGlassDynamicsMode,
ThemeCustomizerGlassQuality,
} from '@/composables/useThemeCustomizer'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
@@ -18,6 +23,8 @@ const props = defineProps<{
deformationStrength: number
/** 用户选择的轨迹、尾波与惯性强度。 */
flowStrength: number
/** 用户保存的动态效果模式;能力降级不会回写该选择。 */
dynamicsMode: ThemeCustomizerGlassDynamicsMode
/** 当前光学质量;标准档不会挂载该组件。 */
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
/** 用户选择的亮边、镜面高光与焦散强度。 */
@@ -74,7 +81,16 @@ const emit = defineEmits<{
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const usesMobilePresentation = useGlassMobilePresentation()
const dynamicsActive = computed(() => !usesMobilePresentation.value)
const preferredMotion = usePreferredReducedMotion()
const compositeFailureLatched = ref(false)
const compositeRecoveryPending = ref(false)
const presentationMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
usesMobilePresentation.value || preferredMotion.value === 'reduce' ? 'off' : props.dynamicsMode,
)
const effectiveDynamicsMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
compositeFailureLatched.value ? 'off' : presentationMode.value,
)
const dynamicsActive = computed(() => effectiveDynamicsMode.value !== 'off')
const interactionSource = useGlassOpticalInteractionSource(dynamicsActive)
const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache()
@@ -84,6 +100,7 @@ const fixedRenderer = useGlassOpticalRenderer({
canvas: fixedCanvas,
deformationStrength: () => props.deformationStrength,
dynamicsActive,
dynamicsMode: effectiveDynamicsMode,
flowStrength: () => props.flowStrength,
interactionSource,
quality: () => props.quality,
@@ -109,6 +126,7 @@ const scrollRenderer = useGlassOpticalRenderer({
canvas: scrollCanvas,
deformationStrength: () => props.deformationStrength,
dynamicsActive,
dynamicsMode: effectiveDynamicsMode,
flowStrength: () => props.flowStrength,
interactionSource,
pageMotion: pagePresentationMotion.reader,
@@ -130,16 +148,45 @@ const scrollRenderer = useGlassOpticalRenderer({
syncDocumentState: false,
})
/** 用户显式改选动态策略时,用同一代次重建两个已进入复合回退的 renderer。 */
function retryCompositeRenderers() {
compositeRecoveryPending.value = true
compositeFailureLatched.value = false
void Promise.allSettled([fixedRenderer.retryAfterFailure(), scrollRenderer.retryAfterFailure()])
}
watch(
() => props.dynamicsMode,
(mode, previousMode) => {
if (mode === previousMode || (!compositeFailureLatched.value && !compositeRecoveryPending.value)) return
retryCompositeRenderers()
},
)
const rendererState = ref<GlassRendererState>('loading')
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
watchEffect(() => {
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
const state: GlassRendererState = states.every(value => value === 'ready')
? 'ready'
: states.some(value => value === 'loading')
? 'loading'
: 'fallback'
const allReady = states.every(value => value === 'ready')
const anyFallback = states.some(value => value === 'fallback')
const anyLoading = states.some(value => value === 'loading')
if (compositeRecoveryPending.value) {
if (allReady) compositeRecoveryPending.value = false
else if (anyFallback && !anyLoading) {
compositeRecoveryPending.value = false
compositeFailureLatched.value = true
}
} else if (anyFallback) compositeFailureLatched.value = true
else if (compositeFailureLatched.value && allReady) compositeFailureLatched.value = false
const state: GlassRendererState = compositeRecoveryPending.value
? 'loading'
: compositeFailureLatched.value
? 'fallback'
: allReady
? 'ready'
: 'loading'
setGlassRendererState(rendererState, state)
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
@@ -151,6 +198,11 @@ watchEffect(() => {
}
})
watchEffect(() => {
document.documentElement.dataset.glassDynamicsMode = props.dynamicsMode
document.documentElement.dataset.glassDynamicsEffectiveMode = effectiveDynamicsMode.value
})
let lastPreparedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl
@@ -287,6 +339,8 @@ watchEffect(() => {
onScopeDispose(() => {
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
delete document.documentElement.dataset.glassDynamicsMode
delete document.documentElement.dataset.glassDynamicsEffectiveMode
setGlassRendererState(rendererState, 'fallback')
})
</script>
+1
View File
@@ -143,6 +143,7 @@ const hasAppModeCustomization = computed(() => {
settings.value.primaryColor !== defaultPrimaryColor ||
settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance ||
settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength ||
settings.value.glassDynamicsMode !== defaultAppModeGlassSettings.glassDynamicsMode ||
settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength ||
settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality ||
settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength ||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
const rendererInitialStates = vi.hoisted(() => [] as string[])
const rendererResults = vi.hoisted(
() =>
[] as Array<{
@@ -19,6 +20,7 @@ const rendererResults = vi.hoisted(
preparedWallpaperRevision: { value: number }
preparedWallpaperUrl: { value: string }
renderedFrames: { value: number }
retryAfterFailure: ReturnType<typeof vi.fn>
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
state: { value: string }
}>,
@@ -61,7 +63,12 @@ vi.mock('@/composables/useGlassOpticalRenderer', () => ({
preparedWallpaperRevision: ref(0),
preparedWallpaperUrl: ref(''),
renderedFrames: ref(0),
state: ref('ready'),
state: ref(rendererInitialStates.shift() ?? 'ready'),
retryAfterFailure: vi.fn(() => {
result.state.value = 'loading'
return Promise.resolve()
}),
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
return (
result.state.value === 'ready' &&
@@ -114,6 +121,7 @@ vi.mock('@/composables/useGlassPresentationCapabilities', async () => {
afterEach(() => {
mobilePresentationState.current!.value = false
rendererInitialStates.length = 0
vi.unstubAllGlobals()
})
@@ -126,6 +134,7 @@ describe('GlassOpticalLayer', () => {
props: {
appearance: 'clear',
deformationStrength: 50,
dynamicsMode: 'fluid',
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'balanced',
@@ -146,6 +155,7 @@ describe('GlassOpticalLayer', () => {
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'fluid')).toBe(true)
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
@@ -161,6 +171,37 @@ describe('GlassOpticalLayer', () => {
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
})
it('does not latch a composite failure while both contexts are initially loading', () => {
rendererCalls.length = 0
rendererResults.length = 0
rendererInitialStates.push('loading', 'loading')
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'clear',
deformationStrength: 50,
dynamicsMode: 'ripple',
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper.jpg',
},
})
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple')
expect(setRendererState).toHaveBeenCalledWith(expect.any(Object), 'loading')
wrapper.unmount()
})
it('keeps both material contexts while disabling dynamics on mobile presentations', async () => {
rendererCalls.length = 0
rendererResults.length = 0
@@ -169,6 +210,7 @@ describe('GlassOpticalLayer', () => {
props: {
appearance: 'frosted',
deformationStrength: 50,
dynamicsMode: 'ripple',
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'high',
@@ -186,11 +228,15 @@ describe('GlassOpticalLayer', () => {
expect(wrapper.findAll('canvas')).toHaveLength(2)
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple')
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
mobilePresentationState.current!.value = false
await nextTick()
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
wrapper.unmount()
})
@@ -210,6 +256,7 @@ describe('GlassOpticalLayer', () => {
appearance: 'frosted',
activateWallpaperRevision: 0,
deformationStrength: 50,
dynamicsMode: 'fluid',
flowStrength: 50,
pendingWallpaperRevision: 7,
pendingWallpaperUrl: '/wallpaper-next.jpg',
@@ -267,6 +314,7 @@ describe('GlassOpticalLayer', () => {
props: {
appearance: 'frosted',
deformationStrength: 50,
dynamicsMode: 'fluid',
flowStrength: 50,
pendingWallpaperRevision: 10,
pendingWallpaperUrl: '/wallpaper-next.jpg',
@@ -322,6 +370,7 @@ describe('GlassOpticalLayer', () => {
appearance: 'frosted',
activateWallpaperRevision: 8,
deformationStrength: 50,
dynamicsMode: 'ripple',
flowStrength: 50,
pendingWallpaperRevision: 8,
pendingWallpaperUrl: '/wallpaper-next.jpg',
@@ -348,13 +397,34 @@ describe('GlassOpticalLayer', () => {
scrollRenderer.state.value = 'fallback'
await nextTick()
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple')
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
expect(requestFrame).not.toHaveBeenCalled()
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
scrollRenderer.state.value = 'loading'
await nextTick()
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
expect(requestFrame).not.toHaveBeenCalled()
fixedRenderer.state.value = 'loading'
scrollRenderer.state.value = 'ready'
await nextTick()
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
expect(requestFrame).not.toHaveBeenCalled()
fixedRenderer.state.value = 'ready'
await nextTick()
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple')
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(640)
await nextTick()
@@ -365,6 +435,93 @@ describe('GlassOpticalLayer', () => {
wrapper.unmount()
})
it('retries both contexts when the requested dynamics mode changes after a composite failure', async () => {
rendererCalls.length = 0
rendererResults.length = 0
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'clear',
deformationStrength: 50,
dynamicsMode: 'ripple',
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
scrollRenderer.state.value = 'fallback'
await nextTick()
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
await wrapper.setProps({ dynamicsMode: 'fluid' })
await nextTick()
expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce()
expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce()
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid')
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading')
fixedRenderer.state.value = 'ready'
await nextTick()
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading')
scrollRenderer.state.value = 'ready'
await nextTick()
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid')
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'ready')
wrapper.unmount()
})
it('keeps the composite fallback without retrying in a loop when explicit recovery fails', async () => {
rendererCalls.length = 0
rendererResults.length = 0
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'clear',
deformationStrength: 50,
dynamicsMode: 'ripple',
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
scrollRenderer.state.value = 'fallback'
await nextTick()
await wrapper.setProps({ dynamicsMode: 'fluid' })
await nextTick()
fixedRenderer.state.value = 'ready'
scrollRenderer.state.value = 'fallback'
await nextTick()
await nextTick()
expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce()
expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce()
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
wrapper.unmount()
})
it.each([
[
'returns false',
@@ -392,6 +549,7 @@ describe('GlassOpticalLayer', () => {
appearance: 'frosted',
activateWallpaperRevision: 9,
deformationStrength: 50,
dynamicsMode: 'fluid',
flowStrength: 50,
pendingWallpaperRevision: 9,
pendingWallpaperUrl: '/wallpaper-next.jpg',