mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-17 10:01:20 -08:00
refactor(hls): [PKG-HLS-03] separate menus and SDK lifecycle
This commit is contained in:
1 parent
33f6d19b77
commit
ba93b98be5
24 files changed
+5849
-382
No files matched your search
@@ -40,7 +40,9 @@ test.beforeAll(async () => {
|
||||
publishedCode = await code(plugin.release, 'package/dist/artplayer-plugin-hls-control.js')
|
||||
// This task exercises source builds. Installed artifacts are a separate HLS-06 gate.
|
||||
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'HLS source fixture cannot masquerade as an installed plugin')
|
||||
sourceCode = await compilePackage('artplayer-plugin-hls-control', 'umd')
|
||||
sourceCode = process.env.ARTPLAYER_HLS_ARTIFACT
|
||||
? fs.readFileSync(process.env.ARTPLAYER_HLS_ARTIFACT, 'utf8')
|
||||
: await compilePackage('artplayer-plugin-hls-control', 'umd')
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
|
||||
media = new Map()
|
||||
for (const [name, expected] of Object.entries(manifest.files)) {
|
||||
@@ -50,7 +52,7 @@ test.beforeAll(async () => {
|
||||
assert.equal(bytes.length, expected.bytes)
|
||||
media.set(name, bytes)
|
||||
}
|
||||
evidence = { sdk: sdk.release, plugin: plugin.release, sourceSHA256: hash(sourceCode), media: manifest, limitations: ['SDK worker disabled', 'local deterministic media', 'source candidate, not installed package', 'no physical device certification'] }
|
||||
evidence = { sdk: sdk.release, plugin: plugin.release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_HLS_ARTIFACT || 'workspace source build', media: manifest, limitations: ['SDK worker disabled', 'local deterministic media', 'not an isolated installed package', 'no physical device certification'] }
|
||||
})
|
||||
|
||||
async function openHls(page, core, plugin, testInfo, manifest = 'master.m3u8') {
|
||||
@@ -211,6 +213,9 @@ test.describe('MSE playback matrix', () => {
|
||||
// Real SDK corroboration of the frozen historical empty-topology defect.
|
||||
await expect(page.locator('.art-control-hls-audio')).toHaveCount(1)
|
||||
}
|
||||
else {
|
||||
await expect(page.locator('.art-control-hls-audio')).toHaveCount(0)
|
||||
}
|
||||
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
expect(await page.evaluate(() => window.destroyedEngines)).toBe(2)
|
||||
@@ -232,5 +237,34 @@ test.describe('MSE playback matrix', () => {
|
||||
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core} core / candidate HLS: SDK events synchronize Auto and audio without replacing menus`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, 'candidate', testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.3)
|
||||
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('Auto')
|
||||
await page.evaluate(() => {
|
||||
window.qualityElement = document.querySelector('.art-control-hls-quality')
|
||||
window.audioElement = document.querySelector('.art-control-hls-audio')
|
||||
window.art.hls.currentLevel = 0
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBe(0)
|
||||
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('90P')
|
||||
await page.evaluate(() => {
|
||||
window.art.hls.currentLevel = -1
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBeGreaterThanOrEqual(0)
|
||||
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('Auto')
|
||||
await page.evaluate(() => {
|
||||
window.art.hls.audioTrack = 1
|
||||
})
|
||||
await expect(page.locator('.art-control-hls-audio .art-selector-value')).toHaveText('French')
|
||||
expect(await page.evaluate(() => ({ quality: window.qualityElement === document.querySelector('.art-control-hls-quality'), audio: window.audioElement === document.querySelector('.art-control-hls-audio') }))).toEqual({ quality: true, audio: true })
|
||||
await page.evaluate(() => window.art.plugins.artplayerPluginHlsControl.update())
|
||||
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('Auto')
|
||||
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import vm from 'node:vm'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { loadPackage } from './load.js'
|
||||
@@ -26,6 +28,21 @@ export async function hlsImplementations() {
|
||||
assert.equal(typeof factory, 'function')
|
||||
implementations.push({ name: `published-${format}`, factory })
|
||||
}
|
||||
for (const filename of (process.env.ARTPLAYER_TEST_HLS || '').split(path.delimiter).filter(Boolean)) {
|
||||
const bytes = fs.readFileSync(filename)
|
||||
let factory
|
||||
if (filename.endsWith('.mjs')) {
|
||||
factory = (await import(`data:text/javascript;base64,${bytes.toString('base64')}`)).default
|
||||
}
|
||||
else {
|
||||
const context = { module: { exports: {} } }
|
||||
context.exports = context.module.exports
|
||||
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
|
||||
factory = context.module.exports
|
||||
}
|
||||
assert.equal(typeof factory, 'function')
|
||||
implementations.push({ name: `artifact-${path.basename(filename)}`, factory })
|
||||
}
|
||||
return implementations
|
||||
}
|
||||
|
||||
@@ -102,3 +119,23 @@ export function hlsHost() {
|
||||
|
||||
export const bothMenus = () => ({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })
|
||||
export const selections = menu => Array.from(menu.selector, item => [item.html, item.value, item.default])
|
||||
|
||||
export function attachHlsEvents(hls) {
|
||||
const listeners = new Map()
|
||||
const Events = Object.fromEntries(['MANIFEST_PARSED', 'LEVELS_UPDATED', 'LEVEL_SWITCHED', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'DESTROYING'].map(key => [key, key]))
|
||||
hls.constructor = { Events }
|
||||
hls.on = (name, callback) => {
|
||||
if (!listeners.has(name))
|
||||
listeners.set(name, new Set())
|
||||
listeners.get(name).add(callback)
|
||||
}
|
||||
hls.off = (name, callback) => listeners.get(name)?.delete(callback)
|
||||
return {
|
||||
listeners,
|
||||
emit(name) {
|
||||
for (const callback of [...(listeners.get(name) || [])])
|
||||
callback(name, {})
|
||||
},
|
||||
count: () => [...listeners.values()].reduce((total, entries) => total + entries.size, 0),
|
||||
}
|
||||
}
|
||||
+209
-1
@@ -1,9 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- The repo uses Node's runner for controlled contracts.
|
||||
import { test } from 'node:test'
|
||||
import { bothMenus, hlsHost, hlsImplementations, selections } from './helpers/hls-control.js'
|
||||
import { attachHlsEvents, bothMenus, hlsHost, hlsImplementations, selections } from './helpers/hls-control.js'
|
||||
|
||||
const implementations = await hlsImplementations()
|
||||
const candidate = implementations.find(item => item.name === 'source').factory
|
||||
for (const { name, factory } of implementations) {
|
||||
test(`${name}: optional factory, synchronous result and deferred core events`, () => {
|
||||
const { art, controls, calls, listeners } = hlsHost()
|
||||
@@ -219,3 +220,210 @@ for (const { name, factory } of implementations.filter(item => item.name.startsW
|
||||
assert.equal(controls.get('hls-quality').selector.some(item => item.default), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate removes empty and disabled menus, then restores them', () => {
|
||||
const { art, hls, controls, settings } = hlsHost()
|
||||
const option = bothMenus()
|
||||
const plugin = candidate(option)(art)
|
||||
plugin.update()
|
||||
hls.levels = []
|
||||
hls.audioTracks = []
|
||||
plugin.update()
|
||||
assert.equal(controls.size, 0)
|
||||
assert.equal(settings.size, 0)
|
||||
hls.levels = [{ height: 240 }]
|
||||
hls.audioTracks = [{ id: 0, name: 'New' }]
|
||||
plugin.update()
|
||||
assert.equal(controls.size, 2)
|
||||
option.quality.control = false
|
||||
option.audio.setting = false
|
||||
plugin.update()
|
||||
assert.deepEqual([...controls.keys()], ['hls-audio'])
|
||||
assert.deepEqual([...settings.keys()], ['hls-quality'])
|
||||
})
|
||||
|
||||
test('candidate ignores superseded callbacks and retained update after destroy', () => {
|
||||
const { art, hls, calls, controls, listeners } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
const plugin = candidate(bothMenus())(art)
|
||||
plugin.update()
|
||||
const old = controls.get('hls-quality')
|
||||
const next = hlsHost().hls
|
||||
next.media = art.template.$video
|
||||
art.hls = next
|
||||
calls.length = 0
|
||||
old.onSelect(old.selector[0])
|
||||
assert.equal(hls.currentLevel, -1)
|
||||
assert.equal(calls.length, 0)
|
||||
plugin.update()
|
||||
assert.equal(events.count(), 0)
|
||||
const current = controls.get('hls-quality')
|
||||
art.emit('destroy')
|
||||
calls.length = 0
|
||||
assert.equal(plugin.update(), undefined)
|
||||
current.onSelect(current.selector[0])
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal([...listeners.values()].every(set => !set.size), true)
|
||||
})
|
||||
|
||||
test('candidate subscriptions update selection without replacing unchanged menus', () => {
|
||||
const { art, hls, calls, controls } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
hls.autoLevelEnabled = true
|
||||
hls.currentLevel = 1
|
||||
const plugin = candidate(bothMenus())(art)
|
||||
plugin.update()
|
||||
assert.equal(events.count(), 6)
|
||||
const menu = controls.get('hls-quality')
|
||||
assert.equal(menu.html, 'Auto')
|
||||
assert.equal(menu.selector.at(-1).default, true)
|
||||
hls.currentLevel = 2
|
||||
calls.length = 0
|
||||
events.emit('LEVEL_SWITCHED')
|
||||
assert.equal(controls.get('hls-quality'), menu)
|
||||
assert.equal(calls.length, 0)
|
||||
hls.autoLevelEnabled = false
|
||||
events.emit('LEVEL_SWITCHED')
|
||||
assert.equal(controls.get('hls-quality'), menu)
|
||||
assert.equal(menu.selector[0].default, true)
|
||||
assert.deepEqual(calls.map(call => call[0]), ['controls.check', 'setting.check'])
|
||||
calls.length = 0
|
||||
events.emit('LEVEL_SWITCHED')
|
||||
assert.equal(calls.length, 0)
|
||||
hls.audioTracks = []
|
||||
events.emit('AUDIO_TRACKS_UPDATED')
|
||||
assert.equal(controls.has('hls-audio'), false)
|
||||
events.emit('DESTROYING')
|
||||
assert.equal(events.count(), 0)
|
||||
assert.equal(controls.size, 0)
|
||||
})
|
||||
|
||||
test('candidate duplicate labels retain the actual selected representation', () => {
|
||||
const { art, hls, controls } = hlsHost()
|
||||
hls.levels = [{ height: 720 }, { height: 720 }]
|
||||
hls.currentLevel = 1
|
||||
candidate(bothMenus())(art).update()
|
||||
assert.deepEqual(selections(controls.get('hls-quality')), [['720P', 1, true], ['Auto', -1, false]])
|
||||
})
|
||||
|
||||
test('candidate rolls back failed subscription setup and retries on update', () => {
|
||||
const { art, hls } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
const on = hls.on
|
||||
hls.on = (name, callback) => {
|
||||
on(name, callback)
|
||||
if (name === 'LEVEL_SWITCHED')
|
||||
throw new Error('subscription rejected')
|
||||
}
|
||||
const plugin = candidate(bothMenus())(art)
|
||||
assert.throws(() => plugin.update(), /subscription rejected/)
|
||||
assert.equal(events.count(), 0)
|
||||
hls.on = on
|
||||
plugin.update()
|
||||
assert.equal(events.count(), 6)
|
||||
plugin.update()
|
||||
assert.equal(events.count(), 6)
|
||||
art.emit('destroy')
|
||||
assert.equal(events.count(), 0)
|
||||
})
|
||||
|
||||
test('candidate synchronously emitted SDK events preserve selection order', () => {
|
||||
const { art, hls, controls, calls } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
let selected = -1
|
||||
Object.defineProperty(hls, 'currentLevel', {
|
||||
get: () => selected,
|
||||
set(value) {
|
||||
calls.push(['currentLevel', value])
|
||||
selected = value
|
||||
events.emit('LEVEL_SWITCHED')
|
||||
},
|
||||
})
|
||||
candidate(bothMenus())(art).update()
|
||||
const menu = controls.get('hls-quality')
|
||||
const item = menu.selector[0]
|
||||
calls.length = 0
|
||||
assert.equal(menu.onSelect(item), item.html)
|
||||
assert.deepEqual(calls, [['currentLevel', 2], ['notice', 'Quality: 1080P'], ['controls.check', item], ['setting.check', item]])
|
||||
})
|
||||
|
||||
test('candidate cancels UI publication when getName destroys or replaces the player engine', () => {
|
||||
for (const action of ['destroy', 'replace']) {
|
||||
const { art, calls } = hlsHost()
|
||||
const option = bothMenus()
|
||||
option.quality.getName = () => {
|
||||
if (action === 'destroy')
|
||||
art.emit('destroy')
|
||||
else
|
||||
art.hls = hlsHost().hls
|
||||
return 'Label'
|
||||
}
|
||||
const plugin = candidate(option)(art)
|
||||
plugin.update()
|
||||
assert.equal(calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('candidate removes only its own SDK listeners and never revives a destroying engine', () => {
|
||||
const { art, hls, calls } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
const foreign = () => {}
|
||||
hls.on('LEVEL_SWITCHED', foreign)
|
||||
const plugin = candidate(bothMenus())(art)
|
||||
plugin.update()
|
||||
assert.equal(events.count(), 7)
|
||||
events.emit('DESTROYING')
|
||||
assert.equal(events.count(), 1)
|
||||
assert.equal(events.listeners.get('LEVEL_SWITCHED').has(foreign), true)
|
||||
calls.length = 0
|
||||
plugin.update()
|
||||
assert.equal(events.count(), 1)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('candidate nested update wins over the outer formatter operation', () => {
|
||||
const { art, controls } = hlsHost()
|
||||
const option = bothMenus()
|
||||
let nested = false
|
||||
let plugin
|
||||
option.quality.getName = () => {
|
||||
if (!nested) {
|
||||
nested = true
|
||||
option.quality.getName = () => 'Latest'
|
||||
plugin.update()
|
||||
}
|
||||
return 'Stale'
|
||||
}
|
||||
plugin = candidate(option)(art)
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality').selector[0].html, 'Latest')
|
||||
})
|
||||
|
||||
test('candidate clears selection and fallback label while an audio track is temporarily unknown', () => {
|
||||
const { art, hls, controls } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
candidate(bothMenus())(art).update()
|
||||
hls.audioTrack = -1
|
||||
events.emit('AUDIO_TRACKS_UPDATED')
|
||||
const menu = controls.get('hls-audio')
|
||||
assert.equal(menu.html, 'Auto')
|
||||
assert.equal(menu.selector.some(item => item.default), false)
|
||||
events.emit('AUDIO_TRACKS_UPDATED')
|
||||
assert.equal(controls.get('hls-audio'), menu)
|
||||
hls.audioTrack = 1
|
||||
events.emit('AUDIO_TRACK_SWITCHED')
|
||||
assert.equal(menu.selector[1].default, true)
|
||||
})
|
||||
|
||||
test('candidate does not repeat UI writes when getName has different current and list labels', () => {
|
||||
const { art, hls, calls } = hlsHost()
|
||||
const events = attachHlsEvents(hls)
|
||||
const options = bothMenus()
|
||||
options.quality.getName = (level, index) => index === undefined ? `Current ${level.height}` : `Choice ${index}`
|
||||
candidate(options)(art).update()
|
||||
hls.currentLevel = 1
|
||||
events.emit('LEVEL_SWITCHED')
|
||||
calls.length = 0
|
||||
events.emit('LEVEL_SWITCHED')
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
Reference in new issue
Block a user