refactor(core): CORE-09 isolate source operations and settle cancelled switches

This commit is contained in:
Harvey Zhao committed 2026-09-11 02:34:26 +08:00
1 parent 874dac9f22
commit 44f4e083a9
29 files changed
+4512 -255

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import type ResourceScope from '../lifecycle/scope'
import type { SourceEvent, SourceEvents, SourceListener } from './types'
export function listenSource(scope: ResourceScope, art: SourceEvents, name: SourceEvent, callback: SourceListener, once = true): SourceListener {
let fired = false
let release: () => void
const guarded: SourceListener = (event) => {
if (scope.closed || fired)
return
if (once) {
fired = true
release()
}
callback(event)
}
art[once ? 'once' : 'on'](name, guarded)
release = scope.add(() => {
art.off(name, guarded)
})
return guarded
}
@@ -0,0 +1,79 @@
import type ResourceScope from '../lifecycle/scope'
import type { UrlTarget } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
export interface SourceOperation {
scope: ResourceScope
assigned: boolean
acceptingEvents: boolean
onAssigned?: () => void
onError?: (error: unknown) => void
active: () => boolean
}
const current = new WeakMap<object, SourceOperation>()
const assignments = new WeakMap<object, SourceOperation>()
export function captureSource(owner: object): () => boolean {
const operation = current.get(owner)
return () => current.get(owner) === operation && (!operation || operation.active())
}
export function beginSource(owner: object): SourceOperation {
const previous = current.get(owner)
const scope = getScope(owner).child()
const operation: SourceOperation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation,
}
current.set(owner, operation)
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner)
operation.onAssigned = undefined
operation.onError = undefined
})
previous?.scope.dispose()
return operation
}
export function takeAssignment(owner: object): SourceOperation {
const operation = assignments.get(owner)
assignments.delete(owner)
return operation || beginSource(owner)
}
export function finishAssignment(operation: SourceOperation): void {
operation.assigned = true
const callback = operation.onAssigned
operation.onAssigned = undefined
if (operation.active())
callback?.()
}
export function assignUrl(owner: UrlTarget, operation: SourceOperation, url: string): void {
assignments.set(owner, operation)
operation.acceptingEvents = true
try {
owner.url = url
}
finally {
// Also support a host-provided URL setter without a core URL mixin.
if (assignments.get(owner) === operation) {
assignments.delete(owner)
finishAssignment(operation)
}
}
}
export function failSource(operation: SourceOperation, error: unknown): void {
if (!operation.active())
return
if (operation.onError)
operation.onError(error)
else
console.warn('Failed to initialize ArtPlayer source:', error)
operation.scope.dispose()
}
+121
View File
@@ -0,0 +1,121 @@
import type { SourceEvent, SourceListener, SwitchHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { listenSource } from './listen'
import { assignUrl, beginSource } from './operation'
export function switchSource(art: SwitchHost, url: string, currentTime: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (isClosing(art) || url === art.url) {
resolve()
return
}
const operation = beginSource(art)
const scope = operation.scope.child()
let settled = false
const settle = (failed = false, error?: unknown) => {
if (settled)
return
settled = true
operation.onError = undefined
operation.onAssigned = undefined
scope.dispose()
if (failed)
reject(error)
else
resolve()
if (failed)
operation.scope.dispose()
}
scope.add(() => {
settle()
})
const active = () => !settled && operation.active()
const fail = (error: unknown) => settle(true, error)
operation.onError = fail
try {
const { playing, aspectRatio, playbackRate } = art
art.pause()
if (!active())
return
const readiness = scope.child()
const handlers = {
'video:error': fail,
'video:loadedmetadata': () => {
if (!active())
return
try {
art.currentTime = currentTime
}
catch (error) {
fail(error)
}
},
'video:canplay': () => {
if (!active())
return
// Detach readiness handlers before awaiting a possibly pending play.
readiness.dispose()
const resume = async () => {
art.playbackRate = playbackRate
if (!active())
return
art.aspectRatio = aspectRatio
if (!active())
return
if (playing) {
// The public play promise still rejects; this internal resume is best effort.
try {
await art.play()
}
catch {}
}
if (!active())
return
art.notice.show = ''
settle()
}
void resume().catch(fail)
},
}
const capture = scope.child()
const queued: [SourceEvent, unknown][] = []
const names = Object.keys(handlers) as SourceEvent[]
for (const name of names) {
listenSource(capture, art, name, (event) => {
if (operation.acceptingEvents)
queued.push([name, event])
}, false)
}
assignUrl(art, operation, url)
if (!active())
return
art.notice.show = ''
if (!active())
return
const activate = () => {
capture.dispose()
if (!url) {
settle()
return
}
const dispatch = {} as Record<SourceEvent, SourceListener>
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name])
for (const [name, event] of queued) {
if (!active() || readiness.closed)
break
dispatch[name](event)
}
queued.length = 0
}
if (operation.assigned)
activate()
else
operation.onAssigned = activate
}
catch (error) {
fail(error)
}
})
}
+44
View File
@@ -0,0 +1,44 @@
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay'
export type SourceListener = (event?: unknown) => void
export interface SourceEvents {
on: (name: SourceEvent, callback: SourceListener) => unknown
once: (name: SourceEvent, callback: SourceListener) => unknown
off: (name: SourceEvent, callback: SourceListener) => unknown
}
export interface SourceMedia {
src: string | null
}
export interface UrlTarget {
url: string | null
}
export interface UrlHost<Media extends SourceMedia, Host> extends UrlTarget, SourceEvents {
template: { $video: Media }
option: {
url: string
type: string
customType: Record<string, ((this: Host, video: Media, url: string, art: Host) => unknown) | undefined>
}
loading: { set show(value: boolean) }
isReady: boolean
emit: (name: 'restart', url: string) => unknown
}
export interface SwitchHost extends UrlTarget, SourceEvents {
currentTime: number
readonly playing: boolean
aspectRatio: string
playbackRate: number
pause: () => unknown
play: () => unknown
notice: { set show(value: string) }
}
export interface SwitchMethods {
switchUrl: (url: string) => Promise<void>
switchQuality: (url: string) => Promise<void>
set switch(url: string)
}