mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-12 15:26:16 -08:00
refactor(mediabunny): [PKG-MB-07] own HLS selection intent and menu lifecycle
This commit is contained in:
1 parent
ccee9157a7
commit
8a2b56e829
27 files changed
+4217
-680
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { mbCandidate } from '../helpers/mediabunny.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
|
||||
const media = new Map(Object.entries(manifest.files).map(([name, expected]) => {
|
||||
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
|
||||
assert.equal(hash(bytes), expected.sha256)
|
||||
return [name, bytes]
|
||||
}))
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const scenario of ['menus', 'topology', 'stale-source', 'stale-destroy', 'latest']) {
|
||||
test(`MediaBunny ${implementation.name}: ${core} core native HLS ${scenario}`, async ({ page, browserName }, testInfo) => {
|
||||
await page.route('**/mb-ui-hls/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const body = media.get(name)
|
||||
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
|
||||
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
|
||||
await page.evaluate(() => {
|
||||
window.mbEvents = []
|
||||
window.mbErrors = []
|
||||
window.mbWrites = []
|
||||
window.mbOption = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }
|
||||
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/mb-ui-hls/master.m3u8', muted: true, setting: true, proxy: window.mbFactory(window.mbOption) })
|
||||
window.mbCanvas = window.art.video
|
||||
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'error'])
|
||||
window.art.on(`video:${event}`, () => window.mbEvents.push(event))
|
||||
document.querySelector('#play').onclick = () => window.art.play().catch(error => window.mbErrors.push(error.message))
|
||||
})
|
||||
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder, coreVersion: window.Artplayer.version }))
|
||||
let outcome = 'unverified'
|
||||
let result
|
||||
try {
|
||||
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
|
||||
expect(browserName).toBe('webkit')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
|
||||
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
|
||||
expect(await page.locator('.art-control-mediabunny-quality').count()).toBe(0)
|
||||
outcome = 'unsupported-capability-control'
|
||||
return
|
||||
}
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
|
||||
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('English')
|
||||
await page.evaluate(() => {
|
||||
for (const name of ['controls', 'setting']) {
|
||||
const registry = window.art[name]
|
||||
const update = registry.update.bind(registry)
|
||||
registry.update = (option) => {
|
||||
if (option.name?.startsWith('mediabunny-'))
|
||||
window.mbWrites.push({ surface: name, name: option.name })
|
||||
return update(option)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (scenario === 'menus') {
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2)
|
||||
await page.evaluate(() => {
|
||||
window.art.pause()
|
||||
window.mbEvents.length = 0
|
||||
})
|
||||
const quality = page.locator('.art-control-mediabunny-quality')
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentLevel?.height))).toBe(90)
|
||||
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('90P')
|
||||
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 1, loadeddata: 1, canplay: 1, canplaythrough: 1, seeked: 1 })
|
||||
await page.evaluate(() => {
|
||||
const menu = window.art.setting.find('mediabunny-audio')
|
||||
window.art.setting.show = true
|
||||
window.art.setting.render(menu.selector)
|
||||
menu.selector.find(item => item.html === 'French').$item.click()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentAudio?.lang))).toBe('fr')
|
||||
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('French')
|
||||
await expect.poll(() => page.evaluate(() => window.mbEvents.filter(event => event === 'seeked').length)).toBe(2)
|
||||
await page.evaluate(() => {
|
||||
window.art.setting.show = false
|
||||
})
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.videoMode))).toBe('auto')
|
||||
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
|
||||
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 3, loadeddata: 3, canplay: 3, canplaythrough: 3, seeked: 3 })
|
||||
result = await page.evaluate(async () => {
|
||||
const state = await window.mbCanvas.getM3u8State()
|
||||
const quality = window.art.controls.cache.get('mediabunny-quality').option
|
||||
const audio = window.art.setting.find('mediabunny-audio')
|
||||
return { currentLevel: state.currentLevel.id, qualitySelected: quality.selector.filter(item => item.default).map(item => item.value), currentAudio: state.currentAudio.id, audioSelected: audio.selector.filter(item => item.default).map(item => item.value), paired: state.currentLevel.track.canBePairedWith(state.currentAudio.track), errors: window.mbErrors }
|
||||
})
|
||||
expect(result.qualitySelected).toEqual([result.currentLevel])
|
||||
expect(result.audioSelected).toEqual([result.currentAudio])
|
||||
expect(result.paired).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
}
|
||||
else if (scenario === 'topology') {
|
||||
await page.evaluate(() => {
|
||||
window.mbOption.m3u8.quality.control = false
|
||||
window.mbOption.m3u8.audio.control = false
|
||||
window.art.emit('restart')
|
||||
})
|
||||
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(0)
|
||||
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') && window.art.setting.find('mediabunny-audio')))).toBe(true)
|
||||
await page.evaluate(async () => {
|
||||
window.mbOption.m3u8.quality.control = true
|
||||
window.mbOption.m3u8.audio.control = true
|
||||
await window.mbCanvas.engine.load('/mb-ui-hls/video-only.m3u8')
|
||||
})
|
||||
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(1)
|
||||
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-audio')))).toBe(false)
|
||||
await page.evaluate(() => window.mbCanvas.engine.load('/test/pattern.mp4'))
|
||||
await expect(page.locator('[class*="art-control-mediabunny-"]')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') || window.art.setting.find('mediabunny-audio')))).toBe(false)
|
||||
result = { settingsSurviveControlDisable: true, audioRemovedForVideoOnly: true, allRemovedForMp4: true }
|
||||
}
|
||||
else if (scenario.startsWith('stale-')) {
|
||||
await page.evaluate(() => {
|
||||
const engine = window.mbCanvas.engine
|
||||
const getState = engine.getHlsState.bind(engine)
|
||||
let first = true
|
||||
engine.getHlsState = async () => {
|
||||
const state = await getState()
|
||||
if (first) {
|
||||
first = false
|
||||
return new Promise((resolve) => {
|
||||
window.mbRelease = () => resolve(state)
|
||||
})
|
||||
}
|
||||
return state
|
||||
}
|
||||
window.art.emit('restart')
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
|
||||
result = await page.evaluate(async (scenario) => {
|
||||
if (scenario === 'stale-source')
|
||||
await window.mbCanvas.engine.load('/test/pattern.mp4')
|
||||
else window.art.destroy(false)
|
||||
const before = window.mbWrites.length
|
||||
window.mbRelease()
|
||||
await window.mbFlush()
|
||||
return { before, after: window.mbWrites.length, menus: document.querySelectorAll('[class*="art-control-mediabunny-"]').length }
|
||||
}, scenario)
|
||||
expect(result.after).toBe(result.before)
|
||||
expect(result.menus).toBe(0)
|
||||
}
|
||||
else {
|
||||
await page.evaluate(async () => {
|
||||
const engine = window.mbCanvas.engine
|
||||
const input = engine.input
|
||||
const getTracks = input.getVideoTracks.bind(input)
|
||||
const tracks = await getTracks()
|
||||
window.mbDesired = tracks.find(track => track.id !== engine.media.videoTrack.id).id
|
||||
let calls = 0
|
||||
input.getVideoTracks = () => {
|
||||
calls++
|
||||
if (calls === 1)
|
||||
return new Promise((resolve) => { window.mbReleaseOlder = () => resolve(tracks) })
|
||||
if (calls === 2)
|
||||
return new Promise((resolve) => { window.mbReleaseNewer = () => resolve(tracks) })
|
||||
return getTracks()
|
||||
}
|
||||
window.mbOlder = window.mbCanvas.switchM3u8Quality(engine.media.videoTrack.id)
|
||||
window.mbNewer = window.mbCanvas.switchM3u8Quality(window.mbDesired)
|
||||
window.mbReleaseOlder()
|
||||
await window.mbOlder
|
||||
window.mbReleaseNewer()
|
||||
await window.mbNewer
|
||||
})
|
||||
result = await page.evaluate(async () => {
|
||||
const state = await window.mbCanvas.getM3u8State()
|
||||
return { desired: window.mbDesired, selected: state.currentLevel.id, ready: window.mbCanvas.readyState, events: window.mbEvents }
|
||||
})
|
||||
expect(result.selected).toBe(result.desired)
|
||||
expect(result.ready).toBe(4)
|
||||
}
|
||||
outcome = 'native-hls-ui-and-selection-observed'
|
||||
}
|
||||
finally {
|
||||
const state = await page.evaluate(() => {
|
||||
window.mbRelease?.()
|
||||
window.mbReleaseOlder?.()
|
||||
window.mbReleaseNewer?.()
|
||||
window.mbContext = window.mbCanvas.engine.audio.audioContext
|
||||
window.art.destroy(false)
|
||||
return { events: window.mbEvents, errors: window.mbErrors, writes: window.mbWrites, error: window.mbCanvas.error, destroyed: window.mbCanvas.engine.destroyed }
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
|
||||
await testInfo.attach('mediabunny-hls-ui', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, scenario, capabilities, outcome, result, state, scope: 'Actual old/candidate core, SDK HLS parsing and native decoding; rendered control/settings clicks, topology and controlled state/query delays. Not installed-package or long-run/device acceptance.' }) })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Actual proxy, controlled SDK/UI interleavings and immutable old artifact controls.
|
||||
import test from 'node:test'
|
||||
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
function deferred() {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function state(selected = 1) {
|
||||
const levels = [
|
||||
{ id: 1, index: 0, name: '720P', height: 720, bitrate: 2000 },
|
||||
{ id: 2, index: 1, name: '1080P', height: 1080, bitrate: 4000 },
|
||||
]
|
||||
const audios = [
|
||||
{ id: 3, index: 0, name: 'English', lang: 'en', language: 'en', bitrate: 128 },
|
||||
{ id: 4, index: 1, name: 'French', lang: 'fr', language: 'fr', bitrate: 128 },
|
||||
]
|
||||
return { levels, audios, currentLevel: levels.find(track => track.id === selected), currentAudio: audios[0], videoMode: 'auto', audioMode: 'auto' }
|
||||
}
|
||||
function environment(option = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }) {
|
||||
const env = mbEnvironment(implementation)
|
||||
const calls = []
|
||||
const warnings = []
|
||||
const notices = []
|
||||
const controls = new Map()
|
||||
const settings = new Map()
|
||||
env.globals.console = { ...console, warn: (...args) => warnings.push(args), error() {} }
|
||||
const registry = (cache, kind) => ({
|
||||
cache,
|
||||
find: name => cache.get(name),
|
||||
update(menu) {
|
||||
calls.push([kind, 'update', menu.name])
|
||||
cache.set(menu.name, menu)
|
||||
},
|
||||
remove(name) {
|
||||
calls.push([kind, 'remove', name])
|
||||
cache.delete(name)
|
||||
},
|
||||
})
|
||||
env.art.controls = registry(controls, 'control')
|
||||
env.art.setting = registry(settings, 'setting')
|
||||
env.art.notice = {
|
||||
get show() { return notices.at(-1) },
|
||||
set show(value) { notices.push(value) },
|
||||
}
|
||||
env.art.off = (name, callback) => {
|
||||
env.handlers.set(name, (env.handlers.get(name) || []).filter(item => item !== callback))
|
||||
}
|
||||
const canvas = env.factory(option)(env.art)
|
||||
canvas.engine.getHlsState = async () => state()
|
||||
const refresh = async () => {
|
||||
await Promise.all((env.handlers.get('video:loadedmetadata') || []).map(callback => callback()))
|
||||
}
|
||||
return { ...env, canvas, option, controls, settings, calls, warnings, notices, refresh }
|
||||
}
|
||||
|
||||
test('MediaBunny HLS retains normal menu names, ordering, defaults and notices', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const quality = env.controls.get('mediabunny-quality')
|
||||
assert.equal(quality.html, '720P')
|
||||
assert.deepEqual(Array.from(quality.selector, item => [item.value, item.default]), [[2, false], [1, true], ['auto', false]])
|
||||
assert.equal(env.settings.get('mediabunny-quality').width, 200)
|
||||
assert.equal(env.settings.get('mediabunny-audio').html, 'Audio')
|
||||
const selected = []
|
||||
env.canvas.engine.selectHlsQuality = async value => selected.push(value)
|
||||
assert.equal(await quality.onSelect(quality.selector[0]), '1080P')
|
||||
assert.deepEqual(selected, [2])
|
||||
assert.deepEqual(env.notices, ['Quality: 1080P'])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny disabled HLS integration installs no menu callbacks', () => {
|
||||
const env = environment({})
|
||||
assert.equal(env.handlers.get('restart'), undefined)
|
||||
assert.equal(env.controls.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS clears all menus for a settled non-HLS source', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
env.canvas.engine.getHlsState = async () => null
|
||||
await env.refresh()
|
||||
assert.equal(env.controls.size + env.settings.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const surface of ['control', 'setting']) {
|
||||
test(`MediaBunny HLS removes disabled ${surface} while preserving the other surface`, async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
env.option.m3u8.quality[surface] = false
|
||||
env.option.m3u8.audio[surface] = false
|
||||
await env.refresh()
|
||||
assert.equal(env[surface === 'control' ? 'controls' : 'settings'].size, 0)
|
||||
assert.equal(env[surface === 'control' ? 'settings' : 'controls'].size, 2)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const ending of ['newer', 'source', 'destroy']) {
|
||||
test(`MediaBunny HLS pending state cannot paint after ${ending}`, async () => {
|
||||
const env = environment()
|
||||
const pending = deferred()
|
||||
env.canvas.engine.getHlsState = () => pending.promise
|
||||
const refresh = env.refresh()
|
||||
if (ending === 'newer') {
|
||||
env.canvas.engine.getHlsState = async () => state(2)
|
||||
await env.refresh()
|
||||
}
|
||||
else if (ending === 'source') {
|
||||
env.canvas.engine.loadSeq++
|
||||
}
|
||||
else {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
pending.resolve(state(1))
|
||||
await refresh
|
||||
if (ending === 'newer')
|
||||
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
|
||||
else assert.equal(env.controls.size + env.settings.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const event of ['video:loadstart', 'video:error', 'destroy']) {
|
||||
test(`MediaBunny HLS ${event} clears rendered menus immediately`, async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
env.art.emit(event)
|
||||
assert.equal(env.controls.size + env.settings.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const ending of ['menu', 'source', 'destroy']) {
|
||||
test(`MediaBunny stale HLS selection callback is inert after ${ending}`, async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
const calls = []
|
||||
env.canvas.engine.selectHlsQuality = async value => calls.push(value)
|
||||
if (ending === 'menu')
|
||||
await env.refresh()
|
||||
else if (ending === 'source')
|
||||
env.canvas.engine.loadSeq++
|
||||
else env.art.emit('destroy')
|
||||
assert.equal(await menu.onSelect(menu.selector[0]), '1080P')
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(env.notices, [])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const ending of ['newer', 'source', 'destroy']) {
|
||||
test(`MediaBunny pending HLS selection cannot notify after ${ending}`, async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
const pending = deferred()
|
||||
env.canvas.engine.selectHlsQuality = () => pending.promise
|
||||
const selecting = menu.onSelect(menu.selector[0])
|
||||
if (ending === 'newer') {
|
||||
env.canvas.engine.selectHlsQuality = async () => {}
|
||||
await menu.onSelect(menu.selector[1])
|
||||
}
|
||||
else if (ending === 'source') {
|
||||
env.canvas.engine.loadSeq++
|
||||
}
|
||||
else {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
const notices = env.notices.slice()
|
||||
pending.resolve()
|
||||
await selecting
|
||||
assert.deepEqual(env.notices, notices)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny background HLS refresh failure is observed without an unhandled rejection', async () => {
|
||||
const env = environment()
|
||||
const failure = new Error('state unavailable')
|
||||
env.canvas.engine.getHlsState = async () => {
|
||||
throw failure
|
||||
}
|
||||
await env.refresh()
|
||||
assert.equal(env.warnings.length, 1)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny current HLS selection failure retains its Promise rejection without success notice', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
const failure = new Error('selection unavailable')
|
||||
env.canvas.engine.selectHlsQuality = async () => {
|
||||
throw failure
|
||||
}
|
||||
await assert.rejects(menu.onSelect(menu.selector[0]), error => error === failure)
|
||||
assert.deepEqual(env.notices, [])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny same-label levels retain the actually selected ID and one highlight', async () => {
|
||||
const env = environment()
|
||||
env.option.m3u8.quality.getName = () => 'Same'
|
||||
env.canvas.engine.getHlsState = async () => state(2)
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
assert.deepEqual(Array.from(menu.selector, item => [item.value, item.default]), [[2, true], ['auto', false]])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS single audio topology removes only audio menus', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const single = state()
|
||||
single.audios.length = 1
|
||||
env.canvas.engine.getHlsState = async () => single
|
||||
await env.refresh()
|
||||
assert.deepEqual([...env.controls.keys()], ['mediabunny-quality'])
|
||||
assert.deepEqual([...env.settings.keys()], ['mediabunny-quality'])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS name callback destroy cannot recreate controls or settings', async () => {
|
||||
const env = environment()
|
||||
env.option.m3u8.quality.getName = (level) => {
|
||||
env.art.emit('destroy')
|
||||
return level.name
|
||||
}
|
||||
await env.refresh()
|
||||
assert.equal(env.controls.size + env.settings.size, 0)
|
||||
})
|
||||
|
||||
test('MediaBunny HLS control update reentry cannot write later settings or audio menus', async () => {
|
||||
const env = environment()
|
||||
const update = env.art.controls.update
|
||||
env.art.controls.update = (menu) => {
|
||||
update(menu)
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
await env.refresh()
|
||||
assert.equal(env.controls.size + env.settings.size, 0)
|
||||
})
|
||||
|
||||
test('MediaBunny HLS destruction removes its refresh listeners and preserves proxy teardown', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const metadataBefore = env.handlers.get('video:loadedmetadata').length
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.canvas.engine.destroyed, true)
|
||||
assert.equal(env.art.mediabunny, undefined)
|
||||
assert.equal(env.handlers.get('video:loadedmetadata').length, metadataBefore - 1)
|
||||
assert.equal(env.handlers.get('restart')?.length || 0, 0)
|
||||
})
|
||||
|
||||
function selectionEnvironment() {
|
||||
const env = environment({})
|
||||
const pairs = []
|
||||
const videos = [1, 2].map(id => ({ id, canBePairedWith: () => true }))
|
||||
const audios = [3, 4].map(id => ({ id, canBePairedWith: () => true }))
|
||||
env.canvas.engine.input = { getVideoTracks: async () => videos, getAudioTracks: async () => audios }
|
||||
env.canvas.engine.media = { isHls: true, videoTrack: videos[0], audioTrack: audios[0], videoMode: 'auto', audioMode: 'auto' }
|
||||
env.canvas.engine.replaceTracks = async (tracks) => {
|
||||
pairs.push(tracks)
|
||||
env.canvas.engine.media = { ...env.canvas.engine.media, ...tracks }
|
||||
}
|
||||
return { ...env, engine: env.canvas.engine, videos, audios, pairs }
|
||||
}
|
||||
|
||||
for (const [method, query, field, ids] of [['selectHlsQuality', 'getVideoTracks', 'videoTrack', [1, 2]], ['selectHlsAudio', 'getAudioTracks', 'audioTrack', [3, 4]]]) {
|
||||
test(`MediaBunny ${method} honors the latest request when the older query resolves first`, async () => {
|
||||
const env = selectionEnvironment()
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const values = await env.engine.input[query]()
|
||||
let queries = 0
|
||||
env.engine.input[query] = () => (++queries === 1 ? first.promise : second.promise)
|
||||
const older = env.engine[method](ids[0])
|
||||
const newer = env.engine[method](ids[1])
|
||||
first.resolve(values)
|
||||
await flush()
|
||||
second.resolve(values)
|
||||
await Promise.all([older, newer])
|
||||
assert.deepEqual(env.pairs.map(pair => pair[field].id), [ids[1]])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny later audio intent supersedes an unresolved quality query', async () => {
|
||||
const env = selectionEnvironment()
|
||||
const pending = deferred()
|
||||
env.engine.input.getVideoTracks = () => pending.promise
|
||||
const older = env.engine.selectHlsQuality(2)
|
||||
const newer = env.engine.selectHlsAudio(4)
|
||||
pending.resolve(env.videos)
|
||||
await Promise.all([older, newer])
|
||||
assert.equal(env.pairs.length, 1)
|
||||
assert.equal(env.pairs[0].audioTrack.id, 4)
|
||||
assert.equal(env.pairs[0].videoTrack.id, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS selection survives its own loadedmetadata refresh without a duplicate notice', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
env.canvas.engine.selectHlsQuality = async () => {
|
||||
env.canvas.engine.getHlsState = async () => state(2)
|
||||
await env.refresh()
|
||||
}
|
||||
await menu.onSelect(menu.selector[0])
|
||||
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
|
||||
assert.deepEqual(env.notices, ['Quality: 1080P'])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS selection invalidates an older state read before it can paint', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
const read = deferred()
|
||||
const switchTrack = deferred()
|
||||
env.canvas.engine.getHlsState = () => read.promise
|
||||
const updating = env.refresh()
|
||||
env.canvas.engine.selectHlsQuality = () => switchTrack.promise
|
||||
const selecting = menu.onSelect(menu.selector[0])
|
||||
const writes = env.calls.length
|
||||
read.resolve(state())
|
||||
await updating
|
||||
assert.equal(env.calls.length, writes)
|
||||
env.canvas.engine.getHlsState = async () => state(2)
|
||||
switchTrack.resolve()
|
||||
await selecting
|
||||
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny failed HLS selection restores highlight from actual tracks before rejecting', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
for (const item of menu.selector) item.default = item.value === 2
|
||||
const failure = new Error('track failed')
|
||||
env.canvas.engine.selectHlsQuality = async () => {
|
||||
throw failure
|
||||
}
|
||||
await assert.rejects(menu.onSelect(menu.selector[0]), error => error === failure)
|
||||
assert.deepEqual(Array.from(env.controls.get('mediabunny-quality').selector).filter(item => item.default).map(item => item.value), [1])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny stale HLS selection rejection is observed without disturbing the current source', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
const pending = deferred()
|
||||
env.canvas.engine.selectHlsQuality = () => pending.promise
|
||||
const selecting = menu.onSelect(menu.selector[0])
|
||||
env.canvas.engine.loadSeq++
|
||||
pending.reject(new Error('old source closed'))
|
||||
await selecting
|
||||
assert.equal(env.notices.length + env.warnings.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS clears all owned surfaces even when one registry cleanup fails', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const failure = new Error('control cleanup failed')
|
||||
const remove = env.art.controls.remove
|
||||
env.art.controls.remove = (name) => {
|
||||
remove(name)
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.controls.size + env.settings.size, 0)
|
||||
assert.equal(env.canvas.engine.destroyed, true)
|
||||
assert.equal(env.handlers.get('restart').length, 0)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
})
|
||||
|
||||
test('MediaBunny HLS failing listener removal still attempts remaining removals and proxy teardown', async () => {
|
||||
const env = environment()
|
||||
const off = env.art.off
|
||||
const removed = []
|
||||
env.art.off = (name, callback) => {
|
||||
removed.push(name)
|
||||
off(name, callback)
|
||||
if (name === 'restart')
|
||||
throw new Error('off failed')
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
assert.equal(removed.length, 5)
|
||||
assert.equal(env.canvas.engine.destroyed, true)
|
||||
assert.equal(env.warnings.length, 1)
|
||||
})
|
||||
|
||||
test('MediaBunny runtime HLS disable clears menus on the next refresh', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
env.option.m3u8 = undefined
|
||||
await env.refresh()
|
||||
assert.equal(env.controls.size + env.settings.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny HLS custom labels retain Auto text while highlighting the actual selected track', async () => {
|
||||
const env = environment()
|
||||
env.option.m3u8.audio = { control: true, setting: true, title: 'Language', auto: 'Automatic', getName: track => track.lang }
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-audio')
|
||||
assert.equal(menu.html, 'en')
|
||||
assert.deepEqual(Array.from(menu.selector, item => [item.html, item.default]), [['en', true], ['fr', false], ['Automatic', false]])
|
||||
const values = []
|
||||
env.canvas.engine.selectHlsAudio = async value => values.push(value)
|
||||
await menu.onSelect(menu.selector[2])
|
||||
assert.deepEqual(values, ['auto'])
|
||||
assert.deepEqual(env.notices, ['Language: Automatic'])
|
||||
assert.equal(env.controls.get('mediabunny-audio').html, 'en')
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const kind of ['quality', 'audio']) {
|
||||
test(`MediaBunny ${kind} pairing fallback preserves a coherent pair and resets only the counterpart mode`, async () => {
|
||||
const env = selectionEnvironment()
|
||||
env.engine.media.videoMode = 'manual'
|
||||
env.engine.media.audioMode = 'manual'
|
||||
if (kind === 'quality') {
|
||||
env.videos[1].canBePairedWith = () => false
|
||||
env.videos[1].getPrimaryPairableAudioTrack = async () => env.audios[1]
|
||||
await env.engine.selectHlsQuality(2)
|
||||
assert.deepEqual([env.pairs[0].videoTrack.id, env.pairs[0].audioTrack.id, env.pairs[0].videoMode, env.pairs[0].audioMode], [2, 4, 'manual', 'auto'])
|
||||
}
|
||||
else {
|
||||
env.audios[1].canBePairedWith = () => false
|
||||
env.audios[1].getPrimaryPairableVideoTrack = async () => env.videos[1]
|
||||
await env.engine.selectHlsAudio(4)
|
||||
assert.deepEqual([env.pairs[0].videoTrack.id, env.pairs[0].audioTrack.id, env.pairs[0].videoMode, env.pairs[0].audioMode], [2, 4, 'auto', 'manual'])
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny latest selection still applies when an earlier replacement changes media during its query', async () => {
|
||||
const env = selectionEnvironment()
|
||||
const replacing = deferred()
|
||||
const query = deferred()
|
||||
env.engine.replaceTracks = async (tracks) => {
|
||||
env.pairs.push(tracks)
|
||||
if (env.pairs.length === 1)
|
||||
await replacing.promise
|
||||
env.engine.media = { ...env.engine.media, ...tracks }
|
||||
}
|
||||
const older = env.engine.selectHlsQuality(1)
|
||||
await flush()
|
||||
env.engine.input.getVideoTracks = () => query.promise
|
||||
const latest = env.engine.selectHlsQuality(2)
|
||||
replacing.resolve()
|
||||
await older
|
||||
query.resolve(env.videos)
|
||||
await latest
|
||||
assert.equal(env.engine.media.videoTrack.id, 2)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny obsolete selection query rejection cannot replace the newer selection result', async () => {
|
||||
const env = selectionEnvironment()
|
||||
const query = deferred()
|
||||
env.engine.input.getVideoTracks = () => query.promise
|
||||
const older = env.engine.selectHlsQuality(2)
|
||||
await env.engine.selectHlsAudio(4)
|
||||
query.reject(new Error('obsolete query'))
|
||||
await older
|
||||
assert.equal(env.engine.media.audioTrack.id, 4)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny Auto audio resolves against the current video when media changes during pairing', async () => {
|
||||
const env = selectionEnvironment()
|
||||
const pending = deferred()
|
||||
env.videos[0].getPrimaryPairableAudioTrack = () => pending.promise
|
||||
env.videos[1].getPrimaryPairableAudioTrack = async () => env.audios[1]
|
||||
const selecting = env.engine.selectHlsAudio('auto')
|
||||
await flush()
|
||||
env.engine.media = { ...env.engine.media, videoTrack: env.videos[1] }
|
||||
pending.resolve(env.audios[0])
|
||||
await selecting
|
||||
assert.equal(env.pairs[0].videoTrack.id, 2)
|
||||
assert.equal(env.pairs[0].audioTrack.id, 4)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const ending of ['resolve', 'reject']) {
|
||||
test(`MediaBunny state ${ending} is discarded when the source generation changes before media replacement`, async () => {
|
||||
const env = environment({})
|
||||
delete env.canvas.engine.getHlsState
|
||||
const query = deferred()
|
||||
env.canvas.engine.media = { isHls: true, input: { getVideoTracks: () => query.promise }, videoTrack: null }
|
||||
const reading = env.canvas.engine.getHlsState()
|
||||
env.canvas.engine.loadSeq++
|
||||
if (ending === 'resolve')
|
||||
query.resolve([])
|
||||
else query.reject(new Error('obsolete state'))
|
||||
assert.equal(await reading, null)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny selection failure superseded during UI recovery does not reject after the newer intent', async () => {
|
||||
const env = environment()
|
||||
await env.refresh()
|
||||
const menu = env.controls.get('mediabunny-quality')
|
||||
const recovery = deferred()
|
||||
env.canvas.engine.getHlsState = () => recovery.promise
|
||||
env.canvas.engine.selectHlsQuality = async () => {
|
||||
throw new Error('first selection failed')
|
||||
}
|
||||
const older = menu.onSelect(menu.selector[0]).then(value => ({ value }), error => ({ error }))
|
||||
await flush()
|
||||
env.canvas.engine.getHlsState = async () => state()
|
||||
env.canvas.engine.selectHlsQuality = async () => {}
|
||||
await menu.onSelect(menu.selector[1])
|
||||
recovery.resolve(state())
|
||||
assert.equal((await older).error, undefined)
|
||||
assert.deepEqual(env.notices, ['Quality: 720P'])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
Reference in new issue
Block a user