mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-18 02:21:20 -08:00
fix(core): [CORE-SUBTITLE-OFFSET-01] refresh paused native cue membership and order
This commit is contained in:
1 parent
b5dee252d4
commit
efed847cdf
23 files changed
+6789
-27
No files matched your search
+13
-2
@@ -219,8 +219,19 @@ VTT/SRT selection, offsets/fullscreen/source changes and multi-instance URL
|
||||
ownership. Old1.0.0/1.1.0/1.2.0 factories run unchanged on the candidate core.
|
||||
Explicit single-active-cue probes record historical and candidate overlap defects;
|
||||
their passing assertions do not mean simultaneous captions are compatible.
|
||||
Paused Firefox offset checks remain failing pending a production fix, and the
|
||||
original WebKit source-switch failure is retained despite a passing rerun.
|
||||
Candidate paused Firefox offsets are corrected by CORE-SUBTITLE-OFFSET-01. Old
|
||||
Firefox hosts retain explicit defect observations; their behavior is not silently
|
||||
counted as correct. WebKit source-switch failures remain under investigation.
|
||||
See `refactor/changes/2026-09-14-PKG-MULTI-SUB-05-combinations.md` before changing
|
||||
expectations. Reports include native active cues and rendered text. The new frozen
|
||||
5.1.2 core route verifies archive integrity and member hashes before serving.
|
||||
|
||||
`subtitle-offset.spec.js` checks current/actual published core offset behavior,
|
||||
including synchronous native/public/rendered captions, identical cue objects,
|
||||
unchanged media time, disabled mode and resumed playback. The published Firefox
|
||||
branch asserts the original incorrect captions; the candidate must have the correct
|
||||
membership and order. `subtitle-offset-native.spec.js` loads a real HTML track
|
||||
without creating an Artplayer instance to compare native timing edits, cue
|
||||
reinsertion and mode toggling. It records actual time within the required interval,
|
||||
not a fabricated millisecond-exact seek landing. These diagnostic observations
|
||||
do not claim every native engine strategy is correct.
|
||||
@@ -38,7 +38,7 @@ async function seek(page, time) {
|
||||
}
|
||||
|
||||
for (const core of cores.filter(core => !['published-5.1.2', 'published-5.1.7'].includes(core))) {
|
||||
test(`Multiple subtitles candidate / ${core}: timed VTT and SRT, offsets, fullscreen and source change`, async ({ page, browser }, testInfo) => {
|
||||
test(`Multiple subtitles candidate / ${core}: timed VTT and SRT, offsets, fullscreen and source change`, async ({ page, browser, browserName }, testInfo) => {
|
||||
await prepare(page, core, candidate, browser, testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.art.plugins.add(window.multipleFactory({ subtitles: [{ name: 'a', url: '/test/combination.vtt' }, { name: 'b', url: '/test/combination.srt' }] }))
|
||||
@@ -54,14 +54,17 @@ for (const core of cores.filter(core => !['published-5.1.2', 'published-5.1.7'].
|
||||
window.art.subtitleOffset = 1
|
||||
})
|
||||
expect(await page.evaluate(() => Array.from(window.art.template.$track.track.cues, cue => [cue.startTime, cue.endTime]))).toEqual([[2, 4], [2, 4], [5, 7]])
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCount(0)
|
||||
const staleHistoricalOffset = core !== 'candidate' && browserName === 'firefox'
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCount(staleHistoricalOffset ? 1 : 0)
|
||||
await seek(page, 2.5)
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveText('First')
|
||||
await page.evaluate(() => {
|
||||
window.art.subtitleOffset = -1
|
||||
})
|
||||
expect(await page.evaluate(() => Array.from(window.art.template.$track.track.cues, cue => [cue.startTime, cue.endTime]))).toEqual([[0, 2], [0, 2], [3, 5]])
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCount(0)
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCount(staleHistoricalOffset ? 1 : 0)
|
||||
if (staleHistoricalOffset)
|
||||
await testInfo.attach('historical-paused-offset-defect', { contentType: 'application/json', body: JSON.stringify({ core, browser: browser.version(), acceptedAsCorrect: false, observation: 'Published core keeps a stale active caption after paused offsets; candidate must clear it. Dedicated native/offset probes retain exact state.' }) })
|
||||
await page.evaluate(() => {
|
||||
window.art.subtitleOffset = 0
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const invalidation of ['time-only', 'remove-add', 'mode-toggle']) {
|
||||
test(`Native paused cue timing probe: ${invalidation}`, async ({ page, browser }, testInfo) => {
|
||||
await page.route('**/test/native-offset.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:01.000 --> 00:03.000\ncaption\n' }))
|
||||
await page.goto('/test/player.html')
|
||||
await page.evaluate(async () => {
|
||||
const video = document.createElement('video')
|
||||
video.muted = true
|
||||
video.src = '/test/pattern.mp4'
|
||||
document.body.append(video)
|
||||
window.probeVideo = video
|
||||
await new Promise(resolve => video.addEventListener('loadedmetadata', resolve, { once: true }))
|
||||
const element = document.createElement('track')
|
||||
element.kind = 'metadata'
|
||||
element.src = '/test/native-offset.vtt'
|
||||
const loaded = new Promise(resolve => element.addEventListener('load', resolve, { once: true }))
|
||||
video.append(element)
|
||||
const track = element.track
|
||||
track.mode = 'hidden'
|
||||
await loaded
|
||||
window.probeTrack = track
|
||||
window.probeCue = track.cues[0]
|
||||
await video.play()
|
||||
video.pause()
|
||||
const sought = new Promise(resolve => video.addEventListener('seeked', resolve, { once: true }))
|
||||
video.currentTime = 1.5
|
||||
await sought
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.probeTrack.activeCues.length)).toBe(1)
|
||||
const state = await page.evaluate(async (invalidation) => {
|
||||
const track = window.probeTrack
|
||||
const cue = window.probeCue
|
||||
const read = () => ({ time: window.probeVideo.currentTime, paused: window.probeVideo.paused, active: Array.from(track.activeCues || [], cue => [cue.startTime, cue.endTime]), sameCue: track.cues[0] === cue })
|
||||
const before = read()
|
||||
cue.startTime = 2
|
||||
cue.endTime = 4
|
||||
const changed = read()
|
||||
if (invalidation === 'remove-add') {
|
||||
track.removeCue(cue)
|
||||
track.addCue(cue)
|
||||
}
|
||||
if (invalidation === 'mode-toggle') {
|
||||
track.mode = 'disabled'
|
||||
track.mode = 'hidden'
|
||||
}
|
||||
const immediate = read()
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const painted = read()
|
||||
window.probeVideo.remove()
|
||||
return { before, changed, immediate, painted }
|
||||
}, invalidation)
|
||||
await testInfo.attach('native-offset-probe', { contentType: 'application/json', body: JSON.stringify({ invalidation, browser: browser.version(), state, scope: 'Native video/TextTrack only, no Artplayer instance; two paint callbacks observe invalidation without moving playback time' }) })
|
||||
expect(state.before.time).toBeGreaterThanOrEqual(1)
|
||||
expect(state.before.time).toBeLessThan(2)
|
||||
expect(state.before).toMatchObject({ paused: true, active: [[1, 3]], sameCue: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const vtt = 'WEBVTT\n\n00:01.000 --> 00:03.000\nFirst\n\n00:01.000 --> 00:03.000\nOverlap\n\n00:04.000 --> 00:06.000\nLater\n'
|
||||
|
||||
for (const core of ['candidate', 'published']) {
|
||||
test(`${core}: ${core === 'candidate' ? 'paused subtitle offsets refresh native membership without moving media' : 'historical paused subtitle offset behavior'}`, async ({ page, browserName }, testInfo) => {
|
||||
await page.route('**/test/offset.vtt', route => route.fulfill({ contentType: 'text/vtt', body: vtt }))
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate(() => window.art.subtitle.switch('/test/offset.vtt'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(3)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
window.art.once('video:seeked', resolve)
|
||||
window.art.seek = 1.5
|
||||
}))
|
||||
await expect(page.locator('.art-subtitle-line')).toHaveCount(2)
|
||||
const records = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const track = art.template.$track.track
|
||||
const cues = Array.from(track.cues)
|
||||
const events = []
|
||||
art.on('subtitleOffset', value => events.push(value))
|
||||
const states = []
|
||||
const time = art.currentTime
|
||||
for (const offset of [1, -1, 0, -3, 0]) {
|
||||
art.subtitleOffset = offset
|
||||
states.push({ offset, time: art.currentTime, paused: art.video.paused, native: Array.from(track.activeCues, cue => cue.text), public: art.subtitle.activeCues.map(cue => cue.text), rendered: Array.from(art.template.$subtitle.querySelectorAll('.art-subtitle-line'), line => line.textContent.trim()), sameCues: cues.every(cue => Array.from(track.cues).includes(cue)), mode: track.mode })
|
||||
}
|
||||
return { states, events, time }
|
||||
})
|
||||
await testInfo.attach('paused-offset', { contentType: 'application/json', body: JSON.stringify({ core, records }) })
|
||||
expect(records.events).toEqual([1, -1, 0, -3, 0])
|
||||
const texts = core === 'published' && browserName === 'firefox'
|
||||
? [['First', 'Overlap'], ['First', 'Overlap'], ['First', 'Overlap'], ['First', 'Overlap', 'Later'], ['First', 'Overlap', 'Later']]
|
||||
: [[], ['First', 'Overlap'], ['First', 'Overlap'], ['Later'], ['First', 'Overlap']]
|
||||
expect(records.time).toBeCloseTo(1.5, 2)
|
||||
for (const [index, state] of records.states.entries()) {
|
||||
expect(state).toEqual({ offset: [1, -1, 0, -3, 0][index], time: records.time, paused: true, native: texts[index], public: texts[index], rendered: texts[index], sameCues: true, mode: 'hidden' })
|
||||
}
|
||||
if (core === 'candidate') {
|
||||
const disabled = await page.evaluate(() => {
|
||||
const track = window.art.template.$track.track
|
||||
track.mode = 'disabled'
|
||||
window.art.subtitleOffset = 2
|
||||
return { mode: track.mode, active: window.art.subtitle.activeCues.length, offset: window.art.subtitleOffset }
|
||||
})
|
||||
expect(disabled).toEqual({ mode: 'disabled', active: 0, offset: 0 })
|
||||
await page.evaluate(() => {
|
||||
window.art.template.$track.track.mode = 'hidden'
|
||||
})
|
||||
await page.locator('#play').click()
|
||||
await page.evaluate(() => {
|
||||
window.art.subtitleOffset = 1
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(2.25)
|
||||
await expect(page.locator('.art-subtitle-line')).toHaveCount(2)
|
||||
await page.locator('#pause').click()
|
||||
}
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
+82
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import { test as it } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { ResourceScope, SubtitleRequest, parseSubtitle, subtitleOffsetMix, beginLifecycle, initSubtitleState, replaceSubtitleTrack } = await loadModules({
|
||||
const { ResourceScope, SubtitleRequest, parseSubtitle, subtitleOffsetMix, beginLifecycle, initSubtitleState, replaceSubtitleTrack, refreshPausedCues } = await loadModules({
|
||||
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
|
||||
SubtitleRequest: 'packages/artplayer/src/subtitle/request',
|
||||
parseSubtitle: { file: 'packages/artplayer/src/subtitle/parse', name: 'parseSubtitle' },
|
||||
@@ -11,6 +11,7 @@ const { ResourceScope, SubtitleRequest, parseSubtitle, subtitleOffsetMix, beginL
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
initSubtitleState: { file: 'packages/artplayer/src/subtitle/state', name: 'initSubtitleState' },
|
||||
replaceSubtitleTrack: { file: 'packages/artplayer/src/subtitle/track', name: 'replaceSubtitleTrack' },
|
||||
refreshPausedCues: { file: 'packages/artplayer/src/subtitle/timing', name: 'refreshPausedCues' },
|
||||
})
|
||||
|
||||
it('subtitle track attribute failure releases its new scope and preserves the original error', () => {
|
||||
@@ -170,3 +171,83 @@ it('subtitle offsets with no cues retain the default state and do not notify', (
|
||||
assert.equal(art.subtitleOffset, 0)
|
||||
assert.equal(Object.hasOwn(art.notice, 'show'), false)
|
||||
})
|
||||
|
||||
it('paused offsets refresh stale native membership before rendering and notifying, preserving cue identity', () => {
|
||||
const cues = [{ startTime: 1, endTime: 3, text: 'First' }, { startTime: 4, endTime: 6, text: 'Later' }]
|
||||
const video = { paused: true, currentTime: 1.5 }
|
||||
const events = []
|
||||
let active = [cues[0]]
|
||||
const indexed = [...cues]
|
||||
const track = {
|
||||
mode: 'hidden',
|
||||
removeCue(cue) {
|
||||
events.push(['remove', cue.text])
|
||||
indexed.splice(indexed.indexOf(cue), 1)
|
||||
active = active.filter(item => item !== cue)
|
||||
},
|
||||
addCue(cue) {
|
||||
events.push(['add', cue.text])
|
||||
indexed.push(cue)
|
||||
active = indexed.filter(cue => cue.startTime <= video.currentTime && video.currentTime < cue.endTime)
|
||||
},
|
||||
get activeCues() { return active },
|
||||
}
|
||||
const art = { template: { $track: { track }, $video: video }, duration: 8, subtitle: { cues, update() {
|
||||
events.push(active.map(cue => cue.text))
|
||||
} }, notice: {}, i18n: { get: key => key }, emit: (...args) => events.push(args) }
|
||||
subtitleOffsetMix(art)
|
||||
art.subtitleOffset = 1
|
||||
const reindex = [['remove', 'First'], ['remove', 'Later'], ['add', 'First'], ['add', 'Later']]
|
||||
assert.deepEqual(events, [...reindex, [], ['subtitleOffset', 1]])
|
||||
events.length = 0
|
||||
art.subtitleOffset = -3
|
||||
assert.deepEqual(events, [...reindex, ['Later'], ['subtitleOffset', -3]])
|
||||
assert.equal(active[0], cues[1])
|
||||
assert.equal(video.currentTime, 1.5)
|
||||
assert.equal(video.paused, true)
|
||||
assert.equal(track.mode, 'hidden')
|
||||
assert.deepEqual(indexed, cues)
|
||||
})
|
||||
|
||||
it('paused refresh detects wrong members even when active counts match and preserves showing mode', () => {
|
||||
const expired = { startTime: 0, endTime: 1 }
|
||||
const current = { startTime: 1, endTime: 2 }
|
||||
const writes = []
|
||||
const track = { mode: 'showing', activeCues: [expired], removeCue: cue => writes.push(['remove', cue]), addCue: cue => writes.push(['add', cue]) }
|
||||
Object.defineProperty(track, 'mode', { get: () => 'showing', set: () => assert.fail('must retain mode') })
|
||||
refreshPausedCues(track, { paused: true, currentTime: 1 }, [expired, current])
|
||||
assert.deepEqual(writes, [['remove', expired], ['remove', current], ['add', expired], ['add', current]])
|
||||
})
|
||||
|
||||
it('paused refresh preserves correct overlap membership without toggling a native track', () => {
|
||||
const cues = [{ startTime: 0, endTime: 2 }, { startTime: 1, endTime: 3 }]
|
||||
const track = { mode: 'hidden', removeCue() {
|
||||
assert.fail('unnecessary native invalidation')
|
||||
}, activeCues: cues }
|
||||
refreshPausedCues(track, { paused: true, currentTime: 1.5 }, cues)
|
||||
})
|
||||
|
||||
it('paused refresh reindexes tied cues when native membership is correct but order drifted', () => {
|
||||
const first = { startTime: 0, endTime: 2 }
|
||||
const second = { startTime: 0, endTime: 2 }
|
||||
const added = []
|
||||
const track = { mode: 'hidden', activeCues: [second, first], removeCue() {}, addCue: cue => added.push(cue) }
|
||||
refreshPausedCues(track, { paused: true, currentTime: 1 }, [first, second])
|
||||
assert.deepEqual(added, [first, second])
|
||||
})
|
||||
|
||||
it('paused refresh does not enable disabled tracks or intervene in playing and limited media hosts', () => {
|
||||
for (const [mode, video] of [['disabled', { paused: true, currentTime: 1.5 }], ['hidden', { paused: false, currentTime: 1.5 }], ['hidden', undefined], ['hidden', { paused: true }], ['hidden', { paused: true, currentTime: Number.NaN }]]) {
|
||||
const track = {
|
||||
get mode() { return mode },
|
||||
set mode(_value) {
|
||||
assert.fail('unexpected track change')
|
||||
},
|
||||
get activeCues() {
|
||||
assert.fail('unexpected cue inspection')
|
||||
},
|
||||
}
|
||||
refreshPausedCues(track, video, [])
|
||||
}
|
||||
refreshPausedCues(undefined, { paused: true, currentTime: 1 }, [])
|
||||
})
|
||||
Reference in new issue
Block a user