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
}
-159
View File
@@ -1,159 +0,0 @@
import config from '../config'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { addClass, isMobile, setStyle, silencePromise } from '../utils'
export default function eventInit(art) {
const {
i18n,
notice,
option,
constructor,
proxy,
template: { $player, $video, $poster },
} = art
let reconnectTime = 0
for (let index = 0; index < config.events.length; index++) {
proxy($video, config.events[index], (event) => {
art.emit(`video:${event.type}`, event)
})
}
// art.on('video:abort', () => {
// });
art.on('video:canplay', () => {
reconnectTime = 0
art.loading.show = false
})
art.once('video:canplay', () => {
art.loading.show = false
art.controls.show = true
art.mask.show = true
art.isReady = true
art.emit('ready')
})
// art.on('video:canplaythrough', () => {
// });
// art.on('video:durationchange', () => {
// });
// art.on('video:emptied', () => {
// });
art.on('video:ended', () => {
if (option.loop) {
art.seek = 0
silencePromise(art.play())
art.controls.show = false
art.mask.show = false
}
else {
art.controls.show = true
art.mask.show = true
}
})
art.on('video:error', async (error) => {
if (reconnectTime < constructor.RECONNECT_TIME_MAX) {
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return
reconnectTime += 1
art.url = option.url
notice.show = `${i18n.get('Reconnect')}: ${reconnectTime}`
art.emit('error', error, reconnectTime)
}
else {
art.mask.show = true
art.loading.show = false
art.controls.show = true
addClass($player, 'art-error')
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return
notice.show = i18n.get('Video Load Failed')
}
})
// art.on('video:loadeddata', () => {
// });
art.on('video:loadedmetadata', () => {
art.emit('resize')
if (isMobile) {
art.loading.show = false
art.controls.show = true
art.mask.show = true
}
})
art.on('video:loadstart', () => {
art.loading.show = true
art.mask.show = false
art.controls.show = true
})
art.on('video:pause', () => {
art.controls.show = true
art.mask.show = true
})
art.on('video:play', () => {
art.mask.show = false
setStyle($poster, 'display', 'none')
})
art.on('video:playing', () => {
art.mask.show = false
})
art.on('video:progress', () => {
if (art.playing) {
art.loading.show = false
}
})
// art.on('video:ratechange', () => {
// });
art.on('video:seeked', () => {
art.loading.show = false
art.mask.show = true
})
art.on('video:seeking', () => {
art.loading.show = true
art.mask.show = false
})
// art.on('video:stalled', () => {
// });
// art.on('video:suspend', () => {
// });
art.on('video:timeupdate', () => {
art.mask.show = false
})
// art.on('video:volumechange', () => {
// });
art.on('video:waiting', () => {
art.loading.show = true
art.mask.show = false
})
}
@@ -0,0 +1,15 @@
import type { MediaEventHost } from '../media/events/types'
import { forwardMediaEvents } from '../media/events/forward'
import { listenMedia } from '../media/events/listen'
import { installEnded, installPlaybackUI } from '../media/events/playback'
import { installReadiness } from '../media/events/readiness'
import { createReconnect } from '../media/events/reconnect'
export default function eventInit(art: MediaEventHost): void {
forwardMediaEvents(art)
const reconnect = createReconnect(art)
installReadiness(art, reconnect)
installEnded(art)
listenMedia(art, 'video:error', reconnect.schedule)
installPlaybackUI(art, reconnect)
}
+16 -3
View File
@@ -13,15 +13,28 @@ export interface SourceOperation {
const current = new WeakMap<object, SourceOperation>()
const assignments = new WeakMap<object, SourceOperation>()
const sources = new WeakMap<object, ResourceScope>()
export function getSourceScope(owner: object): ResourceScope {
return sources.get(owner) || getScope(owner)
}
export function captureSource(owner: object): () => boolean {
const operation = current.get(owner)
const source = sources.get(owner)
return () => current.get(owner) === operation && (!operation || operation.active())
&& sources.get(owner) === source && (!source || !source.closed)
}
export function beginSource(owner: object): SourceOperation {
const previous = current.get(owner)
const scope = getScope(owner).child()
const previous = sources.get(owner)
const source = getScope(owner).child()
const scope = source.child()
sources.set(owner, source)
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner)
})
const operation: SourceOperation = {
scope,
assigned: false,
@@ -35,7 +48,7 @@ export function beginSource(owner: object): SourceOperation {
operation.onAssigned = undefined
operation.onError = undefined
})
previous?.scope.dispose()
previous?.dispose()
return operation
}