refactor(core): CORE-06 type media surfaces and playback hosts

This commit is contained in:
Harvey Zhao committed 2026-09-11 01:18:14 +08:00
1 parent 97d320bd74
commit ee859e4804
27 files changed
+2152 -79

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import { expect, test } from './fixtures.js'
const proxyFile = new URL('../../docs/compiled/artplayer-proxy-canvas.js', import.meta.url)
const proxyCode = fs.readFileSync(proxyFile, 'utf8')
for (const core of ['published', 'candidate']) {
test(`${core}: existing canvas proxy keeps media identity, playback and layout`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.addScriptTag({ content: proxyCode })
await testInfo.attach('canvas-proxy-artifact', { contentType: 'application/json', body: JSON.stringify({
kind: 'existing-workspace-artifact',
file: 'docs/compiled/artplayer-proxy-canvas.js',
sha256: createHash('sha256').update(proxyCode).digest('hex'),
}) })
await page.evaluate(() => {
const container = document.createElement('div')
Object.assign(container.style, { width: '640px', height: '360px' })
document.body.append(container)
window.draws = 0
window.art = new window.Artplayer({
container,
url: '/test/pattern.mp4',
muted: true,
proxy: window.artplayerProxyCanvas(() => window.draws++),
})
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const initial = await page.evaluate(() => {
const art = window.art
return { canvas: art.video instanceof HTMLCanvasElement, native: art.video instanceof HTMLVideoElement, same: art.video === art.template.$video, duration: art.duration, width: art.width, rectWidth: art.rect.width }
})
expect(initial).toEqual({ canvas: true, native: false, same: true, duration: 8, width: 640, rectWidth: 640 })
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await expect.poll(() => page.evaluate(() => window.draws)).toBeGreaterThan(0)
expect(await page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => window.art.pause())
expect(await page.evaluate(() => window.art.playing)).toBe(false)
await page.evaluate(() => {
window.art.seek = 2
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(2, 1)
const layout = await page.evaluate(() => {
const art = window.art
art.template.$container.style.width = '480px'
return { width: art.width, x: art.x, expectedX: art.rect.left + window.pageXOffset }
})
expect(layout.width).toBe(480)
expect(layout.x).toBe(layout.expectedX)
await page.evaluate(() => window.art.destroy())
})
}
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { playMix, pauseMix, playingMix, durationMix, rectMix } = await loadModules(Object.fromEntries(['playMix', 'pauseMix', 'playingMix', 'durationMix', 'rectMix'].map(name => [name, `packages/artplayer/src/player/${name}`])))
test('structural playback hosts retain synchronous proxy values, async play order and receivers', async () => {
const calls = []
const media = {
play() {
assert.equal(this, media)
calls.push('media:play')
return 'proxy-result'
},
pause() {
assert.equal(this, media)
calls.push('media:pause')
return 42
},
}
const art = {
template: { $video: media },
option: { mutex: true },
i18n: { get: key => key },
notice: {
get show() {
return false
},
set show(message) {
calls.push(`notice:${message}`)
},
},
emit: name => calls.push(`event:${name}`),
constructor: { instances: [] },
}
playMix(art)
pauseMix(art)
art.constructor.instances.push(art, {
pause: () => calls.push('mutex'),
})
const play = art.play
const pause = art.pause
const result = play()
assert(result instanceof Promise)
assert.deepEqual(calls, ['media:play'])
assert.equal(await result, 'proxy-result')
assert.deepEqual(calls, ['media:play', 'notice:Play', 'event:play', 'mutex'])
assert.equal(pause(), 42)
assert.deepEqual(calls.slice(-3), ['media:pause', 'notice:Pause', 'event:pause'])
for (const name of ['play', 'pause']) {
const descriptor = Object.getOwnPropertyDescriptor(art, name)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
assert.equal(descriptor.writable, false)
}
})
test('playing uses a shim boolean when present and otherwise keeps native fallback', () => {
const video = { currentTime: 1, paused: false, ended: false, readyState: 3 }
const art = { template: { $video: video } }
playingMix(art)
assert.equal(art.playing, true)
video.playing = false
assert.equal(art.playing, false)
video.currentTime = 0
video.playing = true
assert.equal(art.playing, true)
delete video.playing
assert.equal(art.playing, false)
video.currentTime = 1
video.readyState = 2
assert.equal(art.playing, false)
video.readyState = 3
video.ended = true
assert.equal(art.playing, false)
})
test('duration normalization reads the current media reference and keeps legacy infinity rules', () => {
const art = { template: { $video: { duration: Infinity } } }
durationMix(art)
assert.equal(art.duration, 0)
for (const [value, expected] of [[Number.NaN, 0], [0, 0], [-Infinity, -Infinity], [12.5, 12.5]]) {
art.template.$video = { duration: value }
assert.equal(art.duration, expected)
}
})
test('layout host getters stay live, preserve descriptor order and read scroll offsets', (context) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
Object.defineProperty(globalThis, 'window', { configurable: true, value: { pageXOffset: 4, pageYOffset: 8 } })
context.after(() => {
if (previous)
Object.defineProperty(globalThis, 'window', previous)
else
delete globalThis.window
})
let rectangle = { bottom: 40, height: 30, left: 10, right: 50, top: 10, width: 40 }
const player = { getBoundingClientRect() {
assert.equal(this, player)
return rectangle
} }
const art = { template: { $player: player } }
rectMix(art)
assert.equal(art.rect, rectangle)
assert.equal(art.x, 14)
assert.equal(art.y, 18)
rectangle = { ...rectangle, width: 80, left: 20 }
assert.equal(art.width, 80)
assert.equal(art.x, 24)
assert.deepEqual(Object.getOwnPropertyNames(art), ['template', 'rect', 'bottom', 'height', 'left', 'right', 'top', 'width', 'x', 'y'])
const descriptor = Object.getOwnPropertyDescriptor(art, 'width')
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
assert.equal(descriptor.set, undefined)
})
+64
View File
@@ -0,0 +1,64 @@
import type { LayoutHost, PauseHost, PlayHost } from '../../packages/artplayer/src/media/hosts'
import type { CanvasMedia, MediaSurface, NativeMedia } from '../../packages/artplayer/src/media/types'
import durationMix from '../../packages/artplayer/src/player/durationMix'
import pauseMix from '../../packages/artplayer/src/player/pauseMix'
import playingMix from '../../packages/artplayer/src/player/playingMix'
import playMix from '../../packages/artplayer/src/player/playMix'
import rectMix from '../../packages/artplayer/src/player/rectMix'
const native: NativeMedia = document.createElement('video')
const canvas: CanvasMedia = Object.assign(document.createElement('canvas'), {
src: null,
currentSrc: null,
currentTime: 0,
duration: 10,
paused: true,
ended: false,
readyState: 0,
videoWidth: 320,
videoHeight: 180,
buffered: { length: 0, start: (_index: number) => 0, end: (_index: number) => 0 },
volume: 1,
muted: false,
playbackRate: 1,
play: () => 'sync-result',
pause: () => 42,
load: () => {},
})
function useSurface(media: MediaSurface) {
// @ts-expect-error Proxy capability is optional, including on older native browsers.
media.requestPictureInPicture()
media.requestPictureInPicture?.()
void media.textTracks?.[0]?.activeCues
// @ts-expect-error A media surface cannot be passed off as a complete native video.
const video: HTMLVideoElement = media
void video
}
useSurface(native)
useSurface(canvas)
// @ts-expect-error A raw canvas lacks the proxy's media implementation.
const incomplete: MediaSurface = document.createElement('canvas')
void incomplete
const shared = { i18n: { get: (key: string) => key }, notice: { show: '' }, emit: (_name: 'play' | 'pause') => {} }
const playHost: PlayHost<{ play: () => string }> = { ...shared, template: { $video: { play: () => 'sync-result' } }, option: { mutex: false }, constructor: { instances: [] } }
playMix(playHost)
const result: Promise<string> = playHost.play()
const pauseHost: PauseHost<{ pause: () => number }> = { ...shared, template: { $video: { pause: () => 42 } } }
pauseMix(pauseHost)
const paused: number = pauseHost.pause()
// @ts-expect-error Pausing is not forced to return a Promise.
const wrong: Promise<void> = pauseHost.pause()
void [result, paused, wrong]
const mediaHost = { template: { $video: canvas } }
playingMix(mediaHost)
durationMix(mediaHost)
const state: boolean = mediaHost.playing
const duration: number = mediaHost.duration
const layout: LayoutHost = { template: { $player: document.createElement('div') } }
rectMix(layout)
const width: number = layout.width
// @ts-expect-error Read-only getter properties cannot be assigned.
layout.width = 2
void [state, duration, width]