mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
fix(router): keep route reveal timeline monotonic (#614)
This commit is contained in:
@@ -6,8 +6,6 @@ declare module 'vue-router' {
|
|||||||
subject?: string
|
subject?: string
|
||||||
keepAlive?: boolean
|
keepAlive?: boolean
|
||||||
keepAliveKey?: string
|
keepAliveKey?: string
|
||||||
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
|
|
||||||
pagePresentationHandoff?: 'staged'
|
|
||||||
layoutWrapperClasses?: string
|
layoutWrapperClasses?: string
|
||||||
navActiveLink?: RouteLocationRaw
|
navActiveLink?: RouteLocationRaw
|
||||||
requiresAuth?: boolean
|
requiresAuth?: boolean
|
||||||
|
|||||||
@@ -143,6 +143,38 @@ describe('page presentation motion', () => {
|
|||||||
routeRoot.remove()
|
routeRoot.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ignores a late geometry acknowledgement after reveal has started', () => {
|
||||||
|
const routeRoot = document.createElement('div')
|
||||||
|
Object.defineProperties(routeRoot, {
|
||||||
|
offsetHeight: { configurable: true, get: () => 2096 },
|
||||||
|
offsetWidth: { configurable: true, get: () => 1200 },
|
||||||
|
scrollHeight: { configurable: true, get: () => 2096 },
|
||||||
|
scrollWidth: { configurable: true, get: () => 1200 },
|
||||||
|
})
|
||||||
|
document.body.append(routeRoot)
|
||||||
|
|
||||||
|
expect(motion.start('/dashboard', routeRoot)).toBe(true)
|
||||||
|
const motionEpoch = motion.epoch.value
|
||||||
|
expect(motion.reader.acknowledgeGeometryReady(motionEpoch, 1040)).toBe(true)
|
||||||
|
|
||||||
|
const revealFrame = [...callbacks.values()].at(-1)!
|
||||||
|
revealFrame(1120)
|
||||||
|
const currentFrame = [...callbacks.values()].at(-1)!
|
||||||
|
const currentOpacity = motion.opacity.value
|
||||||
|
const currentProgress = motion.progress.value
|
||||||
|
const currentRevision = motion.revision.value
|
||||||
|
const currentTranslateY = motion.translateY.value
|
||||||
|
|
||||||
|
expect(motion.reader.acknowledgeGeometryReady(motionEpoch, 1160)).toBe(false)
|
||||||
|
expect(motion.opacity.value).toBe(currentOpacity)
|
||||||
|
expect(motion.progress.value).toBe(currentProgress)
|
||||||
|
expect(motion.revision.value).toBe(currentRevision)
|
||||||
|
expect(motion.translateY.value).toBe(currentTranslateY)
|
||||||
|
expect([...callbacks.values()].at(-1)).toBe(currentFrame)
|
||||||
|
|
||||||
|
routeRoot.remove()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps frosted material fully composed when the renderer releases its geometry hold', () => {
|
it('keeps frosted material fully composed when the renderer releases its geometry hold', () => {
|
||||||
document.documentElement.dataset.glassAppearance = 'frosted'
|
document.documentElement.dataset.glassAppearance = 'frosted'
|
||||||
const routeRoot = document.createElement('div')
|
const routeRoot = document.createElement('div')
|
||||||
|
|||||||
@@ -88,17 +88,6 @@ describe('route enter motion', () => {
|
|||||||
expect(motion.phase.value).toBe('running')
|
expect(motion.phase.value).toBe('running')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('commits one paint boundary before a staged handoff', () => {
|
|
||||||
const root = document.createElement('div')
|
|
||||||
const stub = createAnimationStub()
|
|
||||||
root.animate = vi.fn(() => stub.animation)
|
|
||||||
const motion = createMotion()
|
|
||||||
|
|
||||||
motion.start(root, { stagedHandoff: true })
|
|
||||||
runNextFrame()
|
|
||||||
expect(stub.play).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cancels the previous animation and pending frame on rapid navigation', () => {
|
it('cancels the previous animation and pending frame on rapid navigation', () => {
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
const first = createAnimationStub()
|
const first = createAnimationStub()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const revision = ref(0)
|
|||||||
const routeKey = ref('')
|
const routeKey = ref('')
|
||||||
const translateY = ref(0)
|
const translateY = ref(0)
|
||||||
let animationFrame: number | null = null
|
let animationFrame: number | null = null
|
||||||
|
let layoutHoldActive = false
|
||||||
let layoutHoldStartedAt = 0
|
let layoutHoldStartedAt = 0
|
||||||
let layoutStableSince = 0
|
let layoutStableSince = 0
|
||||||
let layoutSignature = ''
|
let layoutSignature = ''
|
||||||
@@ -105,6 +106,7 @@ function getLayoutSignature(root: HTMLElement) {
|
|||||||
function beginReveal(timestamp: number, motionEpoch: number) {
|
function beginReveal(timestamp: number, motionEpoch: number) {
|
||||||
if (!active.value || epoch.value !== motionEpoch) return
|
if (!active.value || epoch.value !== motionEpoch) return
|
||||||
|
|
||||||
|
layoutHoldActive = false
|
||||||
startedAt = timestamp
|
startedAt = timestamp
|
||||||
applyMotionFrame(0)
|
applyMotionFrame(0)
|
||||||
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
|
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
|
||||||
@@ -112,7 +114,7 @@ function beginReveal(timestamp: number, motionEpoch: number) {
|
|||||||
|
|
||||||
/** GPU surface 比整页高度更早稳定时,直接结束布局等待。 */
|
/** GPU surface 比整页高度更早稳定时,直接结束布局等待。 */
|
||||||
function acknowledgeGeometryReady(motionEpoch: number, timestamp = performance.now()) {
|
function acknowledgeGeometryReady(motionEpoch: number, timestamp = performance.now()) {
|
||||||
if (!active.value || epoch.value !== motionEpoch) return false
|
if (!active.value || epoch.value !== motionEpoch || !layoutHoldActive) return false
|
||||||
|
|
||||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
||||||
animationFrame = null
|
animationFrame = null
|
||||||
@@ -144,6 +146,7 @@ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElem
|
|||||||
}
|
}
|
||||||
|
|
||||||
function settleMotion() {
|
function settleMotion() {
|
||||||
|
layoutHoldActive = false
|
||||||
active.value = false
|
active.value = false
|
||||||
opacity.value = 1
|
opacity.value = 1
|
||||||
progress.value = 1
|
progress.value = 1
|
||||||
@@ -191,6 +194,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
|
|||||||
|
|
||||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
||||||
animationFrame = null
|
animationFrame = null
|
||||||
|
layoutHoldActive = false
|
||||||
epoch.value += 1
|
epoch.value += 1
|
||||||
const motionEpoch = epoch.value
|
const motionEpoch = epoch.value
|
||||||
routeKey.value = nextRouteKey
|
routeKey.value = nextRouteKey
|
||||||
@@ -222,6 +226,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
|
|||||||
active.value = true
|
active.value = true
|
||||||
const timestamp = performance.now()
|
const timestamp = performance.now()
|
||||||
if (layoutRoot && !usesCssQuality) {
|
if (layoutRoot && !usesCssQuality) {
|
||||||
|
layoutHoldActive = true
|
||||||
layoutHoldStartedAt = timestamp
|
layoutHoldStartedAt = timestamp
|
||||||
layoutStableSince = timestamp
|
layoutStableSince = timestamp
|
||||||
layoutSignature = getLayoutSignature(layoutRoot)
|
layoutSignature = getLayoutSignature(layoutRoot)
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ import { onScopeDispose, readonly, ref } from 'vue'
|
|||||||
|
|
||||||
export const ROUTE_ENTER_MOTION_DURATION_MS = 180
|
export const ROUTE_ENTER_MOTION_DURATION_MS = 180
|
||||||
export const ROUTE_ENTER_MOTION_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
|
export const ROUTE_ENTER_MOTION_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
|
||||||
export const ROUTE_ENTER_STAGED_PAINT_BOUNDARIES = 1
|
|
||||||
|
|
||||||
export type RouteEnterMotionPhase = 'idle' | 'armed' | 'running'
|
export type RouteEnterMotionPhase = 'idle' | 'armed' | 'running'
|
||||||
|
|
||||||
export interface RouteEnterMotionOptions {
|
|
||||||
/** 重页面离场时多保留一个绘制边界,确保目标页起始态已交给 compositor。 */
|
|
||||||
stagedHandoff?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSkipRouteEnterMotion() {
|
function shouldSkipRouteEnterMotion() {
|
||||||
const launchScreenActive =
|
const launchScreenActive =
|
||||||
document.documentElement.dataset.launchLoading === 'true' && Boolean(document.getElementById('loading-bg'))
|
document.documentElement.dataset.launchLoading === 'true' && Boolean(document.getElementById('loading-bg'))
|
||||||
@@ -39,22 +33,18 @@ export function useRouteEnterMotion() {
|
|||||||
phase.value = 'idle'
|
phase.value = 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAfterPaints(animation: Animation, remainingPaints: number, motionEpoch: number) {
|
function playAfterPaint(animation: Animation, motionEpoch: number) {
|
||||||
if (motionEpoch !== epoch || animation !== activeAnimation) return
|
if (motionEpoch !== epoch || animation !== activeAnimation) return
|
||||||
|
|
||||||
if (remainingPaints <= 0) {
|
|
||||||
phase.value = 'running'
|
|
||||||
animation.play()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFrame = window.requestAnimationFrame(() => {
|
animationFrame = window.requestAnimationFrame(() => {
|
||||||
animationFrame = null
|
animationFrame = null
|
||||||
playAfterPaints(animation, remainingPaints - 1, motionEpoch)
|
if (motionEpoch !== epoch || animation !== activeAnimation) return
|
||||||
|
phase.value = 'running'
|
||||||
|
animation.play()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function start(root: HTMLElement | null | undefined, options: RouteEnterMotionOptions = {}) {
|
function start(root: HTMLElement | null | undefined) {
|
||||||
cancel()
|
cancel()
|
||||||
if (!root || shouldSkipRouteEnterMotion() || typeof root.animate !== 'function') return false
|
if (!root || shouldSkipRouteEnterMotion() || typeof root.animate !== 'function') return false
|
||||||
|
|
||||||
@@ -94,7 +84,7 @@ export function useRouteEnterMotion() {
|
|||||||
// cancel() 会拒绝 finished;epoch 已负责丢弃过期事务。
|
// cancel() 会拒绝 finished;epoch 已负责丢弃过期事务。
|
||||||
})
|
})
|
||||||
|
|
||||||
playAfterPaints(animation, options.stagedHandoff ? ROUTE_ENTER_STAGED_PAINT_BOUNDARIES : 1, motionEpoch)
|
playAfterPaint(animation, motionEpoch)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -19,26 +19,17 @@ const routeCacheKey = computed(() => {
|
|||||||
|
|
||||||
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||||
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||||
const routePresentationState = computed(() => ({
|
|
||||||
handoff: route.meta.pagePresentationHandoff,
|
|
||||||
key: routeTransitionKey.value,
|
|
||||||
}))
|
|
||||||
const pageRouteRef = ref<HTMLElement | null>(null)
|
const pageRouteRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画。
|
// 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画。
|
||||||
function playPageEnterMotion(
|
function playPageEnterMotion() {
|
||||||
nextPresentation = routePresentationState.value,
|
|
||||||
previousPresentation?: typeof routePresentationState.value,
|
|
||||||
) {
|
|
||||||
routeEnterMotion.cancel()
|
routeEnterMotion.cancel()
|
||||||
if (pagePresentationMotion.start(nextPresentation.key, pageRouteRef.value)) return
|
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
|
||||||
|
|
||||||
routeEnterMotion.start(pageRouteRef.value, {
|
routeEnterMotion.start(pageRouteRef.value)
|
||||||
stagedHandoff: previousPresentation?.handoff === 'staged',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(routePresentationState, playPageEnterMotion, { flush: 'post' })
|
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
|
||||||
|
|
||||||
onMounted(playPageEnterMotion)
|
onMounted(playPageEnterMotion)
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const router = createRouter({
|
|||||||
component: () => import('../pages/recommend.vue'),
|
component: () => import('../pages/recommend.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
pagePresentationHandoff: 'staged',
|
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
permission: 'discovery',
|
permission: 'discovery',
|
||||||
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
|
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
|
||||||
|
|||||||
Reference in New Issue
Block a user