mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-19 03:11:20 -08:00
refactor(core): CORE-10 type playback properties and add compatible controls view
This commit is contained in:
1 parent
44f4e083a9
commit
5e31274f1d
48 files changed
+3846
-248
No files matched your search
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user