mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 06:31:20 -08:00
fix(audio): [PKG-AUDIO-05] checkpoint zero-time resume and native buffering evidence
This commit is contained in:
1 parent
0218196df9
commit
d18c545675
25 files changed
+14373
-32
No files matched your search
@@ -97,3 +97,25 @@ The docs `assets/sample/video.mp4` remains an existing sample with its original
|
||||
tracked by BASE-MEDIA-01. The new synthetic fixtures do not resolve rights for existing media.
|
||||
Browser binaries/codec support vary by OS; these projects do not replace Safari/iOS/Android
|
||||
device checks. See the [official browser guidance](https://playwright.dev/docs/browsers).
|
||||
|
||||
## Withheld media diagnostics
|
||||
|
||||
`test/helpers/media-gate.js` binds a temporary HTTP server to loopback and withholds
|
||||
the tail of the pinned media until the test calls release(). Its default response
|
||||
keeps the full Content-Length and streams the prefix; the diagnostic completeRanges
|
||||
mode sends a bounded 206 response and holds the later range. Both preserve exact
|
||||
bytes and a strong ETag. Partial range semantics follow
|
||||
[RFC 9110 section 15.3.7](https://www.rfc-editor.org/rfc/rfc9110.html#name-partial-content).
|
||||
`node --test test/media-gate.test.js` verifies actual HTTP bytes, headers and release.
|
||||
|
||||
`audio-buffering.spec.js` requires trusted waiting after actual clock progression,
|
||||
then release, resumed clocks and synchronization across old/new core/plugin pairs.
|
||||
It currently has unresolved Windows WebKit failures; do not skip them or count
|
||||
the native diagnostic below as equivalent acceptance. See refactor/audio-validation.md.
|
||||
|
||||
`media-gate-native.spec.js` removes all ArtPlayer instances and uses a native element
|
||||
with preload=auto. It records whether playback and starvation were observed for
|
||||
both response modes. A passing diagnostic only proves observation plus successful
|
||||
playback after release; inspect its progressed/waiting flags before claiming buffering.
|
||||
These capability observation timeouts do not relax the integration assertions.
|
||||
Archive the report and results (including traces) before starting another browser suite.
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { createMediaGate } from '../helpers/media-gate.js'
|
||||
import { useAudioFixture } from './audio-fixture.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const { openAudio } = useAudioFixture()
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const plugin of ['published', 'source']) {
|
||||
for (const owner of ['video', 'audio']) {
|
||||
test(`${core} core + ${plugin} audio: real ${owner} starvation resumes after network release`, async ({ page }, testInfo) => {
|
||||
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
|
||||
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
|
||||
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
|
||||
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit)
|
||||
try {
|
||||
await openAudio(page, core, plugin, testInfo, owner === 'audio' ? gate.url : '/test/audio-tone.m4a')
|
||||
await page.evaluate((owner) => {
|
||||
window.starvation = []
|
||||
const media = owner === 'video' ? window.art.video : window.audioPlugin.audio
|
||||
media.addEventListener('waiting', (event) => {
|
||||
window.starvation.push({ trusted: event.isTrusted, time: media.currentTime, readyState: media.readyState, audioPaused: window.audioPlugin.audio.paused, videoTime: window.art.currentTime })
|
||||
})
|
||||
}, owner)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime > 0.3 && window.art.currentTime > 0.3)).toBe(true)
|
||||
if (owner === 'video')
|
||||
await page.evaluate(url => window.art.switchUrl(url), gate.url)
|
||||
await expect.poll(() => page.evaluate(() => window.starvation.some(event => event.trusted && event.time > 0.3 && event.readyState < 3))).toBe(true)
|
||||
expect(gate.blocked).toBeGreaterThan(0)
|
||||
const stalled = await page.evaluate(() => ({ audio: window.audioPlugin.audio.currentTime, video: window.art.currentTime }))
|
||||
if (owner === 'video') {
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
}
|
||||
else {
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
|
||||
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
|
||||
}
|
||||
gate.release()
|
||||
await expect.poll(() => page.evaluate(() => !window.audioPlugin.audio.paused && !window.art.video.paused)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(stalled.audio + 0.3)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
|
||||
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime))).toBeLessThan(0.5)
|
||||
expect(await page.evaluate(() => [window.art.video.error, window.audioPlugin.audio.error])).toEqual([null, null])
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
await testInfo.attach('media-gate-requests', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests }) })
|
||||
const events = page.isClosed() ? { closed: true } : await page.evaluate(() => window.starvation)
|
||||
await testInfo.attach('media-gate', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests, events }) })
|
||||
if (!page.isClosed())
|
||||
await page.evaluate(() => window.art?.destroy())
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,33 +80,64 @@ for (const core of ['published', 'candidate']) {
|
||||
|
||||
test(`${core} core + ${plugin} audio: native offset bounds recover to an in-range seek`, async ({ page }, testInfo) => {
|
||||
await openAudio(page, core, plugin, testInfo)
|
||||
await page.evaluate(() => {
|
||||
window.boundaryAudio = new Audio('/test/audio-tone.m4a?boundary=native')
|
||||
window.boundaryAudio.muted = true
|
||||
document.querySelector('#play').onclick = () => Promise.all([window.art.play(), window.boundaryAudio.play()])
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.boundaryAudio.readyState)).toBeGreaterThanOrEqual(2)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
|
||||
await page.evaluate(() => {
|
||||
window.art.pause()
|
||||
window.boundaryAudio.pause()
|
||||
window.audioPlugin.update({ offset: -2 })
|
||||
window.art.seek = 0.5
|
||||
window.boundaryAudio.currentTime = -1.5
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
|
||||
const negative = await page.evaluate(() => ({ time: window.audioPlugin.audio.currentTime, error: window.audioPlugin.audio.error?.code || null }))
|
||||
expect(negative).toEqual({ time: 0, error: null })
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
|
||||
const negative = await page.evaluate(() => {
|
||||
const state = audio => ({ time: audio.currentTime, error: audio.error?.code || null })
|
||||
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
|
||||
})
|
||||
await testInfo.attach('negative-offset-native', { contentType: 'application/json', body: JSON.stringify(negative) })
|
||||
expect(negative.native.time).toBeGreaterThanOrEqual(0)
|
||||
expect(negative.native.time).toBeCloseTo(0, 1)
|
||||
expect(negative.audio.time).toBeGreaterThanOrEqual(0)
|
||||
expect(negative.audio.time).toBeCloseTo(negative.native.time, 1)
|
||||
expect([negative.audio.error, negative.native.error]).toEqual([null, null])
|
||||
await page.evaluate(() => {
|
||||
window.audioPlugin.update({ offset: 20 })
|
||||
window.art.seek = 1
|
||||
window.boundaryAudio.currentTime = 21
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBe(16)
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
|
||||
const overflow = await page.evaluate(() => {
|
||||
const state = audio => ({ time: audio.currentTime, duration: audio.duration, error: audio.error?.code || null })
|
||||
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
|
||||
})
|
||||
await testInfo.attach('overflow-offset-native', { contentType: 'application/json', body: JSON.stringify(overflow) })
|
||||
expect(overflow.native.duration).toBe(16)
|
||||
expect(overflow.native.time).toBeCloseTo(overflow.native.duration, 1)
|
||||
expect(overflow.audio.time).toBeCloseTo(overflow.native.time, 1)
|
||||
expect([overflow.audio.error, overflow.native.error]).toEqual([null, null])
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.audioPlugin.update({ offset: -0.5 })
|
||||
window.art.seek = 3
|
||||
window.boundaryAudio.currentTime = 2.5
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(2.5, 1)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(2.8)
|
||||
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime + 0.5))).toBeLessThan(0.5)
|
||||
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow: 16, recovered: true }) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow, recovered: true }) })
|
||||
await page.evaluate(() => {
|
||||
window.art.destroy()
|
||||
window.boundaryAudio.pause()
|
||||
window.boundaryAudio.removeAttribute('src')
|
||||
window.boundaryAudio.load()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ export function useAudioFixture() {
|
||||
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
|
||||
})
|
||||
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
|
||||
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
|
||||
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
|
||||
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:canplay', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
|
||||
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, videoPaused: window.art.video.paused, videoReadyState: window.art.video.readyState, playing: window.art.playing, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
|
||||
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
|
||||
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { createMediaGate } from '../helpers/media-gate.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const owner of ['video', 'audio']) {
|
||||
for (const completeRanges of [false, true]) {
|
||||
test(`native ${owner} ${completeRanges ? 'complete ranges' : 'streamed response'} capability without ArtPlayer instances`, async ({ page }, testInfo) => {
|
||||
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
|
||||
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
|
||||
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
|
||||
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit, completeRanges)
|
||||
try {
|
||||
await page.goto('/test/player.html')
|
||||
await page.evaluate(({ owner, url }) => {
|
||||
window.nativeMedia = document.createElement(owner)
|
||||
window.nativeMedia.preload = 'auto'
|
||||
window.nativeMedia.muted = true
|
||||
window.nativeEvents = []
|
||||
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'waiting', 'stalled', 'pause', 'error'])
|
||||
window.nativeMedia.addEventListener(event, e => window.nativeEvents.push({ event, trusted: e.isTrusted, time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState }))
|
||||
window.nativeMedia.src = url
|
||||
document.body.append(window.nativeMedia)
|
||||
document.querySelector('#play').onclick = () => window.nativeMedia.play()
|
||||
}, { owner, url: gate.url })
|
||||
await expect.poll(() => page.evaluate(() => window.nativeMedia.readyState >= 2 || Boolean(window.nativeMedia.error))).toBe(true)
|
||||
await page.locator('#play').click()
|
||||
let progressed = false
|
||||
let waiting = false
|
||||
try {
|
||||
await page.waitForFunction(() => window.nativeMedia.currentTime > 0.3, null, { timeout: 3000 })
|
||||
progressed = true
|
||||
await page.waitForFunction(() => window.nativeEvents.some(event => event.event === 'waiting' && event.trusted && event.time > 0.3 && event.readyState < 3), null, { timeout: 7000 })
|
||||
waiting = true
|
||||
}
|
||||
catch (error) {
|
||||
if (error.name !== 'TimeoutError')
|
||||
throw error
|
||||
}
|
||||
const before = await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused, error: window.nativeMedia.error?.code || null, instances: window.Artplayer.instances.length, events: window.nativeEvents }))
|
||||
expect(before.instances).toBe(0)
|
||||
expect(gate.blocked).toBeGreaterThan(0)
|
||||
gate.release()
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.nativeMedia.currentTime)).toBeGreaterThan(before.time + 0.3)
|
||||
expect(await page.evaluate(() => window.nativeMedia.error)).toBeNull()
|
||||
await testInfo.attach('native-gate-capability', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), limit, completeRanges, progressed, waiting, before, requests: gate.requests, after: await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused })) }) })
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
window.nativeMedia?.pause()
|
||||
window.nativeMedia?.removeAttribute('src')
|
||||
window.nativeMedia?.load()
|
||||
})
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user