mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 11:56:15 -08:00
refactor(audio): [PKG-AUDIO-03] separate track ownership and native lifecycle
This commit is contained in:
1 parent
803e87252e
commit
9c07ea830c
20 files changed
+12654
-250
No files matched your search
+100
-1
@@ -136,7 +136,7 @@ for (const { name, factory } of implementations) {
|
||||
left.audio.calls.length = 0
|
||||
right.audio.calls.length = 0
|
||||
first.art.emit('destroy')
|
||||
assert.deepEqual(left.audio.calls, [['pause'], ['src', ''], ['load']])
|
||||
assert.deepEqual(left.audio.calls, [['pause'], name.startsWith('published') ? ['src', ''] : ['removeAttribute', 'src'], ['load']])
|
||||
assert.deepEqual(right.audio.calls, [])
|
||||
second.art.currentTime = 2
|
||||
second.art.emit('seek')
|
||||
@@ -145,6 +145,105 @@ for (const { name, factory } of implementations) {
|
||||
}
|
||||
|
||||
const historical = implementations.find(item => item.name === 'published-main').factory
|
||||
for (const { name, factory: candidate } of implementations.filter(item => !item.name.startsWith('published'))) {
|
||||
test(`${name}: closed callbacks and retained update cannot revive resources`, (t) => {
|
||||
const { art, listeners } = audioHost(t)
|
||||
const plugin = candidate({ url: 'audio.aac' })(art)
|
||||
const retained = [...listeners.values()].flatMap(set => [...set])
|
||||
art.emit('destroy')
|
||||
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
plugin.audio.calls.length = 0
|
||||
Object.defineProperty(art, 'playing', { get() {
|
||||
throw new Error('Closed host read')
|
||||
} })
|
||||
assert.equal(plugin.update({ url: 'revived.aac' }), undefined)
|
||||
for (const callback of retained)
|
||||
callback()
|
||||
assert.deepEqual(plugin.audio.calls, [])
|
||||
})
|
||||
|
||||
test(`${name}: pending playback rejection becomes inert after destroy`, async (t) => {
|
||||
const { art, setPlay, warnings } = audioHost(t)
|
||||
let reject
|
||||
const pending = new Promise((resolve, fail) => {
|
||||
reject = fail
|
||||
})
|
||||
setPlay(() => pending)
|
||||
candidate({ url: 'audio.aac' })(art)
|
||||
art.emit('play').emit('destroy')
|
||||
reject(new Error('Cancelled playback'))
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(warnings, [])
|
||||
})
|
||||
|
||||
test(`${name}: unsubscription failure does not prevent remaining cleanup`, (t) => {
|
||||
const { art } = audioHost(t)
|
||||
const plugin = candidate({ url: 'audio.aac' })(art)
|
||||
const off = art.off
|
||||
const failure = new Error('Controlled off failure')
|
||||
t.mock.method(art, 'off', (event, callback) => {
|
||||
if (event === 'play')
|
||||
throw failure
|
||||
return off(event, callback)
|
||||
})
|
||||
plugin.audio.calls.length = 0
|
||||
assert.throws(() => art.emit('destroy'), error => error === failure)
|
||||
assert.deepEqual(plugin.audio.calls, [['pause'], ['removeAttribute', 'src'], ['load']])
|
||||
plugin.audio.calls.length = 0
|
||||
art.emit('play')
|
||||
plugin.update({ url: 'revived.aac' })
|
||||
assert.deepEqual(plugin.audio.calls, [])
|
||||
})
|
||||
|
||||
test(`${name}: partial subscription failure releases the media and original error`, (t) => {
|
||||
const { art, instances, listeners } = audioHost(t)
|
||||
const on = art.on
|
||||
const failure = new Error('Controlled on failure')
|
||||
t.mock.method(art, 'on', (event, callback) => {
|
||||
on(event, callback)
|
||||
if (event === 'pause')
|
||||
throw failure
|
||||
return art
|
||||
})
|
||||
assert.throws(() => candidate({ url: 'audio.aac' })(art), error => error === failure)
|
||||
assert.deepEqual(instances[0].calls.slice(-3), [['pause'], ['removeAttribute', 'src'], ['load']])
|
||||
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test(`${name}: source initialization and pause failures still attempt media release`, (t) => {
|
||||
const failure = new Error('Controlled src failure')
|
||||
const { art, instances } = audioHost(t, (audio) => {
|
||||
Object.defineProperty(audio, 'src', {
|
||||
get: () => '',
|
||||
set() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
audio.pause = () => {
|
||||
audio.calls.push(['pause'])
|
||||
throw new Error('Secondary cleanup failure')
|
||||
}
|
||||
})
|
||||
assert.throws(() => candidate({ url: 'audio.aac' })(art), error => error === failure)
|
||||
assert.deepEqual(instances[0].calls.slice(-3), [['pause'], ['removeAttribute', 'src'], ['load']])
|
||||
})
|
||||
|
||||
test(`${name}: native pause/end/seek synchronize audio without public pause calls`, (t) => {
|
||||
const { art } = audioHost(t)
|
||||
const { audio } = candidate({ url: 'audio.aac' })(art)
|
||||
audio.calls.length = 0
|
||||
art.emit('video:pause').emit('video:ended').emit('video:seeking')
|
||||
assert.deepEqual(audio.calls, [['pause'], ['pause'], ['pause']])
|
||||
art.currentTime = 3
|
||||
art.emit('video:seeked')
|
||||
assert.equal(audio.currentTime, 3)
|
||||
art.playing = true
|
||||
art.currentTime = 4
|
||||
art.emit('video:playing')
|
||||
assert.deepEqual(audio.calls.slice(-2), [['currentTime', 4], ['play']])
|
||||
})
|
||||
}
|
||||
|
||||
test('published defect AUDIO-LIFE-01: retained update reloads audio after destroy', (t) => {
|
||||
const { art, listeners } = audioHost(t)
|
||||
const plugin = historical({ url: 'audio.aac' })(art)
|
||||
|
||||
@@ -37,8 +37,8 @@ test.beforeAll(async () => {
|
||||
const bytes = readMember(await ensureArchive(release), member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
publishedCode = bytes.toString()
|
||||
sourceCode = await compilePackage(release.name, 'umd')
|
||||
evidence = { release, sourceSHA256: hash(sourceCode), audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source browser baseline; no physical device or installed package acceptance' }
|
||||
sourceCode = process.env.ARTPLAYER_AUDIO_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_AUDIO_ARTIFACT, 'utf8') : await compilePackage(release.name, 'umd')
|
||||
evidence = { release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_AUDIO_ARTIFACT || 'workspace source build', audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source or explicit artifact browser checks; no physical device or installed package acceptance' }
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@@ -116,6 +116,31 @@ test('published AUDIO-LIFE-01: empty src cleanup produces a native media error',
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core} core + source audio: native pause/end stop audio and closed references stay inert`, async ({ page }, testInfo) => {
|
||||
await openAudio(page, core, 'source', testInfo)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
|
||||
await page.evaluate(() => window.art.video.pause())
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.video.currentTime = 4
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(4, 1)
|
||||
await page.evaluate(() => {
|
||||
window.art.seek = 7.8
|
||||
window.art.play()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.ended)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.networkState)).toBe(0)
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.error)).toBeNull()
|
||||
await page.evaluate(() => window.audioPlugin.update({ url: '/test/audio-tone.m4a?source=revived' }))
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBeNull()
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.networkState)).toBe(0)
|
||||
})
|
||||
|
||||
test(`${core} core + published audio: native pause and end leave external audio playing`, async ({ page }, testInfo) => {
|
||||
await openAudio(page, core, 'published', testInfo)
|
||||
await page.locator('#play').click()
|
||||
@@ -181,7 +206,7 @@ for (const core of ['published', 'candidate']) {
|
||||
window.art.destroy()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe(plugin === 'published' ? '' : null)
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
expect(await page.locator('.art-video-player').count()).toBe(0)
|
||||
})
|
||||
@@ -200,7 +225,7 @@ for (const core of ['published', 'candidate']) {
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe(plugin === 'published' ? '' : null)
|
||||
})
|
||||
}
|
||||
}
|
||||
+31
-20
@@ -1,10 +1,27 @@
|
||||
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'
|
||||
|
||||
async function readFactory(bytes, esm) {
|
||||
if (esm)
|
||||
return (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
|
||||
const context = {
|
||||
module: { exports: {} },
|
||||
Audio: function Audio() {
|
||||
return new globalThis.Audio()
|
||||
},
|
||||
console,
|
||||
}
|
||||
context.exports = context.module.exports
|
||||
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
|
||||
return context.module.exports
|
||||
}
|
||||
|
||||
export async function audioImplementations() {
|
||||
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
|
||||
const archive = await ensureArchive(release)
|
||||
@@ -13,29 +30,16 @@ export async function audioImplementations() {
|
||||
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: {} },
|
||||
Audio: function Audio() {
|
||||
return new globalThis.Audio()
|
||||
},
|
||||
console,
|
||||
}
|
||||
context.exports = context.module.exports
|
||||
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
|
||||
factory = context.module.exports
|
||||
}
|
||||
const factory = await readFactory(bytes, format === 'module')
|
||||
implementations.push({ name: `published-${format}`, factory })
|
||||
}
|
||||
for (const file of (process.env.ARTPLAYER_TEST_AUDIO || '').split(path.delimiter).filter(Boolean))
|
||||
implementations.push({ name: `artifact-${path.basename(file)}`, factory: await readFactory(fs.readFileSync(file), file.endsWith('.mjs')) })
|
||||
return implementations
|
||||
}
|
||||
|
||||
// Records plugin intent only. It does not decode media or emulate browser timing.
|
||||
export function audioHost(t) {
|
||||
export function audioHost(t, initialize = () => {}) {
|
||||
const instances = []
|
||||
const warnings = []
|
||||
let play = () => Promise.resolve()
|
||||
@@ -43,17 +47,20 @@ export function audioHost(t) {
|
||||
constructor() {
|
||||
instances.push(this)
|
||||
this.calls = []
|
||||
this.values = new Map()
|
||||
this.paused = true
|
||||
for (const [name, initial] of Object.entries({ src: '', preload: '', currentTime: 0, playbackRate: 1, volume: 1, muted: false })) {
|
||||
let value = initial
|
||||
this.values.set(name, initial)
|
||||
Object.defineProperty(this, name, {
|
||||
get: () => value,
|
||||
configurable: true,
|
||||
get: () => this.values.get(name),
|
||||
set: (next) => {
|
||||
this.calls.push([name, next])
|
||||
value = next
|
||||
this.values.set(name, next)
|
||||
},
|
||||
})
|
||||
}
|
||||
initialize(this)
|
||||
}
|
||||
|
||||
play() {
|
||||
@@ -63,6 +70,10 @@ export function audioHost(t) {
|
||||
|
||||
pause() { this.calls.push(['pause']) }
|
||||
load() { this.calls.push(['load']) }
|
||||
removeAttribute(name) {
|
||||
this.calls.push(['removeAttribute', name])
|
||||
this.values.set(name, '')
|
||||
}
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Audio')
|
||||
Object.defineProperty(globalThis, 'Audio', { configurable: true, value: Audio })
|
||||
|
||||
Reference in new issue
Block a user