refactor(core): CORE-15 type subtitles and own request and track lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 07:23:10 +08:00
1 parent 64d1939daa
commit 1b83081e8f
28 files changed
+11286 -405

No files matched your search

+455
View File
@@ -0,0 +1,455 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const container = document.createElement('div')
Object.assign(container.style, { width: '640px', height: '360px' })
document.body.append(container)
window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
window.subtitleRequests = new Map()
window.subtitleURLs = []
window.subtitleRevoked = []
const fetchOriginal = window.fetch
const create = URL.createObjectURL.bind(URL)
const revoke = URL.revokeObjectURL.bind(URL)
URL.createObjectURL = (blob) => {
const url = create(blob)
window.subtitleURLs.push(url)
return url
}
URL.revokeObjectURL = (url) => {
window.subtitleRevoked.push(url)
revoke(url)
}
window.fetch = (url, options) => {
if (!String(url).startsWith('/controlled-subtitle/'))
return fetchOriginal(url, options)
// Deliberately ignore AbortSignal: generation checks must work with custom fetch adapters.
return new Promise((resolve, reject) => window.subtitleRequests.set(url, { resolve, reject, signal: options?.signal }))
}
window.completeSubtitle = (name, text) => window.subtitleRequests.get(`/controlled-subtitle/${name}.vtt`).resolve(new Response(`WEBVTT\n\n00:00:00.000 --> 00:00:07.000\n${text}\n`))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const format of ['srt', 'ass']) {
test(`${core}: ${format} conversion reaches native cues with original callback binding`, async ({ page }) => {
await setup(page, core)
await page.evaluate(async (format) => {
const art = window.art
window.subtitleConversion = []
const request = art.subtitle.switch('/controlled-subtitle/convert.vtt', { type: format, onVttLoad(vtt) {
window.subtitleConversion.push({ bound: this === art.subtitle.option, vtt: vtt.startsWith('WEBVTT') })
return vtt
} })
const text = format === 'srt'
? '1\n00:00:01,200 --> 00:00:03,400\nConverted\n'
: '[Events]\nDialogue: 0,0:00:01.20,0:00:03.40,Default,,0,0,0,,Converted\n'
window.subtitleRequests.get('/controlled-subtitle/convert.vtt').resolve(new Response(text))
await request
}, format)
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
expect(await page.evaluate(() => {
const cue = window.art.subtitle.cues[0]
return { text: cue.text, start: cue.startTime, end: cue.endTime, conversion: window.subtitleConversion }
})).toEqual({ text: 'Converted', start: 1.2, end: 3.4, conversion: [{ bound: true, vtt: true }] })
await page.evaluate(() => {
window.art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
})
})
}
test(`${core}: video fullscreen signal rebuilds track kind while retaining its URL`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const request = art.subtitle.switch('/controlled-subtitle/mobile-fullscreen.vtt')
window.completeSubtitle('mobile-fullscreen', 'Mobile fullscreen branch')
const url = await request
const kinds = []
// This drives the WebKit-specific signal branch; it does not emulate a physical iOS fullscreen surface.
Object.defineProperty(art.template.$video, 'webkitDisplayingFullscreen', { configurable: true, value: true })
art.emit('video:timeupdate')
kinds.push(art.template.$track.kind)
const fullscreenTrack = art.template.$track
art.emit('video:timeupdate')
const stable = art.template.$track === fullscreenTrack
Object.defineProperty(art.template.$video, 'webkitDisplayingFullscreen', { configurable: true, value: false })
art.emit('video:timeupdate')
kinds.push(art.template.$track.kind)
const result = { kinds, stable, sameURL: art.subtitle.url === url, notRevoked: !window.subtitleRevoked.includes(url), oldHandlerCleared: fullscreenTrack.onload === null }
art.destroy()
for (const item of window.subtitleURLs) URL.revokeObjectURL(item)
return result
})
expect(result).toEqual({ kinds: ['subtitles', 'metadata'], stable: true, sameURL: true, notRevoked: true, oldHandlerCleared: true })
})
test(`${core}: subtitle remains visible through real native fullscreen`, async ({ page }, testInfo) => {
await setup(page, core)
await page.evaluate(async () => {
const request = window.art.subtitle.switch('/controlled-subtitle/fullscreen.vtt')
window.completeSubtitle('fullscreen', 'Fullscreen cue')
await request
window.initialSubtitleTrack = window.art.template.$track
document.querySelector('#play').onclick = () => {
window.art.fullscreen = true
}
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await page.evaluate(() => {
window.art.seek = 2
})
await expect.poll(() => page.evaluate(() => window.art.template.$subtitle.textContent.trim())).toBe('Fullscreen cue')
const native = await page.evaluate(() => Boolean(document.fullscreenEnabled || document.webkitFullscreenEnabled))
await testInfo.attach('subtitle-fullscreen-capability', { contentType: 'application/json', body: JSON.stringify({ native }) })
expect(native, 'Desktop engine must execute native fullscreen in this test').toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => (document.fullscreenElement || document.webkitFullscreenElement) === window.art.template.$player)).toBe(true)
expect(await page.evaluate(() => ({ text: window.art.template.$subtitle.textContent.trim(), track: window.art.template.$track === window.initialSubtitleTrack, visible: window.art.subtitle.show }))).toEqual({ text: 'Fullscreen cue', track: true, visible: true })
await page.evaluate(() => {
window.art.fullscreen = false
})
await expect.poll(() => page.evaluate(() => !window.art.fullscreen)).toBe(true)
await page.evaluate(() => {
window.art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
})
})
test(`${core}: native subtitle cues, offset, styles and show contracts`, async ({ page }) => {
await setup(page, core)
await page.evaluate(async () => {
const art = window.art
window.subtitleLoads = []
art.on('subtitleLoad', (cues, option) => window.subtitleLoads.push({ array: Array.isArray(cues), native: cues[0] instanceof VTTCue, sameOption: option === art.subtitle.option }))
const request = art.subtitle.switch('/controlled-subtitle/native.vtt', { style: { color: 'red' } })
window.completeSubtitle('native', '<b>One</b>\nTwo')
await request
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await expect.poll(() => page.evaluate(() => window.subtitleLoads.length)).toBe(1)
await page.evaluate(() => {
window.art.seek = 2
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.activeCues.length)).toBe(1)
await page.evaluate(() => {
window.art.subtitle.cues[0].text = '<b>One</b>\n \nTwo'
})
await expect.poll(() => page.evaluate(() => {
window.art.subtitle.update()
return window.art.template.$subtitle.querySelectorAll('.art-subtitle-line').length
})).toBe(2)
const result = await page.evaluate(() => {
const art = window.art
const before = art.subtitle.cues[0]
const lines = Array.from(art.template.$subtitle.querySelectorAll('.art-subtitle-line'), element => ({ text: element.textContent.trim(), group: element.dataset.group }))
const escaped = !art.template.$subtitle.querySelector('b')
const style = art.subtitle.style('fontSize', '22px') === art.template.$subtitle
const color = art.template.$subtitle.style.color
art.option.subtitle.escape = false
art.subtitle.update()
const raw = art.template.$subtitle.querySelector('b')?.textContent
art.subtitleOffset = 1
const offset = { start: before.startTime, end: before.endTime, value: art.subtitleOffset, original: before.originalStartTime, identity: before === art.subtitle.cues[0] }
const showEvents = []
art.on('subtitle', value => showEvents.push(value))
art.subtitle.show = false
art.subtitle.toggle()
const shown = art.subtitle.show
const result = { lines, escaped, style, color, offset, showEvents, shown, raw, loads: window.subtitleLoads }
art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual({ lines: [{ text: '<b>One</b>', group: '0' }, { text: 'Two', group: '0' }], escaped: true, style: true, color: 'red', offset: { start: 1, end: 8, value: 1, original: 0, identity: true }, showEvents: [false, true], shown: true, raw: 'One', loads: [{ array: true, native: true, sameOption: true }] })
})
test(`${core}: subtitle latest request wins and cancelled calls settle`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const first = art.subtitle.switch('/controlled-subtitle/first.vtt', { name: 'First' })
const second = art.subtitle.switch('/controlled-subtitle/second.vtt', { name: 'Second' })
window.completeSubtitle('second', 'Second')
const latest = await second
window.completeSubtitle('first', 'First')
const stale = await first
const result = { winner: art.subtitle.url === latest, cancelled: stale === undefined, notice: art.template.$notice.textContent.trim(), aborted: window.subtitleRequests.get('/controlled-subtitle/first.vtt').signal?.aborted === true }
art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual(core === 'candidate'
? { winner: true, cancelled: true, notice: 'Switch Subtitle: Second', aborted: true }
: { winner: false, cancelled: false, notice: 'Switch Subtitle: First', aborted: false })
})
test(`${core}: subtitle pending completion cannot replace track after destroy`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const track = art.template.$track
const request = art.subtitle.switch('/controlled-subtitle/late.vtt')
art.destroy(false)
window.completeSubtitle('late', 'Late')
const value = await request
const result = { unchanged: art.template.$track === track, cancelled: value === undefined, created: window.subtitleURLs.length }
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual(core === 'candidate' ? { unchanged: true, cancelled: true, created: 0 } : { unchanged: false, cancelled: false, created: 1 })
})
test(`${core}: subtitle owns generated URLs and leaves caller URLs alive`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const foreign = URL.createObjectURL(new Blob(['WEBVTT\n\n00:00:00.000 --> 00:00:07.000\nForeign\n'], { type: 'text/vtt' }))
await art.subtitle.switch(foreign, { type: 'external' })
const request = art.subtitle.switch('/controlled-subtitle/owned.vtt')
window.completeSubtitle('owned', 'Owned')
const owned = await request
art.destroy()
const result = { foreignRevoked: window.subtitleRevoked.includes(foreign), ownedRevoked: window.subtitleRevoked.includes(owned) }
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual(core === 'candidate' ? { foreignRevoked: false, ownedRevoked: true } : { foreignRevoked: true, ownedRevoked: false })
})
}
test('candidate: subtitle cancellation settles before an ignored request or body finishes', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const art = window.art
window.cancelledSubtitles = []
art.subtitle.switch('/controlled-subtitle/never.vtt').then(value => window.cancelledSubtitles.push(value === undefined))
const second = art.subtitle.switch('/controlled-subtitle/body.vtt')
second.then(value => window.cancelledSubtitles.push(value === undefined))
window.subtitleRequests.get('/controlled-subtitle/body.vtt').resolve({ ok: true, arrayBuffer: () => new Promise((resolve) => {
window.finishSubtitleBody = resolve
}) })
})
await expect.poll(() => page.evaluate(() => typeof window.finishSubtitleBody)).toBe('function')
await page.evaluate(() => window.art.subtitle.switch(''))
await expect.poll(() => page.evaluate(() => window.cancelledSubtitles)).toEqual([true, true])
expect(await page.evaluate(async () => {
window.finishSubtitleBody(new TextEncoder().encode('WEBVTT').buffer)
window.subtitleRequests.get('/controlled-subtitle/never.vtt').reject(new Error('obsolete transport'))
await new Promise(resolve => setTimeout(resolve, 0))
const count = window.subtitleURLs.length
window.art.destroy()
return count
})).toBe(0)
})
test('candidate: subtitle conversion reentry preserves the newer operation and notice', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
let second
const first = art.subtitle.switch('/controlled-subtitle/first.vtt', { name: 'Obsolete', onVttLoad(vtt) {
second = art.subtitle.switch('/controlled-subtitle/second.vtt', { name: 'Winner' })
window.completeSubtitle('second', 'Winner')
return vtt
} })
window.completeSubtitle('first', 'Obsolete')
const cancelled = await first
const winner = await second
const result = { cancelled: cancelled === undefined, winner: art.subtitle.url === winner, notice: art.template.$notice.textContent.trim(), generated: window.subtitleURLs.length }
art.destroy()
return result
})
expect(result).toEqual({ cancelled: true, winner: true, notice: 'Switch Subtitle: Winner', generated: 1 })
})
test('candidate: active subtitle errors reject, preserve track and allow a later retry', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const initial = art.template.$track
const error = new Error('controlled subtitle failure')
const promise = art.subtitle.switch('/controlled-subtitle/fail.vtt')
const failure = promise.catch(caught => caught === error)
window.subtitleRequests.get('/controlled-subtitle/fail.vtt').reject(error)
const rejected = await failure
const sameTrack = art.template.$track === initial
const notice = art.template.$notice.textContent.trim()
const retry = art.subtitle.switch('/controlled-subtitle/retry.vtt')
window.completeSubtitle('retry', 'Retry')
const url = await retry
const success = art.subtitle.url === url
art.destroy()
return { rejected, sameTrack, notice, success }
})
expect(result).toEqual({ rejected: true, sameTrack: true, notice: 'controlled subtitle failure', success: true })
})
test('candidate: subtitle HTTP failures do not become usable tracks', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const track = art.template.$track
const request = art.subtitle.switch('/controlled-subtitle/http.vtt').catch(error => error.message)
window.subtitleRequests.get('/controlled-subtitle/http.vtt').resolve(new Response('not subtitles', { status: 503, statusText: 'Unavailable' }))
const error = await request
const result = { error, sameTrack: track === art.template.$track, created: window.subtitleURLs.length }
art.destroy()
return result
})
expect(result).toEqual({ error: 'Failed to load subtitle: 503 Unavailable', sameTrack: true, created: 0 })
})
test('candidate: disabled native tracks expose empty cue arrays without throwing', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.subtitle.textTrack.mode = 'disabled'
const result = [art.subtitle.cues.length, art.subtitle.activeCues.length]
art.subtitle.update()
art.destroy()
return result
})).toEqual([0, 0])
})
test('candidate: native subtitle track failure is reported and obsolete callbacks are removed', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(async () => {
const request = window.art.subtitle.switch('/controlled-subtitle/track-error.vtt', { type: 'external' })
window.completeSubtitle('track-error', 'Not served to track')
await request
})
await expect.poll(() => page.evaluate(() => window.art.template.$notice.textContent.trim())).toBe('Failed to load subtitle track')
const result = await page.evaluate(async () => {
const art = window.art
const previous = art.template.$track
const oldError = previous.onerror
const request = art.subtitle.switch('/controlled-subtitle/recovered.vtt', { name: 'Recovered' })
window.completeSubtitle('recovered', 'Recovered')
await request
oldError(new Event('error'))
const result = { notice: art.template.$notice.textContent.trim(), callbacksCleared: previous.onload === null && previous.onerror === null }
const current = art.template.$track
art.destroy()
result.destroyCleared = current.onload === null && current.onerror === null
return result
})
expect(result).toEqual({ notice: 'Switch Subtitle: Recovered', callbacksCleared: true, destroyCleared: true })
})
test('candidate: constructor and URL setter failures notify without unhandled promises', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const container = window.art.option.container
window.art.destroy()
const art = window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, subtitle: { url: '/controlled-subtitle/constructor.vtt' } })
window.subtitleNoticeWrites = []
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(art.notice), 'show')
Object.defineProperty(art.notice, 'show', { get: descriptor.get, set(value) {
descriptor.set.call(this, value)
window.subtitleNoticeWrites.push(art.template.$notice.textContent.trim())
} })
window.subtitleRequests.get('/controlled-subtitle/constructor.vtt').reject(new Error('constructor subtitle error'))
window.subtitleRetry = () => {
art.subtitle.url = '/controlled-subtitle/setter.vtt'
window.subtitleRequests.get('/controlled-subtitle/setter.vtt').reject(new Error('setter subtitle error'))
}
})
await expect.poll(() => page.evaluate(() => window.subtitleNoticeWrites)).toContain('constructor subtitle error')
await page.evaluate(() => window.subtitleRetry())
await expect.poll(() => page.evaluate(() => window.subtitleNoticeWrites)).toContain('setter subtitle error')
await page.evaluate(() => window.art.destroy())
})
for (const fail of [false, true]) {
test(`candidate: reentrant track replacement keeps the winner when outer throws=${fail}`, async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async (fail) => {
const art = window.art
const proxy = art.events.proxy
let winner
const failure = new Error('outer track registration failure')
art.events.proxy = (...args) => {
art.events.proxy = proxy
art.subtitle.createTrack('subtitles', art.subtitle.url)
winner = art.template.$track
if (fail)
throw failure
return proxy(...args)
}
const promise = art.subtitle.switch('/controlled-subtitle/reentry.vtt').catch(error => error === failure)
window.completeSubtitle('reentry', 'Reentry')
const settled = await promise
let updates = 0
art.subtitle.update = () => updates++
winner.track.dispatchEvent(new Event('cuechange'))
const result = { winner: art.template.$track === winner, kind: winner.kind, updates, settled: fail ? settled === true : settled === undefined, live: !window.subtitleRevoked.includes(winner.src) }
art.destroy()
result.released = window.subtitleRevoked.includes(winner.src)
return result
}, fail)
expect(result).toEqual({ winner: true, kind: 'subtitles', updates: 1, settled: true, live: true, released: true })
})
}
test('candidate: failed track insertion restores the old live track and releases the attempted URL', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const first = art.subtitle.switch('/controlled-subtitle/old.vtt')
window.completeSubtitle('old', 'Old')
const oldURL = await first
const track = art.template.$track
const video = art.template.$video
const append = video.appendChild
const failure = new Error('insert failure')
video.appendChild = () => {
throw failure
}
const attempt = art.subtitle.switch('/controlled-subtitle/failed.vtt').catch(error => error === failure)
window.completeSubtitle('failed', 'Failed')
const rejected = await attempt
video.appendChild = append
let updates = 0
art.subtitle.update = () => updates++
track.track.dispatchEvent(new Event('cuechange'))
const result = { rejected, same: art.template.$track === track, attached: track.parentNode === video, updates, oldLive: !window.subtitleRevoked.includes(oldURL), newReleased: window.subtitleRevoked.includes(window.subtitleURLs[1]) }
art.destroy()
return result
})
expect(result).toEqual({ rejected: true, same: true, attached: true, updates: 1, oldLive: true, newReleased: true })
})
test('candidate: cleanup failure after track commit does not revoke the committed URL', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const first = art.subtitle.switch('/controlled-subtitle/old.vtt')
window.completeSubtitle('old', 'Old')
const oldURL = await first
const oldCleanup = art.subtitle.destroyEvent
const remove = art.events.remove.bind(art.events)
art.events.remove = (cleanup) => {
remove(cleanup)
if (cleanup === oldCleanup)
throw new Error('old cleanup failed')
}
const attempt = art.subtitle.switch('/controlled-subtitle/committed.vtt').then(() => false, () => true)
window.completeSubtitle('committed', 'Committed')
const rejected = await attempt
art.events.remove = remove
const url = art.subtitle.url
let updates = 0
art.subtitle.update = () => updates++
art.subtitle.textTrack.dispatchEvent(new Event('cuechange'))
const result = { rejected, replaced: url !== oldURL, updates, oldReleased: window.subtitleRevoked.includes(oldURL), currentLive: !window.subtitleRevoked.includes(url) }
art.destroy()
result.released = window.subtitleRevoked.includes(url)
return result
})
expect(result).toEqual({ rejected: true, replaced: true, updates: 1, oldReleased: true, currentLive: true, released: true })
})
+172
View File
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Exercise resource promises with Node's test runner.
import { test as it } from 'node:test'
import { loadModules } from './helpers/load.js'
const { ResourceScope, SubtitleRequest, parseSubtitle, subtitleOffsetMix, beginLifecycle, initSubtitleState, replaceSubtitleTrack } = await loadModules({
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
SubtitleRequest: 'packages/artplayer/src/subtitle/request',
parseSubtitle: { file: 'packages/artplayer/src/subtitle/parse', name: 'parseSubtitle' },
subtitleOffsetMix: 'packages/artplayer/src/player/subtitleOffsetMix',
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' },
})
it('subtitle track attribute failure releases its new scope and preserves the original error', () => {
const original = globalThis.document
const error = new Error('label getter failed')
const view = { art: { option: { subtitle: { get name() {
throw error
} } }, template: { $track: { parentNode: null, nextSibling: null } } } }
beginLifecycle(view.art)
const state = initSubtitleState(view, view.art)
const child = state.scope.child.bind(state.scope)
let open = 0
state.scope.child = () => {
const scope = child()
open += 1
scope.add(() => {
open -= 1
})
return scope
}
try {
globalThis.document = { createElement: () => ({ track: {}, remove() {} }) }
for (let attempt = 0; attempt < 3; attempt++) {
assert.throws(() => replaceSubtitleTrack(view, 'metadata', '/test.vtt'), caught => caught === error)
assert.equal(open, 0)
}
}
finally {
state.scope.dispose()
globalThis.document = original
}
})
it('subtitle requests settle immediately on cancel, ignore late resolution and abort transport', async () => {
const root = new ResourceScope()
const request = new SubtitleRequest(root)
let resolve
const result = request.run(() => new Promise((done) => {
resolve = done
}))
request.cancel()
assert.equal(await result, undefined)
assert.equal(request.signal.aborted, true)
resolve('late')
root.dispose()
})
it('subtitle cancellation observes late rejection without rejecting the cancelled caller', async () => {
const root = new ResourceScope()
const request = new SubtitleRequest(root)
let reject
const result = request.run(() => new Promise((_resolve, fail) => {
reject = fail
}))
root.dispose()
assert.equal(await result, undefined)
reject(new Error('late failure'))
await new Promise(resolve => setImmediate(resolve))
})
it('subtitle successful value and active rejection retain their identity', async () => {
const root = new ResourceScope()
const value = {}
assert.equal(await new SubtitleRequest(root).run(async () => value), value)
const error = new Error('active failure')
await assert.rejects(new SubtitleRequest(root).run(async () => {
throw error
}), caught => caught === error)
root.dispose()
})
it('subtitle closed roots never execute work and repeated cancellation is harmless', async () => {
const root = new ResourceScope()
root.dispose()
const request = new SubtitleRequest(root)
let calls = 0
assert.equal(await request.run(async () => {
calls += 1
}), undefined)
request.cancel()
request.cancel()
assert.equal(calls, 0)
})
it('subtitle requests still cancel without AbortController', async () => {
const original = globalThis.AbortController
try {
globalThis.AbortController = undefined
const root = new ResourceScope()
const request = new SubtitleRequest(root)
assert.equal(request.signal, undefined)
const result = request.run(() => new Promise(() => {}))
root.dispose()
assert.equal(await result, undefined)
}
finally {
globalThis.AbortController = original
}
})
it('subtitle conversion keeps callback this, encoded text and inferred extension', () => {
const option = { url: '/sub.SRT?token=1', encoding: 'utf-8', type: '', onVttLoad(vtt) {
assert.equal(this, option)
return `${vtt}NOTE transformed\n`
} }
const buffer = new TextEncoder().encode('1\n00:00:00,1 --> 00:00:01,23\nHello\n').buffer
const result = parseSubtitle(buffer, option)
assert.match(result.vtt, /00:00:00\.100 --> 00:00:01\.230/)
assert.match(result.vtt, /NOTE transformed/)
const latin = parseSubtitle(Uint8Array.from([0x63, 0x61, 0x66, 0xE9]).buffer, { ...option, type: 'vtt', encoding: 'windows-1252', onVttLoad: value => value })
assert.equal(latin.vtt, 'café')
})
it('subtitle pass-through skips conversion callback; invalid decoder and callback errors propagate', () => {
const buffer = new ArrayBuffer(0)
assert.equal(parseSubtitle(buffer, { url: '/subtitle.unknown', encoding: 'utf-8', onVttLoad() {
assert.fail('unexpected callback')
} }), undefined)
assert.throws(() => parseSubtitle(buffer, { url: '/subtitle.vtt', encoding: 'invalid-encoding' }), RangeError)
const error = new Error('callback failure')
assert.throws(() => parseSubtitle(buffer, { url: '/subtitle.vtt', onVttLoad() {
throw error
} }), caught => caught === error)
// Unknown formats and invalid JS callback return values must remain distinct paths.
assert.deepEqual(parseSubtitle(buffer, { url: '/subtitle.vtt', onVttLoad() {} }), { vtt: undefined })
})
it('subtitle offsets preserve cue identity and original bounds across repeated changes', () => {
const cue = { startTime: 1, endTime: 5 }
const events = []
const art = { template: { $track: {} }, duration: 8, subtitle: { cues: [cue], update() {
events.push('update')
} }, notice: {}, i18n: { get: key => key }, emit: (...args) => events.push(args) }
subtitleOffsetMix(art)
art.subtitleOffset = 2
assert.deepEqual(cue, { startTime: 3, endTime: 7, originalStartTime: 1, originalEndTime: 5 })
art.subtitleOffset = -3
assert.deepEqual(cue, { startTime: 0, endTime: 2, originalStartTime: 1, originalEndTime: 5 })
assert.equal(art.subtitleOffset, -3)
assert.equal(art.notice.show, 'Subtitle Offset: -3s')
assert.deepEqual(events, ['update', ['subtitleOffset', 2], 'update', ['subtitleOffset', -3]])
art.subtitleOffset = 20
assert.equal(art.subtitleOffset, 10)
assert.equal(art.notice.show, 'Subtitle Offset: 20s')
})
it('subtitle offsets with no cues retain the default state and do not notify', () => {
const art = { template: {}, subtitle: { cues: [], update() {
assert.fail()
} }, notice: {}, i18n: { get() {
assert.fail()
} }, emit() {
assert.fail()
} }
subtitleOffsetMix(art)
art.subtitleOffset = 1
assert.equal(art.subtitleOffset, 0)
assert.equal(Object.hasOwn(art.notice, 'show'), false)
})
+27
View File
@@ -0,0 +1,27 @@
import type Subtitle from '../../packages/artplayer/src/subtitle'
import type { SubtitleCue, SubtitleHost, SubtitleOption } from '../../packages/artplayer/src/subtitle/types'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Expect<T extends true> = T
declare const subtitle: Subtitle
declare const host: SubtitleHost
declare const options: SubtitleOption
subtitle.init(options)
subtitle.switch('/subtitle.vtt', { onVttLoad: vtt => vtt })
subtitle.style({ color: 'red' })
subtitle.style('fontSize', '22px')
subtitle.url = '/subtitle.srt'
host.emit('subtitleBeforeUpdate', subtitle.activeCues)
type Cues = Expect<Equal<typeof subtitle.cues, SubtitleCue[]>>
type Switch = Expect<Equal<ReturnType<Subtitle['switch']>, Promise<string | null | undefined>>>
type Update = Expect<Equal<ReturnType<Subtitle['update']>, void>>
type Style = Expect<Equal<ReturnType<Subtitle['style']>, HTMLDivElement>>
export type Assertions = [Cues, Switch, Update, Style]
// @ts-expect-error Subtitle update does not accept registry entries.
subtitle.update({ name: 'entry' })
// @ts-expect-error Conversion callbacks produce text synchronously.
subtitle.switch('/subtitle.vtt', { onVttLoad: async vtt => vtt })