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

+151
View File
@@ -0,0 +1,151 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { SubtitleCue, SubtitleHost, SubtitleOption } from './types'
import validator from 'option-validator'
import scheme from '../scheme'
import { setStyle, setStyles, vttToBlob } from '../utils'
import Component from '../utils/component'
import { parseSubtitle } from './parse'
import { renderSubtitle } from './render'
import { beginSubtitleRequest, initSubtitleState, subtitleState } from './state'
import { replaceSubtitleTrack } from './track'
// Retain Component's runtime prototype and method binding without its registry update type.
const SubtitleBase: new (art: SubtitleHost) => Omit<Component<SubtitleHost>, 'update'> = Component
export default class Subtitle extends SubtitleBase {
declare name: string
declare option: SubtitleOption | null
declare destroyEvent: () => unknown
constructor(art: SubtitleHost) {
super(art)
this.name = 'subtitle'
this.option = null
this.destroyEvent = () => null
const state = initSubtitleState(this, art)
// init reports active failures through notice; construction has no promise consumer.
void this.init(art.option.subtitle).catch(() => {})
let lastState = false
const timeupdate = () => {
if (state.scope.closed || !this.url)
return
const fullscreen = art.template.$video.webkitDisplayingFullscreen
if (typeof fullscreen === 'boolean' && fullscreen !== lastState) {
lastState = fullscreen
this.createTrack(fullscreen ? 'subtitles' : 'metadata', this.url)
}
}
art.on('video:timeupdate', timeupdate)
state.scope.add(() => {
art.off('video:timeupdate', timeupdate)
})
}
get url(): string {
return this.art.template.$track.src
}
set url(url: string) {
void this.switch(url).catch(() => {})
}
get textTrack(): TextTrack | undefined {
return this.art.template.$video?.textTracks?.[0]
}
get activeCues(): SubtitleCue[] {
// ArtPlayer's converted resources are WebVTT; disabled tracks expose null cue lists.
return Array.from(this.textTrack?.activeCues || []) as SubtitleCue[]
}
get cues(): SubtitleCue[] {
return Array.from(this.textTrack?.cues || []) as SubtitleCue[]
}
style(styles: Partial<CSSStyleDeclaration>): HTMLDivElement
style(key: string, value: string | number): HTMLDivElement
style(key: string | Partial<CSSStyleDeclaration>, value?: string | number): HTMLDivElement {
const { $subtitle } = this.art.template
return typeof key === 'object' ? setStyles($subtitle, key) : setStyle($subtitle, key, value)
}
update(): void {
renderSubtitle(this)
}
async switch(url: string, newOption: SubtitleInput = {}): Promise<string | null | undefined> {
const { i18n, notice, option } = this.art
const subtitleOption = { ...option.subtitle, ...newOption, url }
const state = subtitleState(this)
const revision = state.revision + 1
const subUrl = await this.init(subtitleOption)
if (!state.scope.closed && state.revision === revision && newOption.name)
notice.show = `${i18n.get('Switch Subtitle')}: ${newOption.name}`
return subUrl
}
createTrack(kind: string, url: string): void {
replaceSubtitleTrack(this, kind, url)
}
async init(subtitleOption: SubtitleOption): Promise<string | null | undefined> {
const state = subtitleState(this)
const request = beginSubtitleRequest(state)
const { notice, template: { $subtitle } } = this.art
return request.run(async () => {
if (!this.textTrack)
return null
validator(subtitleOption, scheme.subtitle)
if (!subtitleOption.url)
return undefined
this.option = subtitleOption
this.style(subtitleOption.style)
let generated: string | undefined
try {
if (!request.active)
return undefined
const response = await fetch(subtitleOption.url, { signal: request.signal })
if (!request.active)
return undefined
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`)
const buffer = await response.arrayBuffer()
if (!request.active)
return undefined
const vtt = parseSubtitle(buffer, subtitleOption)
if (!request.active)
return undefined
const subUrl = vtt === undefined ? subtitleOption.url : (generated = vttToBlob(vtt.vtt))
if (!request.active)
return undefined
$subtitle.innerHTML = ''
const trackRevision = state.trackRevision + 1
if (this.url !== subUrl)
this.createTrack('metadata', subUrl)
if (!request.active || state.trackRevision > trackRevision)
return undefined
if (generated) {
state.ownedURL = generated
generated = undefined
}
return subUrl
}
catch (error) {
if (!request.active)
return undefined
$subtitle.innerHTML = ''
notice.show = error
throw error
}
finally {
if (generated) {
// A replacement can commit before an old listener cleanup throws.
if (!state.scope.closed && this.url === generated)
state.ownedURL = generated
else
URL.revokeObjectURL(generated)
}
}
})
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { SubtitleOption } from './types'
import { getExt } from '../utils'
import { assToVtt, srtToVtt } from '../utils/subtitle'
// Keep conversion and callback binding identical to the public utility parsers.
export function parseSubtitle(buffer: ArrayBuffer, option: SubtitleOption): { vtt: string } | undefined {
const text = new TextDecoder(option.encoding).decode(buffer)
switch (option.type || getExt(option.url)) {
case 'srt': return { vtt: option.onVttLoad(srtToVtt(text)) }
case 'ass': return { vtt: option.onVttLoad(assToVtt(text)) }
case 'vtt': return { vtt: option.onVttLoad(text) }
default: return undefined
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { SubtitleView } from './types'
import { escape } from '../utils'
import { subtitleState } from './state'
export function renderSubtitle(subtitle: SubtitleView): void {
const state = subtitleState(subtitle)
if (state.scope.closed)
return
const revision = state.revision
const render = ++state.render
const active = () => !state.scope.closed && state.revision === revision && state.render === render
const { art } = subtitle
const { $subtitle } = art.template
$subtitle.innerHTML = ''
if (!subtitle.activeCues.length)
return
art.emit('subtitleBeforeUpdate', subtitle.activeCues)
if (!active())
return
const html = subtitle.activeCues.map((cue, index) => cue.text
.split(/\r?\n/)
.filter(line => line.trim())
.map(line => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`)
.join('')).join('')
if (!active())
return
$subtitle.innerHTML = html
art.emit('subtitleAfterUpdate', subtitle.activeCues)
}
@@ -0,0 +1,38 @@
import type ResourceScope from '../lifecycle/scope'
import { requestController } from '../lifecycle/resources'
// Cancellation settles callers even when a fetch adapter ignores AbortSignal.
export default class SubtitleRequest {
readonly scope: ResourceScope
readonly signal?: AbortSignal
private readonly cancelled: Promise<undefined>
constructor(parent: ResourceScope) {
this.scope = parent.child()
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(undefined)
})
})
this.signal = requestController(this.scope)?.signal
}
get active(): boolean {
return !this.scope.closed
}
cancel(): void {
this.scope.dispose()
}
async run<Result>(work: () => Promise<Result>): Promise<Result | undefined> {
if (!this.active)
return undefined
try {
return await Promise.race([work(), this.cancelled])
}
finally {
this.cancel()
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import type ResourceScope from '../lifecycle/scope'
import { getScope } from '../lifecycle/instance'
import SubtitleRequest from './request'
export interface SubtitleState {
scope: ResourceScope
request?: SubtitleRequest
track?: ResourceScope
ownedURL?: string
revision: number
render: number
trackRevision: number
}
const states = new WeakMap<object, SubtitleState>()
export function initSubtitleState(subtitle: object, art: object): SubtitleState {
const scope = getScope(art).child()
const state: SubtitleState = { scope, revision: 0, render: 0, trackRevision: 0 }
states.set(subtitle, state)
scope.add(() => {
releaseSubtitleURL(state)
})
return state
}
export function subtitleState(subtitle: object): SubtitleState {
return states.get(subtitle)!
}
export function beginSubtitleRequest(state: SubtitleState): SubtitleRequest {
const previous = state.request
const request = new SubtitleRequest(state.scope)
state.request = request
state.revision += 1
previous?.cancel()
return request
}
export function releaseSubtitleURL(state: SubtitleState): void {
const url = state.ownedURL
state.ownedURL = undefined
if (url)
URL.revokeObjectURL(url)
}
+83
View File
@@ -0,0 +1,83 @@
import type { SubtitleView } from './types'
import { releaseSubtitleURL, subtitleState } from './state'
export function replaceSubtitleTrack(subtitle: SubtitleView, kind: string, url: string): void {
const state = subtitleState(subtitle)
if (state.scope.closed)
return
const revision = ++state.trackRevision
const { art } = subtitle
const { template } = art
const previous = template.$track
const parent = previous.parentNode
const next = previous.nextSibling
const track = document.createElement('track')
const scope = state.scope.child()
scope.add(() => {
track.onload = null
track.onerror = null
})
let committed = false
try {
track.default = true
track.kind = kind
track.src = url
track.label = art.option.subtitle.name || 'Artplayer'
track.track.mode = 'hidden'
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit('subtitleLoad', subtitle.cues, subtitle.option)
}
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error('Failed to load subtitle track')
}
if (scope.closed || state.trackRevision !== revision) {
scope.dispose()
return
}
previous.remove()
template.$video.appendChild(track)
template.$track = track
const textTrack = subtitle.textTrack
const cleanup = textTrack
? art.proxy(textTrack, 'cuechange', () => {
if (!scope.closed && template.$track === track)
subtitle.update()
})
: () => {}
scope.add(() => {
art.events.remove(cleanup)
})
if (scope.closed || state.trackRevision !== revision) {
scope.dispose()
track.remove()
return
}
const previousScope = state.track
state.track = scope
subtitle.destroyEvent = cleanup
previous.onload = null
previous.onerror = null
committed = true
try {
previousScope?.dispose()
}
finally {
if (state.ownedURL && state.ownedURL !== url)
releaseSubtitleURL(state)
}
}
catch (error) {
if (committed)
throw error
scope.dispose()
track.remove()
if (state.scope.closed || state.trackRevision !== revision)
throw error
template.$track = previous
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null)
throw error
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { ComponentHost } from '../component/types'
import type { OptionalMediaCapabilities } from '../media/types'
export type SubtitleOption = Required<SubtitleInput>
export interface SubtitleCue extends TextTrackCue {
text: string
originalStartTime?: number
originalEndTime?: number
}
export interface SubtitleTrack extends HTMLTrackElement {
offset?: number
}
export interface SubtitleHost extends ComponentHost {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities, $subtitle: HTMLDivElement, $track: SubtitleTrack }
option: { subtitle: SubtitleOption }
proxy: ComponentHost['events']['proxy']
on: (name: 'video:timeupdate', callback: () => void) => unknown
off: (name: 'video:timeupdate', callback: () => void) => unknown
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
}
export interface SubtitleView {
art: SubtitleHost
option: SubtitleOption | null
destroyEvent: () => unknown
readonly cues: SubtitleCue[]
readonly activeCues: SubtitleCue[]
readonly textTrack: TextTrack | undefined
update: () => void
}
export interface SubtitleOffsetHost {
template: { $track?: SubtitleTrack | null }
subtitle: { readonly cues: SubtitleCue[], update: () => void }
readonly duration: number
notice: { set show(value: string) }
i18n: { get: (key: string) => string }
emit: (name: 'subtitleOffset', value: number) => unknown
}