mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
perf(app): add activity lifecycle management (#579)
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 应用活动状态:active 允许交互与装饰动效;idle 表示前台静置;passive 表示短时失焦;
|
||||
* suspended 表示隐藏或持续失焦,需要释放高成本渲染资源。
|
||||
*/
|
||||
export type AppActivityState = 'active' | 'idle' | 'passive' | 'suspended'
|
||||
|
||||
export const APP_ACTIVITY_IDLE_DELAY_MS = 3 * 60_000
|
||||
export const APP_ACTIVITY_SUSPEND_DELAY_MS = 60_000
|
||||
|
||||
type AppActivityStateListener = (state: AppActivityState) => void
|
||||
|
||||
const activityEvents = ['keydown', 'pointerdown', 'pointermove', 'scroll', 'touchstart', 'wheel'] as const
|
||||
|
||||
/**
|
||||
* 统一管理页面活动状态,确保装饰动效、定时器和高成本渲染共享相同的前后台语义。
|
||||
*/
|
||||
export class AppActivityLifecycle {
|
||||
private state: AppActivityState = 'active'
|
||||
private listeners = new Set<AppActivityStateListener>()
|
||||
private idleTimer: number | null = null
|
||||
private suspendTimer: number | null = null
|
||||
private lastActivityAt = Date.now()
|
||||
private acquireCount = 0
|
||||
private started = false
|
||||
|
||||
private readonly handleActivity = () => {
|
||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return
|
||||
|
||||
this.lastActivityAt = Date.now()
|
||||
this.setState('active')
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
private readonly handleBlur = () => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
|
||||
this.clearIdleTimer()
|
||||
this.setState('passive')
|
||||
this.scheduleSuspend()
|
||||
}
|
||||
|
||||
private readonly handleFocus = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
|
||||
this.clearSuspendTimer()
|
||||
this.lastActivityAt = Date.now()
|
||||
this.setState('active')
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.clearTimers()
|
||||
this.setState('suspended')
|
||||
return
|
||||
}
|
||||
|
||||
if (document.hasFocus()) {
|
||||
this.handleFocus()
|
||||
} else {
|
||||
this.handleBlur()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前生命周期状态。 */
|
||||
getState() {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/** 获取最近一次有效前台交互时间。 */
|
||||
getLastActivityAt() {
|
||||
return this.lastActivityAt
|
||||
}
|
||||
|
||||
/** 订阅状态变化;订阅时立即回放当前状态。 */
|
||||
subscribe(listener: AppActivityStateListener) {
|
||||
this.listeners.add(listener)
|
||||
listener(this.state)
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取生命周期使用权;首个消费者注册全局监听,最后一个消费者释放全部资源。
|
||||
*/
|
||||
acquire() {
|
||||
this.acquireCount += 1
|
||||
if (!this.started) this.start()
|
||||
|
||||
let released = false
|
||||
|
||||
return () => {
|
||||
if (released) return
|
||||
|
||||
released = true
|
||||
this.acquireCount = Math.max(0, this.acquireCount - 1)
|
||||
if (this.acquireCount === 0) this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** 供路由或测试路径显式登记一次有效前台活动。 */
|
||||
markActivity() {
|
||||
this.handleActivity()
|
||||
}
|
||||
|
||||
private start() {
|
||||
if (this.started) return
|
||||
|
||||
this.started = true
|
||||
activityEvents.forEach(event => document.addEventListener(event, this.handleActivity, { passive: true }))
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.addEventListener('blur', this.handleBlur)
|
||||
window.addEventListener('focus', this.handleFocus)
|
||||
|
||||
this.handleVisibilityChange()
|
||||
}
|
||||
|
||||
private stop() {
|
||||
if (!this.started) return
|
||||
|
||||
this.clearTimers()
|
||||
activityEvents.forEach(event => document.removeEventListener(event, this.handleActivity))
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('blur', this.handleBlur)
|
||||
window.removeEventListener('focus', this.handleFocus)
|
||||
this.started = false
|
||||
}
|
||||
|
||||
private setState(state: AppActivityState) {
|
||||
if (this.state === state) return
|
||||
|
||||
this.state = state
|
||||
this.listeners.forEach(listener => listener(state))
|
||||
}
|
||||
|
||||
private scheduleIdle() {
|
||||
if (this.idleTimer !== null) return
|
||||
|
||||
const elapsed = Date.now() - this.lastActivityAt
|
||||
const delay = Math.max(0, APP_ACTIVITY_IDLE_DELAY_MS - elapsed)
|
||||
|
||||
this.idleTimer = window.setTimeout(() => {
|
||||
this.idleTimer = null
|
||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return
|
||||
|
||||
const remaining = APP_ACTIVITY_IDLE_DELAY_MS - (Date.now() - this.lastActivityAt)
|
||||
if (remaining > 0) {
|
||||
this.scheduleIdle()
|
||||
return
|
||||
}
|
||||
|
||||
this.setState('idle')
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private scheduleSuspend() {
|
||||
this.clearSuspendTimer()
|
||||
this.suspendTimer = window.setTimeout(() => {
|
||||
this.suspendTimer = null
|
||||
if (document.visibilityState === 'visible' && !document.hasFocus()) this.setState('suspended')
|
||||
}, APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||
}
|
||||
|
||||
private clearIdleTimer() {
|
||||
if (this.idleTimer === null) return
|
||||
|
||||
window.clearTimeout(this.idleTimer)
|
||||
this.idleTimer = null
|
||||
}
|
||||
|
||||
private clearSuspendTimer() {
|
||||
if (this.suspendTimer === null) return
|
||||
|
||||
window.clearTimeout(this.suspendTimer)
|
||||
this.suspendTimer = null
|
||||
}
|
||||
|
||||
private clearTimers() {
|
||||
this.clearIdleTimer()
|
||||
this.clearSuspendTimer()
|
||||
}
|
||||
}
|
||||
|
||||
export const appActivityLifecycle = new AppActivityLifecycle()
|
||||
Reference in New Issue
Block a user