refactor(mediabunny): [PKG-MB-04] checkpoint typed shim and event cleanup

This commit is contained in:
Harvey Zhao committed 2026-09-12 18:27:33 +08:00
1 parent adba8a3d12
commit 9af253a5a9
24 files changed
+1478 -250

No files matched your search

@@ -1,37 +0,0 @@
/**
* Event Target Implementation
* Simple event system for video events
*/
export default class EventTarget {
constructor() {
this.listeners = new Map()
}
addEventListener(type, fn) {
if (!this.listeners.has(type)) {
this.listeners.set(type, [])
}
this.listeners.get(type).push(fn)
}
removeEventListener(type, fn) {
const list = this.listeners.get(type)
if (!list)
return
const index = list.indexOf(fn)
if (index >= 0) {
list.splice(index, 1)
}
}
emit(type, detail) {
const evt = new Event(type)
evt.detail = detail
const list = this.listeners.get(type)
if (list) {
list.forEach(fn => fn(evt))
}
}
}
@@ -0,0 +1,44 @@
export type MediaEvent = Event & { detail: unknown }
export type MediaListener = (event: MediaEvent) => unknown
export default class EventTarget {
declare listeners: Map<string, MediaListener[]>
#closed = false
constructor() {
this.listeners = new Map()
}
addEventListener(type: string, fn: MediaListener): void {
if (this.#closed)
return
if (!this.listeners.has(type))
this.listeners.set(type, [])
this.listeners.get(type)?.push(fn)
}
removeEventListener(type: string, fn: MediaListener): void {
const list = this.listeners.get(type)
if (!list)
return
const index = list.indexOf(fn)
if (index >= 0)
list.splice(index, 1)
}
emit(type: string, detail?: unknown): void {
if (this.#closed)
return
const event = Object.assign(new Event(type), { detail })
// Keep historical live-array forEach semantics and uncaught listener exceptions.
this.listeners.get(type)?.forEach((fn) => {
if (!this.#closed)
fn(event)
})
}
destroy(): void {
this.#closed = true
this.listeners.clear()
}
}
@@ -0,0 +1,4 @@
import type { EngineOptions, EnginePort } from './engine-types'
declare const MediaBunnyEngine: new (options: EngineOptions) => EnginePort
export default MediaBunnyEngine
@@ -1,16 +1,22 @@
import EventTarget from './EventTarget.js'
/**
* Video Element Shim
* Simulates HTMLVideoElement interface for MediaBunny
*/
import type { EngineOptions, EnginePort, ProxyOptions, ShimHost } from './engine-types'
import type { MediaListener } from './EventTarget'
import EventTarget from './EventTarget'
import MediaBunnyEngine from './MediaBunnyEngine.js'
function clamp(v, min, max) {
return Math.max(min, Math.min(max, Number(v) || 0))
}
import { cancelFrame, closeFrames, requestFrame } from './shim-frames'
import { clampVolume, timeRanges } from './shim-values'
export default class VideoShim {
constructor({ art, canvas, ctx, option }) {
declare art: ShimHost
declare canvas: HTMLCanvasElement
declare option: ProxyOptions
declare events: EventTarget
declare engine: EnginePort
declare _src: unknown
declare _volume: number
declare _muted: boolean
declare _playbackRate: number
constructor({ art, canvas, ctx, option }: Omit<EngineOptions, 'events' | 'option'> & { art: ShimHost, option: ProxyOptions }) {
this.art = art
this.canvas = canvas
this.option = option
@@ -52,11 +58,11 @@ export default class VideoShim {
}
// Event methods
addEventListener(type, fn) {
addEventListener(type: string, fn: MediaListener) {
this.events.addEventListener(type, fn)
}
removeEventListener(type, fn) {
removeEventListener(type: string, fn: MediaListener) {
this.events.removeEventListener(type, fn)
}
@@ -65,7 +71,7 @@ export default class VideoShim {
return this._src
}
set src(v) {
set src(v: unknown) {
this._src = v
if (v)
this.engine.load(v)
@@ -80,7 +86,7 @@ export default class VideoShim {
return this.engine.currentTime
}
set currentTime(t) {
set currentTime(t: unknown) {
this.engine.seek(Number(t) || 0)
}
@@ -101,16 +107,8 @@ export default class VideoShim {
return this.createTimeRanges(0, this.engine.duration)
}
createTimeRanges(start, end) {
const duration = this.engine.duration
if (!duration || Number.isNaN(duration) || end <= 0) {
return { length: 0, start: () => 0, end: () => 0 }
}
return {
length: 1,
start: () => start,
end: () => end,
}
createTimeRanges(start: number, end: number): TimeRanges {
return timeRanges(this.engine.duration, start, end)
}
// Playback state
@@ -148,7 +146,7 @@ export default class VideoShim {
return this._playbackRate
}
set playbackRate(v) {
set playbackRate(v: unknown) {
const rate = Number(v)
if (Number.isNaN(rate) || rate <= 0)
return
@@ -163,8 +161,8 @@ export default class VideoShim {
return this._volume
}
set volume(v) {
this._volume = clamp(v, 0, 1)
set volume(v: unknown) {
this._volume = clampVolume(v)
this._muted = false
this.engine.setVolume(this._volume, this._muted)
this.events.emit('volumechange')
@@ -174,7 +172,7 @@ export default class VideoShim {
return this._muted
}
set muted(v) {
set muted(v: unknown) {
this._muted = !!v
this.engine.setVolume(this._volume, this._muted)
this.events.emit('volumechange')
@@ -198,11 +196,11 @@ export default class VideoShim {
return this.engine.getHlsState()
}
switchM3u8Quality(value) {
switchM3u8Quality(value: unknown) {
return this.engine.selectHlsQuality(value)
}
switchM3u8Audio(value) {
switchM3u8Audio(value: unknown) {
return this.engine.selectHlsAudio(value)
}
@@ -220,7 +218,7 @@ export default class VideoShim {
return this.option.poster || ''
}
set poster(v) {
set poster(v: string) {
this.option.poster = v
}
@@ -228,52 +226,52 @@ export default class VideoShim {
return this.option.autoplay || false
}
set autoplay(v) {}
set autoplay(v: unknown) {}
get loop() {
return this.option.loop || false
}
set loop(v) {}
set loop(v: unknown) {}
get controls() {
return false
}
set controls(v) {}
set controls(v: unknown) {}
get playsInline() {
return true
}
set playsInline(v) {}
set playsInline(v: unknown) {}
get crossOrigin() {
return this.option.crossOrigin || ''
}
set crossOrigin(v) {}
set crossOrigin(v: unknown) {}
get preload() {
return 'auto'
}
set preload(v) {}
set preload(v: unknown) {}
get defaultMuted() {
return false
}
set defaultMuted(v) {}
set defaultMuted(v: unknown) {}
get defaultPlaybackRate() {
return 1
}
set defaultPlaybackRate(v) {}
set defaultPlaybackRate(v: unknown) {}
// Methods
canPlayType(_type) {
canPlayType(_type: string) {
return 'maybe'
}
@@ -281,29 +279,15 @@ export default class VideoShim {
return this.canvas.getBoundingClientRect()
}
requestVideoFrameCallback(callback) {
const id = requestAnimationFrame((time) => {
callback(time, {
presentationTime: this.engine.currentTime,
expectedDisplayTime: time + 16.6,
width: this.engine.videoWidth,
height: this.engine.videoHeight,
mediaTime: this.engine.currentTime,
presentedFrames: 0,
processingDuration: 0,
captureTime: time,
receiveTime: time,
rtpTimestamp: 0,
})
})
return id
requestVideoFrameCallback(callback: VideoFrameRequestCallback): number {
return requestFrame(this, callback)
}
cancelVideoFrameCallback(id) {
cancelAnimationFrame(id)
cancelVideoFrameCallback(id: number): void {
cancelFrame(this, id)
}
setAttribute(name, value) {
setAttribute(name: string, value: unknown) {
if (name === 'src') {
this.src = value
}
@@ -317,11 +301,18 @@ export default class VideoShim {
this.muted = true
}
else {
this.canvas.setAttribute(name, value)
Reflect.apply(this.canvas.setAttribute, this.canvas, [name, value])
}
}
destroy() {
this.engine.destroy()
destroy(): void {
if (!closeFrames(this))
return
try {
this.engine.destroy()
}
finally {
this.events.destroy()
}
}
}
@@ -0,0 +1,44 @@
import type factory from '../types/artplayer-proxy-mediabunny'
import type EventTarget from './EventTarget'
import type { MediaEvent } from './EventTarget'
import type { getHlsState } from './hls-state'
export type ProxyOptions = NonNullable<Parameters<typeof factory>[0]>
export type HlsState = Awaited<ReturnType<typeof getHlsState>>
export interface ShimHost {
constructor: { config: { events: readonly string[] } }
option: { url?: unknown }
emit: (type: string, event: MediaEvent) => unknown
}
export interface EngineOptions {
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D | null
events: EventTarget
option?: ProxyOptions
}
// Transitional boundary for the coordinator; replaced by its strict implementation in MB-04.
export interface EnginePort {
readonly currentTime: number
readonly duration: number
readonly videoWidth: number
readonly videoHeight: number
paused: boolean
ended: boolean
seeking: boolean
readyState: number
networkState: number
error: { code: number, message: string } | null
play: () => Promise<void>
pause: () => void
load: (source: unknown) => Promise<void>
seek: (time: number) => Promise<void>
destroy: () => void
setVolume: (volume: number, muted: boolean) => void
setPlaybackRate: (rate: number) => void
getHlsState: () => Promise<HlsState>
selectHlsQuality: (value: unknown) => Promise<void>
selectHlsAudio: (value: unknown) => Promise<void>
}
@@ -0,0 +1,61 @@
import type { EnginePort } from './engine-types'
interface FrameOwner {
engine: Pick<EnginePort, 'currentTime' | 'videoWidth' | 'videoHeight'>
}
interface FrameState {
closed: boolean
frames: Set<number>
}
// Keep resource bookkeeping off the observable shim own-property/canvas surface.
const states = new WeakMap<FrameOwner, FrameState>()
function state(owner: FrameOwner): FrameState {
let value = states.get(owner)
if (!value) {
value = { closed: false, frames: new Set() }
states.set(owner, value)
}
return value
}
export function requestFrame(owner: FrameOwner, callback: VideoFrameRequestCallback): number {
const resource = state(owner)
if (resource.closed)
return 0
const id = requestAnimationFrame((time) => {
resource.frames.delete(id)
if (resource.closed)
return
callback(time, {
presentationTime: owner.engine.currentTime,
expectedDisplayTime: time + 16.6,
width: owner.engine.videoWidth,
height: owner.engine.videoHeight,
mediaTime: owner.engine.currentTime,
presentedFrames: 0,
processingDuration: 0,
captureTime: time,
receiveTime: time,
rtpTimestamp: 0,
})
})
resource.frames.add(id)
return id
}
export function cancelFrame(owner: FrameOwner, id: number): void {
state(owner).frames.delete(id)
cancelAnimationFrame(id)
}
export function closeFrames(owner: FrameOwner): boolean {
const resource = state(owner)
if (resource.closed)
return false
resource.closed = true
for (const id of resource.frames) cancelAnimationFrame(id)
resource.frames.clear()
return true
}
@@ -0,0 +1,9 @@
export function clampVolume(value: unknown): number {
return Math.max(0, Math.min(1, Number(value) || 0))
}
export function timeRanges(duration: number, start: number, end: number): TimeRanges {
if (!duration || Number.isNaN(duration) || end <= 0)
return { length: 0, start: () => 0, end: () => 0 }
return { length: 1, start: () => start, end: () => end }
}