mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat(glass): add selectable dynamics modes (#639)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user