refactor(mediabunny): [PKG-MB-08] checkpoint typed entry and compatible media declarations

This commit is contained in:
Harvey Zhao committed 2026-09-12 21:31:34 +08:00
1 parent 8a2b56e829
commit 5e28dfee95
39 files changed
+3007 -287

No files matched your search

@@ -1,3 +1,4 @@
import type { MediaBunnyShim, SyntheticFrameCallback } from '../types/artplayer-proxy-mediabunny'
import type { EngineOptions, EnginePort, ProxyOptions, ShimHost } from './engine-types'
import type { MediaListener } from './EventTarget'
import EventTarget from './EventTarget'
@@ -5,7 +6,7 @@ import MediaBunnyEngine from './MediaBunnyEngine.js'
import { cancelFrame, closeFrames, requestFrame } from './shim-frames'
import { clampVolume, timeRanges } from './shim-values'
export default class VideoShim {
export default class VideoShim implements MediaBunnyShim {
declare art: ShimHost
declare canvas: HTMLCanvasElement
declare option: ProxyOptions
@@ -82,7 +83,7 @@ export default class VideoShim {
}
// Time
get currentTime() {
get currentTime(): number {
return this.engine.currentTime
}
@@ -143,7 +144,7 @@ export default class VideoShim {
}
// Playback rate
get playbackRate() {
get playbackRate(): number {
return this._playbackRate
}
@@ -158,7 +159,7 @@ export default class VideoShim {
}
// Volume
get volume() {
get volume(): number {
return this._volume
}
@@ -169,7 +170,7 @@ export default class VideoShim {
this.events.emit('volumechange')
}
get muted() {
get muted(): boolean {
return this._muted
}
@@ -223,56 +224,56 @@ export default class VideoShim {
this.option.poster = v
}
get autoplay() {
get autoplay(): boolean {
return this.option.autoplay || false
}
set autoplay(v: unknown) {}
get loop() {
get loop(): boolean {
return this.option.loop || false
}
set loop(v: unknown) {}
get controls() {
get controls(): boolean {
return false
}
set controls(v: unknown) {}
get playsInline() {
get playsInline(): boolean {
return true
}
set playsInline(v: unknown) {}
get crossOrigin() {
get crossOrigin(): string {
return this.option.crossOrigin || ''
}
set crossOrigin(v: unknown) {}
get preload() {
get preload(): string {
return 'auto'
}
set preload(v: unknown) {}
get defaultMuted() {
get defaultMuted(): boolean {
return false
}
set defaultMuted(v: unknown) {}
get defaultPlaybackRate() {
get defaultPlaybackRate(): number {
return 1
}
set defaultPlaybackRate(v: unknown) {}
// Methods
canPlayType(_type: string) {
canPlayType(_type: string): 'maybe' {
return 'maybe'
}
@@ -280,7 +281,7 @@ export default class VideoShim {
return this.canvas.getBoundingClientRect()
}
requestVideoFrameCallback(callback: VideoFrameRequestCallback): number {
requestVideoFrameCallback(callback: SyntheticFrameCallback): number {
return requestFrame(this, callback)
}
@@ -0,0 +1,36 @@
import type VideoShim from './VideoShim'
export function bridgeCanvas(canvas: HTMLCanvasElement, shim: VideoShim): void {
const methods = new Map<string, (...args: unknown[]) => unknown>()
// Dynamic forwarding is intentional: own and direct-prototype members are historical API.
const native = canvas as unknown as Record<string, unknown>
const members = shim as unknown as Record<string, unknown>
for (const name in canvas) {
if (typeof native[name] === 'function') {
// Preserve the historical second read, including accessor side effects and binding errors.
const method = native[name] as (...args: unknown[]) => unknown
methods.set(name, method.bind(canvas))
}
}
const names = new Set([
...Object.getOwnPropertyNames(shim),
...Object.getOwnPropertyNames(Object.getPrototypeOf(shim)),
])
for (const name of names) {
if (name === 'constructor' || name in canvas)
continue
Object.defineProperty(canvas, name, {
get() {
const value = members[name]
return typeof value === 'function' ? value.bind(shim) : value
},
set(value: unknown) {
members[name] = value
},
configurable: true,
enumerable: true,
})
}
for (const [name, method] of methods)
native[name] = (...args: unknown[]) => method(...args)
}
@@ -0,0 +1,11 @@
export function releaseAll(actions: (() => void)[]): void {
const failures: unknown[] = []
for (const action of actions) {
try {
action()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
@@ -0,0 +1,45 @@
import type { ProxyOptions, ShimHost } from './engine-types'
import type { MenuHost } from './m3u8-types'
import type VideoShim from './VideoShim'
import { releaseAll } from './cleanup'
import { setupM3u8Controls } from './m3u8'
export interface EntryHost extends ShimHost, MenuHost {
constructor: ShimHost['constructor'] & { utils: { createElement: (tag: 'canvas') => HTMLCanvasElement } }
option: ShimHost['option'] & { autoSize?: boolean }
template?: { $player?: HTMLElement }
mediabunny?: unknown
}
export function entryLifecycle(art: EntryHost, canvas: HTMLCanvasElement, shim: VideoShim, option: ProxyOptions) {
let closed = false
let controls: ReturnType<typeof setupM3u8Controls>
function resize(): void {
if (closed || !art.template?.$player || art.option.autoSize)
return
Object.assign(canvas.style, { width: '100%', height: '100%', objectFit: 'contain' })
}
function destroy(): void {
if (closed)
return
closed = true
releaseAll([
() => controls?.destroy(),
() => {
if (art.mediabunny === shim)
delete art.mediabunny
},
() => shim.destroy(),
() => art.off?.('resize', resize),
() => art.off?.('video:loadedmetadata', resize),
() => art.off?.('destroy', destroy),
])
}
function install(): void {
art.on('resize', resize)
art.on('video:loadedmetadata', resize)
controls = setupM3u8Controls({ art, shim, option })
art.on('destroy', destroy)
}
return { install, destroy }
}
@@ -1,89 +0,0 @@
import { setupM3u8Controls } from './m3u8.js'
/**
* ArtPlayer MediaBunny Proxy
* Main entry point
*/
import VideoShim from './VideoShim.js'
export default function artplayerProxyMediabunny(option = {}) {
return (art) => {
const { constructor } = art
const { createElement } = constructor.utils
// Create canvas element
const canvas = createElement('canvas')
const ctx = canvas.getContext('2d')
// Create video shim
const shim = new VideoShim({
art,
canvas,
ctx,
option,
})
art.mediabunny = shim
// Proxy canvas methods to shim
const originalCanvasMethods = {}
for (const prop in canvas) {
if (typeof canvas[prop] === 'function') {
originalCanvasMethods[prop] = canvas[prop].bind(canvas)
}
}
// Get all properties from shim instance and prototype
const propertyNames = new Set([
...Object.getOwnPropertyNames(shim),
...Object.getOwnPropertyNames(Object.getPrototypeOf(shim)),
])
// Add shim properties to canvas
for (const prop of propertyNames) {
if (prop === 'constructor')
continue
if (!(prop in canvas)) {
Object.defineProperty(canvas, prop, {
get() {
const value = shim[prop]
return typeof value === 'function' ? value.bind(shim) : value
},
set(v) {
shim[prop] = v
},
configurable: true,
enumerable: true,
})
}
}
// Restore original canvas methods
for (const prop in originalCanvasMethods) {
canvas[prop] = (...args) => originalCanvasMethods[prop](...args)
}
// Handle resize
function resize() {
const player = art.template?.$player
if (!player || art.option.autoSize)
return
Object.assign(canvas.style, {
width: '100%',
height: '100%',
objectFit: 'contain',
})
}
art.on('resize', resize)
art.on('video:loadedmetadata', resize)
setupM3u8Controls({ art, shim, option })
// Cleanup on destroy
art.on('destroy', () => {
delete art.mediabunny
shim.destroy()
})
return canvas
}
}
@@ -0,0 +1,33 @@
import type Artplayer from 'artplayer'
import type { ProxyOptions } from './engine-types'
import type { EntryHost } from './entry-lifecycle'
import { bridgeCanvas } from './canvas-bridge'
import { entryLifecycle } from './entry-lifecycle'
import VideoShim from './VideoShim'
export default function artplayerProxyMediabunny(option: ProxyOptions = {}) {
return (player: Artplayer): HTMLCanvasElement => {
// Runtime constructor utilities and the owned alias exceed the historical consumer facade.
const art = player as unknown as EntryHost
const { createElement } = art.constructor.utils
const canvas = createElement('canvas')
const shim = new VideoShim({ art, canvas, ctx: canvas.getContext('2d'), option })
const lifecycle = entryLifecycle(art, canvas, shim, option)
try {
art.mediabunny = shim
bridgeCanvas(canvas, shim)
lifecycle.install()
}
catch (error) {
try {
lifecycle.destroy()
}
catch (failure) { console.warn('MediaBunny entry setup cleanup:', failure) }
throw error
}
return canvas
}
}
// Restore the 1.0.0 CommonJS namespace path without changing the callable factory type.
Object.defineProperty(artplayerProxyMediabunny, 'default', { value: artplayerProxyMediabunny, writable: true, configurable: true })
@@ -1,16 +1,5 @@
import type { DisplayConfig, Label, MenuHost, MenuModel, SelectorItem } from './m3u8-types'
export function releaseAll(actions: (() => void)[]): void {
const failures: unknown[] = []
for (const action of actions) {
try {
action()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
import { releaseAll } from './cleanup'
export default function createMenu(art: MenuHost, name: string, icon: string) {
const owned = { control: false, setting: false }
@@ -1,7 +1,8 @@
import type { Label, SelectorItem, SetupOptions } from './m3u8-types'
import $audio from '../../artplayer-plugin-hls-control/src/audio.svg?raw'
import $quality from '../../artplayer-plugin-hls-control/src/quality.svg?raw'
import createMenu, { releaseAll } from './m3u8-menu'
import { releaseAll } from './cleanup'
import createMenu from './m3u8-menu'
import { audioModel, qualityModel } from './m3u8-model'
export function setupM3u8Controls({ art, shim, option }: SetupOptions) {
@@ -95,10 +96,16 @@ export function setupM3u8Controls({ art, shim, option }: SetupOptions) {
}
catch (error) { console.warn('MediaBunny HLS listener cleanup:', error) }
}
art.on('video:loadedmetadata', refresh)
art.on('restart', refresh)
art.on('video:loadstart', invalidate)
art.on('video:error', invalidate)
art.on('destroy', destroy)
return { update }
try {
art.on('video:loadedmetadata', refresh)
art.on('restart', refresh)
art.on('video:loadstart', invalidate)
art.on('video:error', invalidate)
art.on('destroy', destroy)
}
catch (error) {
destroy()
throw error
}
return { update, destroy }
}
@@ -1,3 +1,4 @@
import type { SyntheticFrameCallback } from '../types/artplayer-proxy-mediabunny'
import type { EnginePort } from './engine-types'
interface FrameOwner {
@@ -20,7 +21,7 @@ function state(owner: FrameOwner): FrameState {
return value
}
export function requestFrame(owner: FrameOwner, callback: VideoFrameRequestCallback): number {
export function requestFrame(owner: FrameOwner, callback: SyntheticFrameCallback): number {
const resource = state(owner)
if (resource.closed)
return 0