refactor(core): CORE-11 split media events and scope reconnect lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 03:19:52 +08:00
1 parent 5e31274f1d
commit b8039ad1f1
27 files changed
+4844 -475

No files matched your search

@@ -0,0 +1,13 @@
import type { MediaEventHost } from './types'
import config from '../../config'
import { isClosing } from '../../lifecycle/instance'
export function forwardMediaEvents(art: MediaEventHost): void {
const { proxy, template: { $video } } = art
for (let index = 0; index < config.events.length; index++) {
proxy($video, config.events[index]!, (event: Event) => {
if (!isClosing(art))
art.emit(`video:${event.type}`, event)
})
}
}
@@ -0,0 +1,21 @@
import type { MediaEventName, MediaEvents, MediaListener, MediaUI } from './types'
import { getScope, isClosing } from '../../lifecycle/instance'
export function listenMedia(art: MediaEvents, name: MediaEventName, callback: MediaListener, once = false): void {
const guarded: MediaListener = (event) => {
if (!isClosing(art))
callback(event)
}
art[once ? 'once' : 'on'](name, guarded)
getScope(art).add(() => {
art.off(name, guarded)
})
}
export function showMediaUI(art: MediaUI, changes: [keyof MediaUI, boolean][], active?: () => boolean): void {
for (const [name, value] of changes) {
if (isClosing(art) || (active && !active()))
return
art[name].show = value
}
}
@@ -0,0 +1,53 @@
import type { MediaEventHost, Reconnect } from './types'
import { isClosing } from '../../lifecycle/instance'
import { captureSource } from '../../source/operation'
import { isMobile, setStyle, silencePromise } from '../../utils'
import { listenMedia, showMediaUI } from './listen'
export function installEnded(art: MediaEventHost): void {
const { option } = art
listenMedia(art, 'video:ended', () => {
if (option.loop) {
const active = captureSource(art)
art.seek = 0
if (isClosing(art) || !active())
return
silencePromise(art.play())
if (active())
showMediaUI(art, [['controls', false], ['mask', false]], active)
}
else {
showMediaUI(art, [['controls', true], ['mask', true]])
}
})
}
export function installPlaybackUI(art: MediaEventHost, reconnect: Reconnect): void {
const { template: { $poster } } = art
listenMedia(art, 'video:loadedmetadata', () => {
art.emit('resize')
if (isMobile)
showMediaUI(art, [['loading', false], ['controls', true], ['mask', true]])
})
listenMedia(art, 'video:loadstart', () => {
reconnect.loadStart()
showMediaUI(art, [['loading', true], ['mask', false], ['controls', true]])
})
listenMedia(art, 'video:pause', () => {
showMediaUI(art, [['controls', true], ['mask', true]])
})
listenMedia(art, 'video:play', () => {
showMediaUI(art, [['mask', false]])
if (!isClosing(art))
setStyle($poster, 'display', 'none')
})
listenMedia(art, 'video:playing', () => showMediaUI(art, [['mask', false]]))
listenMedia(art, 'video:progress', () => {
if (art.playing)
showMediaUI(art, [['loading', false]])
})
listenMedia(art, 'video:seeked', () => showMediaUI(art, [['loading', false], ['mask', true]]))
listenMedia(art, 'video:seeking', () => showMediaUI(art, [['loading', true], ['mask', false]]))
listenMedia(art, 'video:timeupdate', () => showMediaUI(art, [['mask', false]]))
listenMedia(art, 'video:waiting', () => showMediaUI(art, [['loading', true], ['mask', false]]))
}
@@ -0,0 +1,17 @@
import type { MediaEventHost, Reconnect } from './types'
import { isClosing } from '../../lifecycle/instance'
import { listenMedia, showMediaUI } from './listen'
export function installReadiness(art: MediaEventHost, reconnect: Reconnect): void {
listenMedia(art, 'video:canplay', () => {
reconnect.reset()
showMediaUI(art, [['loading', false]])
})
listenMedia(art, 'video:canplay', () => {
showMediaUI(art, [['loading', false], ['controls', true], ['mask', true]])
if (isClosing(art))
return
art.isReady = true
art.emit('ready')
}, true)
}
@@ -0,0 +1,88 @@
import type ResourceScope from '../../lifecycle/scope'
import type { MediaEventHost, Reconnect } from './types'
import { isClosing } from '../../lifecycle/instance'
import { wait } from '../../lifecycle/resources'
import { assignUrl, beginSource, getSourceScope } from '../../source/operation'
import { addClass, removeClass } from '../../utils'
import { showMediaUI } from './listen'
export function createReconnect(art: MediaEventHost): Reconnect {
const { option, constructor, i18n, notice, template: { $player } } = art
let source = getSourceScope(art)
let attempts = 0
let pending: ResourceScope | undefined
const run = async (error: unknown) => {
if (isClosing(art))
return
const current = getSourceScope(art)
if (current !== source) {
source = current
attempts = 0
}
if (pending && !pending.closed)
return
const attempt = current.child()
pending = attempt
attempt.add(() => {
if (pending === attempt)
pending = undefined
})
const active = () => !isClosing(art) && !attempt.closed && current === getSourceScope(art)
const retry = attempts < constructor.RECONNECT_TIME_MAX
try {
if (!retry) {
showMediaUI(art, [['mask', true], ['loading', false], ['controls', true]], active)
if (!active())
return
addClass($player, 'art-error')
}
if (!await wait(attempt, constructor.RECONNECT_SLEEP_TIME) || !active())
return
if (retry) {
const url = option.url
if (!active())
return
attempts += 1
const attemptNumber = attempts
// End the old timer before creating the retry's new source generation.
attempt.dispose()
const operation = beginSource(art)
source = getSourceScope(art)
assignUrl(art, operation, url)
if (!operation.active())
return
const message = `${i18n.get('Reconnect')}: ${attemptNumber}`
if (!operation.active())
return
notice.show = message
if (operation.active())
art.emit('error', error, attemptNumber)
}
else {
const message = i18n.get('Video Load Failed')
if (active())
notice.show = message
}
}
finally {
attempt.dispose()
}
}
return {
reset() {
attempts = 0
pending?.dispose()
removeClass($player, 'art-error')
},
loadStart() {
removeClass($player, 'art-error')
},
schedule(error) {
void run(error).catch((failure) => {
console.warn('ArtPlayer reconnect failed:', failure)
})
},
}
}
@@ -0,0 +1,43 @@
export type MediaEventName = `video:${string}`
export type MediaListener = (event?: unknown) => void
export interface MediaEvents {
on: (name: MediaEventName, callback: MediaListener) => unknown
once: (name: MediaEventName, callback: MediaListener) => unknown
off: (name: MediaEventName, callback: MediaListener) => unknown
emit: {
(name: MediaEventName, event: Event): unknown
(name: 'ready' | 'resize'): unknown
(name: 'error', error: unknown, attempt: number): unknown
}
}
export interface MediaUI {
loading: { set show(value: boolean) }
controls: { set show(value: boolean) }
mask: { set show(value: boolean) }
}
export interface MediaEventHost extends MediaEvents, MediaUI {
template: {
$video: EventTarget
$player: Pick<HTMLElement, 'classList'>
$poster: Pick<HTMLElement, 'style'>
}
proxy: (target: EventTarget, name: string, callback: (event: Event) => void) => unknown
isReady: boolean
readonly playing: boolean
url: string | null
set seek(time: number)
play: () => unknown
option: { url: string, loop: boolean }
i18n: { get: (key: string) => string }
notice: { set show(message: string) }
constructor: { RECONNECT_TIME_MAX: number, RECONNECT_SLEEP_TIME: number }
}
export interface Reconnect {
reset: () => void
loadStart: () => void
schedule: (error: unknown) => void
}