refactor(canvas): [PKG-CANVAS-03] own media and frame lifecycle in strict TypeScript

This commit is contained in:
Harvey Zhao committed 2026-09-12 13:11:07 +08:00
1 parent e931520e66
commit 2b319109cd
29 files changed
+2015 -362

No files matched your search

@@ -0,0 +1,39 @@
export function forwardMedia(canvas: HTMLCanvasElement, video: HTMLVideoElement, active: () => boolean, invalidate: () => void, mount: () => void): void {
const methods = new Map<string, (...args: unknown[]) => unknown>()
for (const name in canvas) {
const value: unknown = Reflect.get(canvas, name)
if (typeof value === 'function')
methods.set(name, (...args) => Reflect.apply(value, canvas, args))
}
for (const name in video) {
if (name in canvas)
continue
Object.defineProperty(canvas, name, {
get() {
const value: unknown = Reflect.get(video, name)
if (typeof value !== 'function')
return value
return (...args: unknown[]) => {
if (!active())
return name === 'play' ? Promise.resolve() : undefined
if (name === 'load')
invalidate()
if (name === 'play')
mount()
return Reflect.apply(value, video, args)
}
},
set(value: unknown) {
if (!active())
return
if (name === 'src' || name === 'srcObject')
invalidate()
Reflect.set(video, name, value)
},
configurable: true,
enumerable: true,
})
}
for (const [name, method] of methods)
Reflect.set(canvas, name, method)
}
@@ -0,0 +1,18 @@
export function hasDimensions(video: HTMLVideoElement): boolean {
return Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight) && video.videoWidth > 0 && video.videoHeight > 0
}
export function resizeCanvas(canvas: HTMLCanvasElement, video: HTMLVideoElement, player: HTMLElement | null | undefined, autoSize: boolean | undefined): void {
if (!player || autoSize || !hasDimensions(video))
return
const width = player.clientWidth
const height = player.clientHeight
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
return
const aspect = video.videoWidth / video.videoHeight
const canvasWidth = width / height > aspect ? height * aspect : width
const canvasHeight = width / height > aspect ? height : width / aspect
canvas.width = canvasWidth
canvas.height = canvasHeight
canvas.style.padding = `${(height - canvasHeight) / 2}px ${(width - canvasWidth) / 2}px`
}
@@ -1,139 +0,0 @@
export default function artplayerProxyCanvas(callback) {
return (art) => {
const { constructor } = art
const { createElement } = constructor.utils
const canvas = createElement('canvas')
const video = createElement('video')
const ctx = canvas.getContext('2d')
let animationId = null
const originalCanvasMethods = {}
for (const prop in canvas) {
if (typeof canvas[prop] === 'function') {
originalCanvasMethods[prop] = canvas[prop].bind(canvas)
}
}
for (const prop in video) {
if (!(prop in canvas)) {
Object.defineProperty(canvas, prop, {
get() {
const value = video[prop]
return typeof value === 'function' ? value.bind(video) : value
},
set(value) {
video[prop] = value
},
configurable: true,
enumerable: true,
})
}
}
for (const prop in originalCanvasMethods) {
canvas[prop] = function (...args) {
if (prop in originalCanvasMethods) {
return originalCanvasMethods[prop](...args)
}
return video[prop](...args)
}
}
setupEventListeners()
setupArtPlayerEvents()
return canvas
function setupEventListeners() {
const { events } = constructor.config
setTimeout(() => {
events.forEach((event) => {
art.proxy(video, event, (event) => {
art.emit(`video:${event.type}`, event)
})
})
})
}
async function draw() {
try {
if (typeof createImageBitmap !== 'undefined') {
const bitmap = await createImageBitmap(video)
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
bitmap.close()
}
else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
callback && callback(ctx, video)
art.emit('artplayerProxyCanvas:draw', ctx, video)
}
catch (error) {
art.emit('artplayerProxyCanvas:error', error)
}
}
async function animation() {
await draw()
animationId = requestAnimationFrame(animation)
}
function resize() {
const player = art.template?.$player
if (!player || art.option.autoSize)
return
const aspectRatio = video.videoWidth / video.videoHeight
const containerWidth = player.clientWidth
const containerHeight = player.clientHeight
const containerRatio = containerWidth / containerHeight
let canvasWidth, canvasHeight
if (containerRatio > aspectRatio) {
canvasHeight = containerHeight
canvasWidth = canvasHeight * aspectRatio
}
else {
canvasWidth = containerWidth
canvasHeight = canvasWidth / aspectRatio
}
canvas.width = canvasWidth
canvas.height = canvasHeight
const paddingLeft = (containerWidth - canvasWidth) / 2
const paddingTop = (containerHeight - canvasHeight) / 2
Object.assign(canvas.style, {
padding: `${paddingTop}px ${paddingLeft}px`,
})
}
function setupArtPlayerEvents() {
art.on('video:loadedmetadata', () => {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
})
art.on('video:play', () => {
cancelAnimationFrame(animationId)
animation()
})
art.on('video:pause', () => {
cancelAnimationFrame(animationId)
})
art.on('resize', () => {
resize()
draw()
})
art.on('destroy', () => {
cancelAnimationFrame(animationId)
})
}
}
}
@@ -0,0 +1,132 @@
import type Artplayer from 'artplayer'
import type { DrawCallback } from './renderer'
import { forwardMedia } from './adapter'
import { hasDimensions, resizeCanvas } from './geometry'
import { ownMedia } from './media'
import { createRenderer } from './renderer'
import { createFrameScheduler } from './scheduler'
export default function artplayerProxyCanvas(callback?: DrawCallback) {
return (art: Artplayer): HTMLCanvasElement => {
const constructor = art.constructor as typeof Artplayer
const canvas = constructor.utils.createElement('canvas')
const video = constructor.utils.createElement('video')
let host: Artplayer | null = art
let closed = art.isDestroy
let timer: ReturnType<typeof setTimeout> | null = null
const releases: Array<() => void> = []
const active = () => !closed && !host?.isDestroy
const media = ownMedia(video, () => host?.template?.$player, active)
const renderer = createRenderer(canvas, video, callback, (context, source) => host?.emit('artplayerProxyCanvas:draw', context, source), error => host?.emit('artplayerProxyCanvas:error', error))
const scheduler = createFrameScheduler(renderer.draw, active)
forwardMedia(canvas, video, active, scheduler.stop, media.mount)
if (closed) {
host = null
return canvas
}
function destroy(): void {
if (closed)
return
closed = true
scheduler.destroy()
if (timer !== null)
clearTimeout(timer)
timer = null
const failures: unknown[] = []
for (const release of [...releases.splice(0).reverse(), media.destroy]) {
try {
release()
}
catch (error) { failures.push(error) }
}
host = null
canvas.width = 0
canvas.height = 0
if (failures.length)
throw failures[0]
}
function listen(name: string, handler: () => void): void {
if (!active())
return
const listener = () => {
if (name === 'destroy' || active())
handler()
}
const release = () => art.off(name, listener)
releases.push(release)
art.on(name, listener)
if (closed)
release()
}
function request(): void {
if (renderer.available())
scheduler.request()
}
try {
listen('destroy', destroy)
listen('video:loadedmetadata', () => {
if (hasDimensions(video)) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
}
})
listen('video:play', () => {
media.mount()
if (renderer.available())
scheduler.start()
})
listen('video:pause', scheduler.stop)
listen('video:emptied', scheduler.stop)
listen('video:seeked', request)
listen('resize', () => {
resizeCanvas(canvas, video, host?.template?.$player, host?.option.autoSize)
request()
})
if (active()) {
timer = setTimeout(() => {
timer = null
if (!active())
return
try {
media.mount()
for (const name of constructor.config.events) {
if (!active())
break
const forward = (event: Event) => {
if (active())
host?.emit(`video:${event.type}`, event)
}
// Own native removal before invoking an extensible proxy registrar.
const remove = () => video.removeEventListener(name, forward)
releases.push(remove)
const release = art.proxy(video, name, forward)
if (active())
releases.push(release)
else
release()
}
}
catch (error) {
try {
destroy()
}
catch { /* Finish rollback without replacing the initialization error. */ }
art.emit('artplayerProxyCanvas:error', error)
}
})
}
}
catch (error) {
try {
destroy()
}
catch { /* Preserve the original setup error. */ }
throw error
}
return canvas
}
}
@@ -0,0 +1,36 @@
export function ownMedia(video: HTMLVideoElement, parent: () => HTMLElement | null | undefined, active: () => boolean) {
let mounted = false
return {
mount() {
if (mounted || !active())
return
const target = parent()
if (!target)
return
video.setAttribute('aria-hidden', 'true')
video.tabIndex = -1
video.playsInline = true
// Keep media connected before playback without adding another visible player.
Object.assign(video.style, { position: 'absolute', width: 'auto', height: 'auto', opacity: '0', pointerEvents: 'none', left: '0', top: '0' })
target.appendChild(video)
mounted = true
if (!active())
video.remove()
},
destroy() {
const failures: unknown[] = []
const clearObject = () => {
video.srcObject = null
}
for (const release of [() => video.pause(), () => video.removeAttribute('src'), clearObject, () => video.load(), () => video.remove()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
mounted = false
if (failures.length)
throw failures[0]
},
}
}
@@ -0,0 +1,55 @@
import { hasDimensions } from './geometry'
export type DrawCallback = (context: CanvasRenderingContext2D, video: HTMLVideoElement) => void
export function createRenderer(canvas: HTMLCanvasElement, video: HTMLVideoElement, callback: DrawCallback | undefined, notify: DrawCallback, error: (failure: unknown) => void) {
const context = canvas.getContext('2d')
let reported = false
return {
available() {
if (context)
return true
if (!reported) {
reported = true
error(new Error('Canvas 2D context is unavailable'))
}
return false
},
async draw(valid: () => boolean): Promise<void> {
const usable = () => valid() && video.readyState >= 2 && !video.seeking
if (!context || !usable() || !hasDimensions(video) || !canvas.width || !canvas.height)
return
let acquiring = false
try {
if (typeof createImageBitmap !== 'undefined') {
acquiring = true
const bitmap = await createImageBitmap(video)
acquiring = false
try {
if (usable())
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
}
finally { bitmap.close() }
}
else {
context.drawImage(video, 0, 0, canvas.width, canvas.height)
}
if (!usable())
return
if (callback)
callback(context, video)
if (usable())
notify(context, video)
}
catch (failure) {
// Chromium can expose readyState 4 before its first frame is usable.
// Other engines report zero frame counts even while drawing successfully.
const awaitingFirstFrame = acquiring && typeof failure === 'object' && failure !== null && 'name' in failure
&& failure.name === 'InvalidStateError' && video.currentTime === 0
&& typeof video.getVideoPlaybackQuality === 'function' && video.getVideoPlaybackQuality().totalVideoFrames === 0
if (usable() && !awaitingFirstFrame)
error(failure)
}
},
}
}
@@ -0,0 +1,69 @@
export function createFrameScheduler(draw: (valid: () => boolean) => Promise<void>, active: () => boolean) {
let running = false
let closed = false
let busy = false
let pending = false
let generation = 0
let frame: number | null = null
function cancel(): void {
if (frame !== null)
cancelAnimationFrame(frame)
frame = null
}
function drain(): void {
if (busy || closed || !active())
return
if (pending) {
pending = false
busy = true
const version = generation
const valid = () => !closed && active() && generation === version
const complete = () => {
busy = false
drain()
}
// An observer throwing must not leave the scheduler busy or reject unhandled.
void draw(valid).then(complete, complete)
}
else if (running && frame === null) {
frame = requestAnimationFrame(() => {
frame = null
pending = true
drain()
})
}
}
function request(): void {
if (closed || !active())
return
generation++
pending = true
cancel()
drain()
}
function stop(): void {
running = false
pending = false
generation++
cancel()
}
return {
request,
start() {
if (running || closed || !active())
return
running = true
request()
},
stop,
destroy() {
closed = true
stop()
},
}
}