mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 22:01:20 -08:00
test(hls): [PKG-HLS-02] cover released contracts and real SDK playback
This commit is contained in:
1 parent
cbbdc03a7c
commit
33f6d19b77
55 files changed
+8678
-14
No files matched your search
@@ -14,6 +14,13 @@ Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
|
||||
|
||||
`helpers/playback.js` creates the controlled media facade used by the original playback regressions. It models only the exercised methods and events. It is not HTMLMediaElement validation; actual playback remains in browser tests.
|
||||
|
||||
`node --test test/hls-control.test.js` runs shared HLS control contracts against workspace source
|
||||
and integrity-checked published main/legacy/ESM. It is included in `yarn test:unit`.
|
||||
`helpers/hls-control.js` owns the controlled SDK/registry host; it does not simulate decoding or ABR.
|
||||
Published-only tests preserve historical bug observations without requiring candidate code to keep them.
|
||||
Real Hls.js 1.5.17 playback is in `test/browser/hls-control.spec.js`; see its environment limits in
|
||||
`refactor/hls-validation.md`. Test SDK download is pinned and verified, not an installed runtime dependency.
|
||||
|
||||
`contracts/emitter.js` owns public assertions, independent of module layout. `public-behavior.test.js` runs them against the integrity-checked published core and current bundled source. The combined chain/context contract corresponds to BASE-03 EVENT.chain and EVENT.context-arguments; other EVENT IDs retain their baseline names. Exceptions, callback identity, mutation during dispatch and once reentry must not be weakened during migration.
|
||||
|
||||
Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to add a candidate to the same public-contract run, then execute `node --test test/public-behavior.test.js`. An invalid path/export fails the run. This is a narrow event contract check, not isolated package installation or complete API compatibility; ENG-07 owns tarball consumption.
|
||||
|
||||
@@ -15,6 +15,12 @@ no retries, a loopback server on 8084, and no existing dev server. Set `ARTPLAYE
|
||||
to use another free port. The server builds core and chapter from current JS/TS entries in memory;
|
||||
it does not modify dist or docs outputs. Restart the test command after source changes.
|
||||
|
||||
`hls-control.spec.js` separately builds HLS control and verifies a pinned Hls.js 1.5.17 archive.
|
||||
Its local playlists/segments are generated fixtures with checked hashes. Windows WebKit lacks MSE:
|
||||
one explicit capability/error-cleanup test covers that limitation and its 14 playback cases are
|
||||
skipped with a reason. Chromium/Firefox run the full four-combination HLS matrix. This is not
|
||||
Safari playback approval; see [HLS validation](../../refactor/hls-validation.md) for evidence and open gates.
|
||||
|
||||
`playback.spec.js` tests published/current core with current chapter, real decoded frame colors,
|
||||
media time advancing, pause, seek, source change to the existing docs video, destroy and real
|
||||
HTTP failure. These are initial smoke contracts, not full chapter or lifecycle coverage.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { compilePackage } from '../helpers/load.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
let sdkCode
|
||||
let publishedCode
|
||||
let sourceCode
|
||||
let evidence
|
||||
let media
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const state = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
return {
|
||||
supported: window.Hls?.isSupported(),
|
||||
mediaSource: typeof window.MediaSource,
|
||||
managedMediaSource: typeof window.ManagedMediaSource,
|
||||
video: art?.video.outerHTML,
|
||||
readyState: art?.video.readyState,
|
||||
networkState: art?.video.networkState,
|
||||
sdkErrors: window.sdkErrors,
|
||||
sdkEvents: window.sdkEvents,
|
||||
levels: art?.hls.levels.map(level => ({ height: level.height, codec: level.videoCodec })),
|
||||
tracks: art?.hls.audioTracks.map(track => ({ id: track.id, name: track.name })),
|
||||
}
|
||||
}).catch(error => ({ diagnosticError: error.message }))
|
||||
await testInfo.attach('hls-state', { contentType: 'application/json', body: JSON.stringify(state, null, 2) })
|
||||
})
|
||||
test.beforeAll(async () => {
|
||||
const sdk = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-sdk.json', import.meta.url)))
|
||||
const plugin = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-control-release.json', import.meta.url)))
|
||||
async function code(release, member) {
|
||||
const bytes = readMember(await ensureArchive(release), member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
return bytes.toString()
|
||||
}
|
||||
sdkCode = await code(sdk.release, 'package/dist/hls.min.js')
|
||||
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')
|
||||
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)) {
|
||||
assert(/^[\w.-]+$/.test(name))
|
||||
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
|
||||
assert.equal(hash(bytes), expected.sha256)
|
||||
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'] }
|
||||
})
|
||||
|
||||
async function openHls(page, core, plugin, testInfo, manifest = 'master.m3u8') {
|
||||
await testInfo.attach('hls-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
await page.route('**/hls-fixture/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const bytes = media.get(name)
|
||||
await route.fulfill({ status: bytes ? 200 : 503, body: bytes || 'Intentional HLS failure', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: sdkCode })
|
||||
await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode })
|
||||
await page.evaluate((manifest) => {
|
||||
window.sdkErrors = []
|
||||
window.sdkEvents = []
|
||||
window.destroyedEngines = 0
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: `/hls-fixture/${manifest}`,
|
||||
muted: true,
|
||||
setting: true,
|
||||
plugins: [window.artplayerPluginHlsControl({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })],
|
||||
customType: { m3u8(video, url, art) {
|
||||
if (art.hls)
|
||||
art.hls.destroy()
|
||||
const hls = new window.Hls({ enableWorker: false, manifestLoadingMaxRetry: 0 })
|
||||
art.hls = hls
|
||||
hls.on(window.Hls.Events.ERROR, (_, data) => window.sdkErrors.push({ type: data.type, details: data.details, fatal: data.fatal }))
|
||||
for (const name of ['LEVEL_SWITCHED', 'AUDIO_TRACK_SWITCHED', 'MANIFEST_PARSED'])
|
||||
hls.on(window.Hls.Events[name], (_, data) => window.sdkEvents.push({ name, level: data.level, id: data.id }))
|
||||
hls.on(window.Hls.Events.DESTROYING, () => window.destroyedEngines++)
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
} },
|
||||
})
|
||||
const art = window.art
|
||||
art.on('destroy', () => art.hls.destroy())
|
||||
document.querySelector('#play').onclick = () => art.play()
|
||||
document.querySelector('#pause').onclick = () => art.pause()
|
||||
}, manifest)
|
||||
}
|
||||
|
||||
test('SDK MSE availability is explicit; unavailable hosts report failure and clean up', async ({ page, browserName }, testInfo) => {
|
||||
await openHls(page, 'candidate', 'published', testInfo)
|
||||
const capability = await page.evaluate(() => ({ supported: window.Hls.isSupported(), mse: typeof window.MediaSource, managed: typeof window.ManagedMediaSource }))
|
||||
if (process.platform === 'win32' && browserName === 'webkit') {
|
||||
expect(capability).toEqual({ supported: false, mse: 'undefined', managed: 'undefined' })
|
||||
await expect.poll(() => page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([{ type: 'mediaError', details: 'manifestIncompatibleCodecsError', fatal: true }])
|
||||
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
|
||||
}
|
||||
else {
|
||||
expect(capability.supported).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
expect(await page.evaluate(() => {
|
||||
const sdk = window.art.hls
|
||||
window.art.destroy()
|
||||
return { media: sdk.media, destroyed: window.destroyedEngines }
|
||||
})).toEqual({ media: null, destroyed: 1 })
|
||||
})
|
||||
|
||||
test.describe('MSE playback matrix', () => {
|
||||
// Verified by the separate capability test above. Safari/MSE coverage remains HLS-05 work.
|
||||
test.skip(({ browserName }) => process.platform === 'win32' && browserName === 'webkit', 'Bundled Windows WebKit exposes neither MediaSource nor ManagedMediaSource; this is an unverified playback environment, not compatibility approval')
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const plugin of ['published', 'candidate']) {
|
||||
test(`${core} core / ${plugin} HLS: real decoding, manual quality, Auto and audio`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, plugin, 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)
|
||||
const initial = await page.evaluate(() => ({
|
||||
supported: window.Hls.isSupported(),
|
||||
version: window.Hls.version,
|
||||
levels: window.art.hls.levels.map(level => level.height),
|
||||
tracks: window.art.hls.audioTracks.map(track => ({ id: track.id, name: track.name })),
|
||||
frames: window.art.video.getVideoPlaybackQuality().totalVideoFrames,
|
||||
}))
|
||||
expect(initial).toMatchObject({ supported: true, version: '1.5.17', levels: [90, 180], tracks: [{ id: 0, name: 'English' }, { id: 1, name: 'French' }] })
|
||||
expect(initial.frames).toBeGreaterThan(0)
|
||||
await page.evaluate(() => {
|
||||
window.art.controls.show = true
|
||||
})
|
||||
const quality = page.locator('.art-control-hls-quality')
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => ({ automatic: window.art.hls.autoLevelEnabled, level: window.art.hls.currentLevel, height: window.art.video.videoHeight }))).toEqual({ automatic: false, level: 0, height: 90 })
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^180P$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => ({ manual: window.art.hls.autoLevelEnabled, level: window.art.hls.currentLevel, height: window.art.video.videoHeight }))).toEqual({ manual: false, level: 1, height: 180 })
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBeGreaterThanOrEqual(0)
|
||||
await page.evaluate(() => {
|
||||
window.art.controls.show = true
|
||||
})
|
||||
const audio = page.locator('.art-control-hls-audio')
|
||||
await audio.hover()
|
||||
await audio.locator('.art-selector-item').filter({ hasText: /^French$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'AUDIO_TRACK_SWITCHED' && event.id === 1))).toBe(true)
|
||||
expect(await page.evaluate(() => window.art.hls.audioTrack)).toBe(1)
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.seek = 5
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeCloseTo(5, 1)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(5.2)
|
||||
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
|
||||
const result = await page.evaluate(() => {
|
||||
const video = window.art.video
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 16
|
||||
canvas.height = 16
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(video, 0, 0, 16, 16)
|
||||
const pixels = ctx.getImageData(0, 0, 16, 16).data
|
||||
const colors = new Set()
|
||||
for (let index = 0; index < pixels.length; index += 4)
|
||||
colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`)
|
||||
const sdk = window.art.hls
|
||||
window.art.destroy()
|
||||
return { colors: colors.size, media: sdk.media, destroyed: window.destroyedEngines, children: document.querySelector('.player').childElementCount }
|
||||
})
|
||||
expect(result.colors).toBeGreaterThan(8)
|
||||
expect(result).toMatchObject({ media: null, destroyed: 1, children: 0 })
|
||||
})
|
||||
|
||||
test(`${core} core / ${plugin} HLS: failed manifest and actual SDK teardown`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, plugin, testInfo, 'missing.m3u8')
|
||||
await expect.poll(() => page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([{ type: 'networkError', details: 'manifestLoadError', fatal: true }])
|
||||
expect(await page.locator('.art-control-hls-quality').count()).toBe(0)
|
||||
await page.evaluate(() => window.art.switchUrl('/hls-fixture/master.m3u8'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.levels.length)).toBe(2)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
|
||||
expect(await page.evaluate(() => {
|
||||
const sdk = window.art.hls
|
||||
window.art.destroy()
|
||||
return { destroyed: window.destroyedEngines, media: sdk.media }
|
||||
})).toEqual({ destroyed: 2, media: null })
|
||||
})
|
||||
|
||||
test(`${core} core / ${plugin} HLS: source switch drops SDK audio topology`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, plugin, 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.2)
|
||||
await page.evaluate(() => {
|
||||
window.previousHls = window.art.hls
|
||||
return window.art.switchUrl('/hls-fixture/video-only.m3u8')
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => ({ levels: window.art.hls.levels.length, tracks: window.art.hls.audioTracks.length, height: window.art.video.videoHeight }))).toEqual({ levels: 1, tracks: 0, height: 90 })
|
||||
expect(await page.evaluate(() => ({ oldMedia: window.previousHls.media, replaced: window.previousHls !== window.art.hls, destroyed: window.destroyedEngines }))).toEqual({ oldMedia: null, replaced: true, destroyed: 1 })
|
||||
if (plugin === 'published') {
|
||||
// Real SDK corroboration of the frozen historical empty-topology defect.
|
||||
await expect(page.locator('.art-control-hls-audio')).toHaveCount(1)
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${core} core / published HLS: real Auto playback loses Auto label on update`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, 'published', 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.2)
|
||||
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
const height = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.plugins.artplayerPluginHlsControl.update()
|
||||
return art.hls.levels[art.hls.currentLevel].height
|
||||
})
|
||||
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText(`${height}P`)
|
||||
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deterministic HLS fixtures
|
||||
|
||||
Generated by `node scripts/generate-hls-fixture.mjs`, using FFmpeg recorded in manifest.json.
|
||||
No downloaded film or audio: testsrc2 supplies moving patterns, sine supplies 440/880 Hz audio.
|
||||
The 12-second master offers 160x90 and 320x180 AVC video, English/French AAC audio, and a
|
||||
separate video-only master for topology changes. Each media playlist is VOD with 2-second segments.
|
||||
|
||||
Segments use `.mpegts` so source tools cannot mistake MPEG transport streams for TypeScript.
|
||||
The HLS browser fixture serves them as video/mp2t. File contents and byte counts are checked
|
||||
against manifest.json before browser use. Tests need neither FFmpeg nor a remote media service.
|
||||
|
||||
To change the media, generate into a new directory, for example
|
||||
`node scripts/generate-hls-fixture.mjs refactor/.cache/hls-media-next`; inspect the output and
|
||||
update the media, manifest and browser evidence together. The script refuses existing directories.
|
||||
Set ARTPLAYER_FFMPEG to a particular FFmpeg executable if it is not on PATH. Re-encoding with
|
||||
another FFmpeg version is not expected to preserve bytes; do not overwrite hashes to silence drift.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.005333,
|
||||
english-00.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-01.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-02.mpegts
|
||||
#EXTINF:1.984000,
|
||||
english-03.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-04.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-05.mpegts
|
||||
#EXTINF:0.021333,
|
||||
english-06.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.005333,
|
||||
french-00.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-01.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-02.mpegts
|
||||
#EXTINF:1.984000,
|
||||
french-03.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-04.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-05.mpegts
|
||||
#EXTINF:0.021333,
|
||||
french-06.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.000000,
|
||||
high-00.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-01.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-02.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-03.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-04.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-05.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.000000,
|
||||
low-00.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-01.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-02.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-03.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-04.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-05.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"kind": "generated-hls-media",
|
||||
"version": "ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers",
|
||||
"duration": 12,
|
||||
"commands": [
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=160x90:rate=24",
|
||||
"-t",
|
||||
"12",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"baseline",
|
||||
"-level:v",
|
||||
"3.0",
|
||||
"-g",
|
||||
"48",
|
||||
"-keyint_min",
|
||||
"48",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-crf",
|
||||
"28",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"low-%02d.mpegts",
|
||||
"low.m3u8"
|
||||
],
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=320x180:rate=24",
|
||||
"-t",
|
||||
"12",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"baseline",
|
||||
"-level:v",
|
||||
"3.0",
|
||||
"-g",
|
||||
"48",
|
||||
"-keyint_min",
|
||||
"48",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-crf",
|
||||
"28",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"high-%02d.mpegts",
|
||||
"high.m3u8"
|
||||
],
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000",
|
||||
"-t",
|
||||
"12",
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"48k",
|
||||
"-ac",
|
||||
"2",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"english-%02d.mpegts",
|
||||
"english.m3u8"
|
||||
],
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=880:sample_rate=48000",
|
||||
"-t",
|
||||
"12",
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"48k",
|
||||
"-ac",
|
||||
"2",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"french-%02d.mpegts",
|
||||
"french.m3u8"
|
||||
]
|
||||
],
|
||||
"files": {
|
||||
"english-00.mpegts": {
|
||||
"sha256": "cc599f7c270385e85c28f710a34c5bd90b09d4d27adb3b1e6af3de65584b8207",
|
||||
"bytes": 13912
|
||||
},
|
||||
"english-01.mpegts": {
|
||||
"sha256": "01b17c28defc8a8ebf4978c5236eb759d80521a1cb840898095c35dac420cec5",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-02.mpegts": {
|
||||
"sha256": "90751b8b5abedd6b2f2e4eb83ccd609098ec7f2901163aaebf977cb9a11c46eb",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-03.mpegts": {
|
||||
"sha256": "9b1d86b7b261df66dbdace4ef1e846796cd710ffb72b22fc69532c1fe7d1c55e",
|
||||
"bytes": 13912
|
||||
},
|
||||
"english-04.mpegts": {
|
||||
"sha256": "dbfddd4f997942dd0b912f4c8bdbc5c11ee2a3fc51ac67df48d3aed2756715b9",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-05.mpegts": {
|
||||
"sha256": "9b53790d4f50f172852b7d4ff51bdd4bedd39e4c7a1efbfe734edf7b9a68a533",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-06.mpegts": {
|
||||
"sha256": "50baaa5e9fe7ca49b22c4d0817c44103b3edc763aee7f1b17d22f796d4189d4a",
|
||||
"bytes": 752
|
||||
},
|
||||
"english.m3u8": {
|
||||
"sha256": "5aae9d117d458f65f7febae68555ea4e48b2055b4cfb123fb919e385a1edda21",
|
||||
"bytes": 365
|
||||
},
|
||||
"french-00.mpegts": {
|
||||
"sha256": "7278acd094bc7caeeeea424a2c11ac4285fc45a8becb6c8ec55068f295ae35cd",
|
||||
"bytes": 13912
|
||||
},
|
||||
"french-01.mpegts": {
|
||||
"sha256": "e9464d109e36a53d7f151442b544d47ce79324bbaa805e0a98f171621c9c7a13",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-02.mpegts": {
|
||||
"sha256": "3ac48cab6aac942950b59f79ac6bd950dcae425f14821b83b1b5bb72d6815eb0",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-03.mpegts": {
|
||||
"sha256": "906f2d8b1f29bb7d57240b145563bab975d8f7dfff78cebd1505b6d2a3feb43b",
|
||||
"bytes": 13912
|
||||
},
|
||||
"french-04.mpegts": {
|
||||
"sha256": "cbf82876118d41af254bdef51f6cd08d842e8c6edf53250667fad2b8088e3601",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-05.mpegts": {
|
||||
"sha256": "a91e564ec54d845837217c45b8e25b08fe208f2b5f97562f513baabd515427d9",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-06.mpegts": {
|
||||
"sha256": "2c8edc11b06bacf1e62c4db67594ce187aa93e0241012011ebcf587b64ccff46",
|
||||
"bytes": 752
|
||||
},
|
||||
"french.m3u8": {
|
||||
"sha256": "6f7fb3eacb944597fa3105cb6d708eac3a8dc413e1706a11b6fb0e631214634e",
|
||||
"bytes": 358
|
||||
},
|
||||
"high-00.mpegts": {
|
||||
"sha256": "c5a8faac85b0594818255117696dce43fda6b363df40e2f3ffc453bd236cc833",
|
||||
"bytes": 45308
|
||||
},
|
||||
"high-01.mpegts": {
|
||||
"sha256": "5714640c3f17d86c56036453dc17d45f871af63b461318c43527af3482f8661c",
|
||||
"bytes": 52264
|
||||
},
|
||||
"high-02.mpegts": {
|
||||
"sha256": "fea1cbfb199bc867a322c7a646d43594a1d0d1d23fa0bcb6e770f2669b489440",
|
||||
"bytes": 48128
|
||||
},
|
||||
"high-03.mpegts": {
|
||||
"sha256": "4a74a28e7ad6bc626358e32365a0b07df1da82c92753e28032983e22404958b5",
|
||||
"bytes": 54144
|
||||
},
|
||||
"high-04.mpegts": {
|
||||
"sha256": "d04889b3b996d3e79c12bc297f9577690d3ae4f4172cdc0484b584954dab789c",
|
||||
"bytes": 47376
|
||||
},
|
||||
"high-05.mpegts": {
|
||||
"sha256": "36ee0c64b8c3637d0e2282c611e9250650ac493440095fc2efbe9de1c54add15",
|
||||
"bytes": 48128
|
||||
},
|
||||
"high.m3u8": {
|
||||
"sha256": "672c0e8b99242be05b16fc747a00715b2c25579b1ecc1623c0579fa34f0cb11d",
|
||||
"bytes": 311
|
||||
},
|
||||
"low-00.mpegts": {
|
||||
"sha256": "cbd62f5f4641cbe188a15640139bea393b5927fce600ff7513ef9ade9b33fb62",
|
||||
"bytes": 28952
|
||||
},
|
||||
"low-01.mpegts": {
|
||||
"sha256": "f7821fba38c63f5d80daef21cdd3430880bdc9fd5f5adfa939ed6481106c2507",
|
||||
"bytes": 32900
|
||||
},
|
||||
"low-02.mpegts": {
|
||||
"sha256": "3dc93f9f0fd065678b01fc6c568c12350e9ab0b324d1e0fa0e9f77787da7e7d2",
|
||||
"bytes": 30268
|
||||
},
|
||||
"low-03.mpegts": {
|
||||
"sha256": "d94c8913f7fde98161550041287529b90a58d537d6083d4382540c5fce0b430b",
|
||||
"bytes": 32336
|
||||
},
|
||||
"low-04.mpegts": {
|
||||
"sha256": "11596c59b547c2ff23bbba1f70e15f061a3e87bbdc1f4f59daa9b5279a6003af",
|
||||
"bytes": 29704
|
||||
},
|
||||
"low-05.mpegts": {
|
||||
"sha256": "5c6ec25a69fdad73a23cfbc34f0f3c6d8abe44592650c92fa9110a38d6bd9e45",
|
||||
"bytes": 29516
|
||||
},
|
||||
"low.m3u8": {
|
||||
"sha256": "404a8d868ed2af3598269aee89caba0563c2805dac223fe4c00c9a9eda73554f",
|
||||
"bytes": 305
|
||||
},
|
||||
"master.m3u8": {
|
||||
"sha256": "f226bd38f3c76714ada2b5a6866ad1cb39b041023e4a18d971d175f26f917e38",
|
||||
"bytes": 470
|
||||
},
|
||||
"video-only.m3u8": {
|
||||
"sha256": "3d7bb9069f6218191dc65745389bf1ab0ba6d5ed66cbd14e2cf30211913da3fc",
|
||||
"bytes": 108
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="english.m3u8"
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=YES,URI="french.m3u8"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
|
||||
low.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=320x180,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
|
||||
high.m3u8
|
||||
@@ -0,0 +1,4 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"
|
||||
low.m3u8
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { loadPackage } from './load.js'
|
||||
|
||||
export async function hlsImplementations() {
|
||||
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-control-release.json', import.meta.url)))
|
||||
const archive = await ensureArchive(release)
|
||||
const implementations = [{ name: 'source', factory: (await loadPackage(release.name)).default }]
|
||||
for (const format of ['main', 'legacy', 'module']) {
|
||||
const member = `package/${release.manifest[format].replace(/^\.\//, '')}`
|
||||
const bytes = readMember(archive, member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
let factory
|
||||
if (format === 'module') {
|
||||
factory = (await import(`data:text/javascript;base64,${Buffer.from(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: `published-${format}`, factory })
|
||||
}
|
||||
return implementations
|
||||
}
|
||||
|
||||
// Controlled SDK and registry host: no DOM rendering, decoding or ABR simulation.
|
||||
export function hlsHost() {
|
||||
const listeners = new Map()
|
||||
const calls = []
|
||||
const controls = new Map()
|
||||
const settings = new Map()
|
||||
const video = {}
|
||||
const art = {
|
||||
template: { $video: video },
|
||||
constructor: { utils: { errorHandle: (condition, message) => {
|
||||
if (!condition)
|
||||
throw new Error(message)
|
||||
} } },
|
||||
on(event, callback) {
|
||||
if (!listeners.has(event))
|
||||
listeners.set(event, new Set())
|
||||
listeners.get(event).add(callback)
|
||||
return art
|
||||
},
|
||||
off(event, callback) {
|
||||
listeners.get(event)?.delete(callback)
|
||||
return art
|
||||
},
|
||||
emit(event, ...args) {
|
||||
for (const callback of [...(listeners.get(event) || [])])
|
||||
callback(...args)
|
||||
return art
|
||||
},
|
||||
notice: {},
|
||||
}
|
||||
let notice = ''
|
||||
Object.defineProperty(art.notice, 'show', {
|
||||
get: () => notice,
|
||||
set: (value) => {
|
||||
calls.push(['notice', value])
|
||||
notice = value
|
||||
},
|
||||
})
|
||||
for (const [name, entries] of [['controls', controls], ['setting', settings]]) {
|
||||
art[name] = {
|
||||
update(item) {
|
||||
calls.push([`${name}.update`, item.name])
|
||||
entries.set(item.name, item)
|
||||
},
|
||||
remove(nameToRemove) {
|
||||
calls.push([`${name}.remove`, nameToRemove])
|
||||
entries.delete(nameToRemove)
|
||||
},
|
||||
check(item) { calls.push([`${name}.check`, item]) },
|
||||
}
|
||||
}
|
||||
const hls = {
|
||||
media: video,
|
||||
levels: [{ height: 360 }, { height: 720 }, { height: 1080 }],
|
||||
audioTracks: [{ id: 0, name: 'English' }, { id: 1, lang: 'fr' }],
|
||||
}
|
||||
for (const [key, initial] of [['currentLevel', -1], ['audioTrack', 0]]) {
|
||||
let value = initial
|
||||
Object.defineProperty(hls, key, {
|
||||
configurable: true,
|
||||
get: () => value,
|
||||
set: (next) => {
|
||||
calls.push([key, next])
|
||||
value = next
|
||||
},
|
||||
})
|
||||
}
|
||||
art.hls = hls
|
||||
return { art, hls, calls, controls, settings, listeners }
|
||||
}
|
||||
|
||||
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])
|
||||
@@ -0,0 +1,221 @@
|
||||
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'
|
||||
|
||||
const implementations = await hlsImplementations()
|
||||
for (const { name, factory } of implementations) {
|
||||
test(`${name}: optional factory, synchronous result and deferred core events`, () => {
|
||||
const { art, controls, calls, listeners } = hlsHost()
|
||||
const empty = factory()(art)
|
||||
assert.deepEqual(Object.keys(empty).sort(), ['name', 'update'])
|
||||
assert.equal(empty.name, 'artplayerPluginHlsControl')
|
||||
assert.equal(empty.update(), undefined)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(listeners.get('ready').has(empty.update), true)
|
||||
assert.equal(listeners.get('restart').has(empty.update), true)
|
||||
const plugin = factory(bothMenus())(art)
|
||||
assert.equal(controls.size, 0)
|
||||
art.emit('ready')
|
||||
assert.equal(controls.size, 2)
|
||||
calls.length = 0
|
||||
art.emit('restart', 'next.m3u8')
|
||||
assert.deepEqual(calls.map(call => call[0]), ['controls.update', 'setting.update', 'controls.update', 'setting.update'])
|
||||
assert.equal(plugin.update(), undefined)
|
||||
})
|
||||
|
||||
test(`${name}: missing or foreign Hls fails before any menu changes`, () => {
|
||||
const { art, calls, hls } = hlsHost()
|
||||
const plugin = factory(bothMenus())(art)
|
||||
for (const invalid of [undefined, null, {}, { media: {} }]) {
|
||||
art.hls = invalid
|
||||
assert.throws(() => plugin.update(), { message: 'Cannot find instance of HLS from "art.hls"' })
|
||||
assert.equal(calls.length, 0)
|
||||
}
|
||||
art.hls = hls
|
||||
plugin.update()
|
||||
assert.equal(calls.length, 4)
|
||||
})
|
||||
|
||||
test(`${name}: quality and audio retain names, ordering, defaults and UI fields`, () => {
|
||||
const { art, hls, controls, settings } = hlsHost()
|
||||
hls.levels = [{ name: 'SD', height: 360 }, { height: 720 }, { name: 'SD', height: 480 }, { height: 1080 }]
|
||||
hls.audioTracks.push({ id: 2, language: 'de' }, { id: 3, name: 'English' })
|
||||
const plugin = factory(bothMenus())(art)
|
||||
plugin.update()
|
||||
assert.deepEqual(selections(controls.get('hls-quality')), [['1080P', 3, false], ['720P', 1, false], ['SD', 0, false], ['Auto', -1, true]])
|
||||
assert.deepEqual(selections(controls.get('hls-audio')), [['English', 0, true], ['fr', 1, false], ['de', 2, false]])
|
||||
for (const [key, title] of [['hls-quality', 'Quality'], ['hls-audio', 'Audio']]) {
|
||||
const control = controls.get(key)
|
||||
const setting = settings.get(key)
|
||||
assert.equal(control.position, 'right')
|
||||
assert.equal(control.style.padding, '0 10px')
|
||||
assert.equal(setting.width, 200)
|
||||
assert.match(setting.icon, /<svg/)
|
||||
assert.equal(setting.html, title)
|
||||
assert.equal(setting.tooltip, control.html)
|
||||
assert.equal(setting.selector, control.selector)
|
||||
assert.equal(setting.onSelect, control.onSelect)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: selection writes SDK then notice then checks the same item`, () => {
|
||||
for (const [key, field, title] of [['hls-quality', 'currentLevel', 'Quality'], ['hls-audio', 'audioTrack', 'Audio']]) {
|
||||
const { art, calls, controls, hls } = hlsHost()
|
||||
factory(bothMenus())(art).update()
|
||||
const menu = controls.get(key)
|
||||
const item = menu.selector[1]
|
||||
calls.length = 0
|
||||
assert.equal(menu.onSelect(item), item.html)
|
||||
assert.equal(hls[field], item.value)
|
||||
assert.deepEqual(calls, [[field, item.value], ['notice', `${title}: ${item.html}`], ['controls.check', item], ['setting.check', item]])
|
||||
if (key === 'hls-quality') {
|
||||
assert.equal(menu.onSelect(menu.selector.at(-1)), 'Auto')
|
||||
assert.equal(hls.currentLevel, -1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: getName receives original SDK item and optional map index without binding`, () => {
|
||||
const { art, hls, controls } = hlsHost()
|
||||
hls.currentLevel = 1
|
||||
const invocations = []
|
||||
const option = bothMenus()
|
||||
option.quality.getName = function (...args) {
|
||||
invocations.push({ context: this, args })
|
||||
return `Level ${args[0].height}`
|
||||
}
|
||||
factory(option)(art).update()
|
||||
assert.equal(invocations.length, 4)
|
||||
assert.equal(invocations[0].args.length, 1)
|
||||
assert.equal(invocations[0].args[0], hls.levels[1])
|
||||
for (let index = 0; index < 3; index++) {
|
||||
assert.equal(invocations[index + 1].context, undefined)
|
||||
assert.equal(invocations[index + 1].args[0], hls.levels[index])
|
||||
assert.equal(invocations[index + 1].args[1], index)
|
||||
}
|
||||
assert.equal(controls.get('hls-quality').html, 'Level 720')
|
||||
})
|
||||
|
||||
test(`${name}: callback failure is synchronous and later update can recover`, () => {
|
||||
const { art, calls, controls } = hlsHost()
|
||||
const failure = new Error('consumer callback failure')
|
||||
const option = bothMenus()
|
||||
option.audio.getName = () => {
|
||||
throw failure
|
||||
}
|
||||
const plugin = factory(option)(art)
|
||||
assert.throws(() => plugin.update(), error => error === failure)
|
||||
assert.deepEqual(calls.map(call => call[1]), ['hls-quality', 'hls-quality'])
|
||||
assert.equal(controls.has('hls-audio'), false)
|
||||
delete option.audio.getName
|
||||
assert.equal(plugin.update(), undefined)
|
||||
assert.equal(controls.has('hls-audio'), true)
|
||||
})
|
||||
|
||||
test(`${name}: rejected SDK selection propagates before notice or checks`, () => {
|
||||
for (const [key, field] of [['hls-quality', 'currentLevel'], ['hls-audio', 'audioTrack']]) {
|
||||
const { art, hls, controls, calls } = hlsHost()
|
||||
factory(bothMenus())(art).update()
|
||||
const failure = new Error('SDK selection rejected')
|
||||
const selected = hls[field]
|
||||
Object.defineProperty(hls, field, {
|
||||
get: () => selected,
|
||||
set() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
calls.length = 0
|
||||
const menu = controls.get(key)
|
||||
assert.throws(() => menu.onSelect(menu.selector[0]), error => error === failure)
|
||||
assert.equal(calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: individual surfaces, translated labels and falsy defaults`, () => {
|
||||
for (const surface of ['control', 'setting']) {
|
||||
const { art, controls, settings, calls } = hlsHost()
|
||||
const options = { quality: { [surface]: true, title: 'Definition', auto: 'Automatic' }, audio: { [surface]: true, title: '', auto: '' } }
|
||||
factory(options)(art).update()
|
||||
const active = surface === 'control' ? controls : settings
|
||||
assert.equal((surface === 'control' ? settings : controls).size, 0)
|
||||
const menu = active.get('hls-quality')
|
||||
assert.equal(menu.selector.at(-1).html, 'Automatic')
|
||||
calls.length = 0
|
||||
menu.onSelect(menu.selector[0])
|
||||
assert.equal(art.notice.show, 'Definition: 1080P')
|
||||
assert.deepEqual(calls.map(call => call[0]), ['currentLevel', 'notice', `${surface === 'control' ? 'controls' : 'setting'}.check`])
|
||||
active.get('hls-audio').onSelect(active.get('hls-audio').selector[0])
|
||||
assert.equal(art.notice.show, 'Audio: English')
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: update reads replacement engine and new list indexes`, () => {
|
||||
const host = hlsHost()
|
||||
const plugin = factory(bothMenus())(host.art)
|
||||
plugin.update()
|
||||
const replacement = hlsHost().hls
|
||||
replacement.media = host.art.template.$video
|
||||
replacement.levels = [{ height: 144 }, { height: 240 }]
|
||||
replacement.currentLevel = 1
|
||||
host.art.hls = replacement
|
||||
host.art.emit('restart')
|
||||
const menu = host.controls.get('hls-quality')
|
||||
assert.deepEqual(selections(menu), [['240P', 1, true], ['144P', 0, false], ['Auto', -1, false]])
|
||||
menu.onSelect(menu.selector[1])
|
||||
assert.equal(replacement.currentLevel, 0)
|
||||
assert.equal(host.hls.currentLevel, -1)
|
||||
})
|
||||
}
|
||||
|
||||
// Published-only observations freeze bugs, not requirements for the new implementation.
|
||||
for (const { name, factory } of implementations.filter(item => item.name.startsWith('published-'))) {
|
||||
test(`${name}: historical empty topology and disabled flags retain stale menus`, () => {
|
||||
const { art, hls, controls, settings } = hlsHost()
|
||||
const option = bothMenus()
|
||||
const plugin = factory(option)(art)
|
||||
plugin.update()
|
||||
const old = controls.get('hls-quality')
|
||||
hls.levels = []
|
||||
hls.audioTracks = []
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality'), old)
|
||||
assert.equal(settings.size, 2)
|
||||
hls.levels = [{ height: 240 }]
|
||||
option.quality.control = false
|
||||
option.quality.setting = false
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality'), old)
|
||||
})
|
||||
|
||||
test(`${name}: historical callbacks still write old engines after replacement and destroy`, () => {
|
||||
const { art, hls, controls, calls } = hlsHost()
|
||||
const plugin = factory(bothMenus())(art)
|
||||
plugin.update()
|
||||
const old = controls.get('hls-quality')
|
||||
const replacement = hlsHost().hls
|
||||
replacement.media = art.template.$video
|
||||
art.hls = replacement
|
||||
plugin.update()
|
||||
old.onSelect(old.selector[0])
|
||||
assert.equal(hls.currentLevel, 2)
|
||||
assert.equal(replacement.currentLevel, -1)
|
||||
art.emit('destroy')
|
||||
calls.length = 0
|
||||
plugin.update()
|
||||
assert.equal(calls.length, 4, 'Retained update remains active after core destroy event')
|
||||
})
|
||||
|
||||
test(`${name}: historical Auto label follows playing level, duplicate default is lost`, () => {
|
||||
const { art, hls, controls } = hlsHost()
|
||||
hls.autoLevelEnabled = true
|
||||
hls.currentLevel = 1
|
||||
const plugin = factory(bothMenus())(art)
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality').html, '720P')
|
||||
assert.equal(controls.get('hls-quality').selector.at(-1).default, false)
|
||||
hls.levels = [{ height: 720 }, { height: 720 }]
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality').selector.some(item => item.default), false)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user