refactor(core): CORE-10 type playback properties and add compatible controls view

This commit is contained in:
Harvey Zhao committed 2026-09-11 02:51:55 +08:00
1 parent 44f4e083a9
commit 5e31274f1d
48 files changed
+3846 -248

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: real media preserves timeline, volume, rate and property descriptors`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?properties=media'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(() => {
const { art } = window
const seeks = []
const muted = []
art.on('seek', (...args) => seeks.push(args))
art.on('muted', value => muted.push(value))
art.seek = '2.5 seconds'
const seekNotice = art.template.$noticeInner.textContent
art.forward = 1
art.backward = 0.5
art.volume = 0.42
const volumeNotice = art.template.$noticeInner.textContent
art.muted = 'truthy'
art.playbackRate = 1.5
const rateNotice = art.template.$noticeInner.textContent
art.playbackRate = 0
const resetNotice = art.template.$noticeInner.textContent
const descriptors = Object.fromEntries(['currentTime', 'seek', 'forward', 'backward', 'volume', 'muted', 'playbackRate', 'loaded', 'loadedTime', 'played', 'state'].map((name) => {
const descriptor = Object.getOwnPropertyDescriptor(art, name)
return [name, { getter: typeof descriptor.get === 'function', setter: typeof descriptor.set === 'function', enumerable: descriptor.enumerable, configurable: descriptor.configurable }]
}))
const unreadable = Object.fromEntries(['seek', 'forward', 'backward', 'switch', 'quality'].map(name => [name, art[name] === undefined]))
const buffered = Array.from({ length: art.video.buffered.length }, (_, index) => art.video.buffered.end(index))
const result = { seeks, muted, time: art.currentTime, volume: art.volume, mediaMuted: art.video.muted, rate: art.playbackRate, seekNotice, volumeNotice, rateNotice, resetNotice, descriptors, unreadable, played: art.played, duration: art.duration, buffered, loadedTime: art.loadedTime, loaded: art.loaded, error: art.video.error?.code || 0 }
art.destroy()
return result
})
expect(result.seeks).toEqual([[2.5, '2.5 seconds'], [3.5, 3.5], [3, 3]])
expect(result.muted).toEqual(['truthy'])
expect(result.time).toBeCloseTo(3, 2)
expect(result.volume).toBeCloseTo(0.42, 6)
expect(result.mediaMuted).toBe(true)
expect(result.rate).toBe(1)
expect(result.seekNotice).toBe('00:02 / 00:08')
expect(result.volumeNotice).toBe('Volume: 42')
expect(result.rateNotice).toBe('Rate: 1.5x')
expect(result.resetNotice).toBe('Rate: Normal')
expect(result.played).toBeCloseTo(3 / result.duration, 6)
// WebKit may empty its buffered ranges during seek; compare the actual media surface.
expect(result.loadedTime).toBe(result.buffered.at(-1) || 0)
expect(result.loaded).toBeCloseTo(result.loadedTime / result.duration, 6)
expect(result.error).toBe(0)
expect(Object.values(result.unreadable)).toEqual([true, true, true, true, true])
for (const [name, descriptor] of Object.entries(result.descriptors)) {
expect(descriptor).toEqual({
getter: !['seek', 'forward', 'backward'].includes(name),
setter: !['loaded', 'loadedTime', 'played'].includes(name),
enumerable: false,
configurable: false,
})
}
})
test(`${core}: invalid time inputs and rate no-op retain native behavior`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?properties=invalid'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(() => {
const { art } = window
art.currentTime = 2
const times = []
for (const input of [Number.NaN, undefined, null, '', 'invalid']) {
art.currentTime = input
times.push(art.currentTime)
}
art.notice.show = 'keep'
art.playbackRate = 1
art.playbackRate = Number.NaN
const result = { times, rate: art.playbackRate, notice: art.template.$noticeInner.textContent, noRuntimeType: window.Artplayer.PlaybackControls === undefined }
art.destroy()
return result
})
expect(result).toEqual({ times: [2, 2, 2, 2, 2], rate: 1, notice: 'keep', noRuntimeType: true })
})
}
+175
View File
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const names = ['currentTimeMix', 'durationMix', 'seekMix', 'volumeMix', 'playbackRateMix', 'playedMix', 'loadedMix', 'stateMix', 'toggleMix']
const mixins = await loadModules(Object.fromEntries(names.map(name => [name, `packages/artplayer/src/player/${name}`])))
function createArt() {
const stored = []
const emitted = []
const video = { currentTime: 0, duration: 100, volume: 0.5, muted: false, playbackRate: 1, buffered: { length: 0, end() {
throw new Error('empty')
} } }
const art = {
template: { $video: video },
notice: { show: '' },
i18n: { get: key => key },
storage: { set: (...args) => stored.push(args) },
emit: (...args) => emitted.push(args),
mini: false,
pip: false,
fullscreen: false,
fullscreenWeb: false,
}
for (const name of names.filter(name => name !== 'toggleMix'))
mixins[name](art)
return { art, video, stored, emitted }
}
test('currentTime keeps parseFloat coercion, clamps and ignores NaN', () => {
const { art, video } = createArt()
for (const [input, expected] of [['12.5 seconds', 12.5], [-2, 0], [200, 100], [Infinity, 100], [-Infinity, 0]]) {
art.currentTime = input
assert.equal(video.currentTime, expected)
}
art.currentTime = 7
for (const input of [Number.NaN, undefined, null, '', 'invalid']) {
art.currentTime = input
assert.equal(art.currentTime, 7)
}
let coerced = 0
art.currentTime = { toString() {
coerced++
return '22s'
} }
assert.equal(art.currentTime, 22)
assert.equal(coerced, 1)
assert.throws(() => art.currentTime = Symbol('time'), TypeError)
})
test('seek preserves original payload, notification order and forward/backward coercion', () => {
const { art, emitted } = createArt()
art.seek = '12.5 seconds'
assert.equal(art.currentTime, 12.5)
assert.equal(art.notice.show, '00:12 / 01:40')
assert.deepEqual(emitted.at(-1), ['seek', 12.5, '12.5 seconds'])
art.currentTime = 10
art.forward = '2'
assert.deepEqual(emitted.at(-1), ['seek', 100, '102'])
art.backward = '2'
assert.deepEqual(emitted.at(-1), ['seek', 98, 98])
for (const name of ['seek', 'forward', 'backward']) {
assert.equal(art[name], undefined)
const descriptor = Object.getOwnPropertyDescriptor(art, name)
assert.equal(descriptor.get, undefined)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
}
})
test('volume uses actual assigned media volume, historical percentage formatting and nonzero persistence', () => {
const { art, video, stored, emitted } = createArt()
art.volume = 2
assert.equal(video.volume, 1)
assert.equal(art.notice.show, 'Volume: 100')
art.volume = -1
assert.equal(video.volume, 0)
assert.deepEqual(stored, [['volume', 1]])
art.volume = 1e-9
assert.equal(art.notice.show, 'Volume: 1')
assert.deepEqual(stored.at(-1), ['volume', 1e-9])
art.muted = 'legacy-truthy'
assert.equal(video.muted, 'legacy-truthy')
assert.deepEqual(emitted.at(-1), ['muted', 'legacy-truthy'])
})
test('playbackRate preserves equal-value no-op, falsy normalization and native errors', () => {
const { art, video } = createArt()
art.notice.show = 'keep'
art.playbackRate = 1
assert.equal(art.notice.show, 'keep')
art.playbackRate = 1.5
assert.equal(art.notice.show, 'Rate: 1.5x')
art.playbackRate = 0
assert.equal(video.playbackRate, 1)
assert.equal(art.notice.show, 'Rate: Normal')
art.notice.show = 'keep again'
art.playbackRate = Number.NaN
assert.equal(art.notice.show, 'keep again')
const error = new Error('native rate rejected')
Object.defineProperty(video, 'playbackRate', { get: () => 1, set() {
throw error
} })
assert.throws(() => art.playbackRate = -1, actual => actual === error)
})
test('progress getters keep raw ratios and the last buffered range', () => {
const { art, video } = createArt()
assert.equal(art.loadedTime, 0)
video.buffered = { length: 2, end: index => [3, 40][index] }
assert.equal(art.loadedTime, 40)
assert.equal(art.loaded, 0.4)
art.currentTime = 25
assert.equal(art.played, 0.25)
video.duration = Infinity
assert.equal(art.duration, 0)
assert.equal(art.played, Infinity)
assert.equal(art.loaded, 0)
video.currentTime = 0
video.duration = 0
video.buffered = { length: 0 }
assert(Number.isNaN(art.played))
assert(Number.isNaN(art.loaded))
})
test('state prioritizes mini/pip/fullscreen/web and only disables other states', () => {
const { art } = createArt()
assert.equal(art.state, 'standard')
art.mini = art.pip = art.fullscreen = art.fullscreenWeb = true
assert.equal(art.state, 'mini')
art.state = 'fullscreen'
assert.deepEqual([art.mini, art.pip, art.fullscreen, art.fullscreenWeb], [false, false, true, false])
assert.equal(art.state, 'fullscreen')
art.state = 'unknown'
assert.equal(art.state, 'standard')
art.state = 'pip'
assert.equal(art.pip, false)
})
test('captured media attributes remain captured while duration follows the current template', () => {
const { art, video } = createArt()
art.template.$video = { currentTime: 88, duration: 200, volume: 0.7, muted: true, playbackRate: 2, buffered: { length: 0 } }
assert.equal(art.duration, 200)
art.currentTime = 150
assert.equal(video.currentTime, 150)
assert.equal(art.template.$video.currentTime, 88)
assert.equal(art.volume, 0.5)
assert.equal(art.muted, false)
assert.equal(art.playbackRate, 1)
video.buffered = { length: 1, end: () => 50 }
assert.equal(art.loaded, 0.5)
assert.equal(art.played, 0.75)
})
test('toggle returns exact dynamic branch results and preserves extracted function binding', async () => {
const pending = Promise.resolve(42)
const calls = []
const art = { playing: false, play() {
calls.push(this)
return pending
}, pause() {
calls.push(this)
return 'paused'
} }
mixins.toggleMix(art)
const toggle = art.toggle
assert.equal(toggle(), pending)
art.playing = true
assert.equal(toggle.call({}), 'paused')
assert.deepEqual(calls, [art, art])
assert.equal(await pending, 42)
const descriptor = Object.getOwnPropertyDescriptor(art, 'toggle')
assert.deepEqual([descriptor.writable, descriptor.configurable, descriptor.enumerable], [false, false, false])
})
+6 -1
View File
@@ -13,8 +13,13 @@ const throttled: number = Artplayer.utils.throttle(() => 1, 10)()
const descriptor: void = Artplayer.utils.def({}, 'value', { value: 1 })
const updated: Artplayer['setting'] = art.setting.update({ name: 'entry', html: 'Entry' })
const removed: Artplayer['setting'] = art.setting.remove('entry')
const seekRead: number = art.seek
const forwardRead: number = art.forward
const backwardRead: number = art.backward
const switchRead: string = art.switch
const qualityRead: Artplayer['quality'] = art.quality
art.on('subtitleBeforeUpdate', (cue) => {
const historicalCue: VTTCue = cue
void historicalCue
})
void [requiredUrl, registration, toggle, debounced, throttled, descriptor, updated, removed]
void [requiredUrl, registration, toggle, debounced, throttled, descriptor, updated, removed, seekRead, forwardRead, backwardRead, switchRead, qualityRead]
+33
View File
@@ -0,0 +1,33 @@
import currentTimeMix from '../../packages/artplayer/src/player/currentTimeMix'
import loadedMix from '../../packages/artplayer/src/player/loadedMix'
import seekMix from '../../packages/artplayer/src/player/seekMix'
import toggleMix from '../../packages/artplayer/src/player/toggleMix'
declare const timing: {
template: { $video: Pick<HTMLMediaElement, 'currentTime' | 'duration' | 'buffered'> }
duration: number
notice: { show: string }
emit: (name: 'seek', actual: number, requested: number | string) => void
}
currentTimeMix(timing)
timing.currentTime = '12.5 seconds'
const time: number = timing.currentTime
seekMix(timing)
timing.seek = 12
const noSeekGetter: undefined = timing.seek
const noForwardGetter: undefined = timing.forward
loadedMix(timing)
const loaded: number = timing.loadedTime
void [time, loaded, noSeekGetter, noForwardGetter]
// @ts-expect-error Buffered state is computed, not writable.
timing.loaded = 0.5
// @ts-expect-error Time inputs do not advertise booleans.
timing.currentTime = true
declare const playback: { playing: boolean, play: () => Promise<42>, pause: () => 'paused' }
toggleMix(playback)
const result: Promise<42> | 'paused' = playback.toggle()
void result
// @ts-expect-error A toggle preserves either branch, not only the play branch.
const invalid: Promise<42> = playback.toggle()
void invalid
+19
View File
@@ -0,0 +1,19 @@
import type Artplayer from 'artplayer'
import type { PlaybackControls } from 'artplayer'
import type { PlaybackControls as SharedControls } from 'artplayer/types'
declare const art: Artplayer
const controls: PlaybackControls = art
const shared: SharedControls = controls
const toggled: Promise<void> | void = shared.toggle()
if (toggled)
toggled.catch(() => {})
const played: Promise<void> = controls.play()
const paused: void = controls.pause()
const historical: void = art.toggle()
void [played, paused, historical]
// @ts-expect-error A paused toggle does not return a Promise.
const alwaysAsync: Promise<void> = controls.toggle()
// @ts-expect-error Toggle does not report playback as a boolean.
const boolean: boolean = controls.toggle()
void [alwaysAsync, boolean]