refactor(multiple-subtitles): [PKG-MULTI-SUB-04] type runtime modules and vendor boundaries

This commit is contained in:
Harvey Zhao committed 2026-09-13 11:21:06 +08:00
1 parent beaa93d6cc
commit da64aae267
28 files changed
+7075 -86

No files matched your search

@@ -1,19 +1,21 @@
import type Artplayer from 'artplayer'
import type { Option, Result, Track } from './types'
import createLifetime from './lifetime'
import { parseTracks, serializeTracks } from './merge'
import createRenderer from './render'
import loadVtt from './request'
export default function artplayerPluginMultipleSubtitles({ subtitles = [] }) {
return async (art) => {
const { unescape, getExt, srtToVtt, assToVtt } = art.constructor.utils
export default function artplayerPluginMultipleSubtitles({ subtitles = [] }: Option) {
return async (art: Artplayer): Promise<Result> => {
const { unescape, getExt, srtToVtt, assToVtt } = (art.constructor as typeof Artplayer).utils
const lifetime = createLifetime(art)
const render = createRenderer(art, lifetime, unescape)
let trees = []
function setTracks(selected) {
let trees: Track[] = []
function setTracks(selected: readonly (Track | undefined)[]): void {
if (!lifetime.closed)
render(serializeTracks(selected))
}
const result = {
const result: Result = {
name: 'multipleSubtitles',
tracks(names = []) {
if (!lifetime.closed)
@@ -29,7 +31,8 @@ export default function artplayerPluginMultipleSubtitles({ subtitles = [] }) {
const vtts = await Promise.all(subtitles.map(option => loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime)))
if (lifetime.closed)
return result
trees = parseTracks(vtts, subtitles)
// A live completed request yields text; cancellation was checked above.
trees = parseTracks(vtts as string[], subtitles)
setTracks(trees)
return result
}
@@ -1,11 +1,13 @@
export default function createLifetime(art) {
import type { Cleanup, Lifetime, LifetimeHost } from './types'
export default function createLifetime(art: LifetimeHost): Lifetime {
let closed = Boolean(art.isDestroy)
const cleanups = new Set()
let cancel
const cancelled = new Promise((resolve) => {
const cleanups = new Set<Cleanup>()
let cancel!: Cleanup
const cancelled = new Promise<void>((resolve) => {
cancel = resolve
})
function run(cleanup) {
function run(cleanup: Cleanup): void {
try {
cleanup()
}
@@ -13,7 +15,7 @@ export default function createLifetime(art) {
console.warn('Failed to clean up multiple subtitles:', error)
}
}
const lifetime = {
const lifetime: Lifetime = {
get closed() { return closed },
own(cleanup) {
if (closed)
@@ -1,30 +0,0 @@
import { WebVTTParser, WebVTTSerializer } from './parser'
export function parseTracks(vtts, subtitles) {
const parser = new WebVTTParser()
return vtts.map((vtt, index) => {
const tree = parser.parse(vtt, 'metadata')
tree.url = subtitles[index].url
tree.name = subtitles[index].name
return tree
})
}
export function serializeTracks(trees) {
const cues = []
for (const tree of trees) {
for (const cue of tree.cues) {
cues.push({
...cue,
tree: {
...cue.tree,
children: cue.tree.children.map(child => ({
...child,
value: `<div class="art-subtitle-${tree.name}">${child.value}</div>`,
})),
},
})
}
}
return new WebVTTSerializer().serialize(cues)
}
@@ -0,0 +1,33 @@
import type { Cue, WrappedNode } from './parser'
import type { Track, TrackOption } from './types'
import { WebVTTParser, WebVTTSerializer } from './parser'
export function parseTracks(vtts: readonly string[], subtitles: readonly TrackOption[]): Track[] {
const parser = new WebVTTParser()
return vtts.map((vtt, index) => {
const tree = parser.parse(vtt, 'metadata')
// Options and completed downloads share the same Promise.all index.
return { ...tree, url: subtitles[index]!.url, name: subtitles[index]!.name }
})
}
export function serializeTracks(trees: readonly (Track | undefined)[]): string {
const cues: Cue<WrappedNode>[] = []
for (const selected of trees) {
// Unknown names retain the historical TypeError at the property access below.
const tree = selected!
for (const cue of tree.cues) {
cues.push({
...cue,
tree: {
...cue.tree,
children: cue.tree.children.map(child => ({
...child,
value: `<div class="art-subtitle-${tree.name}">${child.value}</div>`,
})),
},
})
}
}
return new WebVTTSerializer().serialize(cues)
}
@@ -0,0 +1,56 @@
export interface TextNode {
type: 'text'
value: string
}
export interface ObjectNode {
type: 'object'
name: string
classes: string[]
value?: string
children: CueNode[]
}
export interface TimestampNode {
type: 'timestamp'
value: number
}
export type CueNode = TextNode | ObjectNode | TimestampNode
// The historical plugin wraps every top-level value, including timestamp nodes.
export type WrappedNode = TextNode | (Omit<ObjectNode, 'value'> & { value: string }) | { type: 'timestamp', value: string }
export interface Cue<Node = CueNode> {
id: string
startTime: number
endTime: number
pauseOnExit: boolean
direction: 'horizontal' | 'rl' | 'lr'
snapToLines: boolean
linePosition: number | 'auto'
lineAlign: 'start' | 'center' | 'end'
textPosition: number | 'auto'
positionAlign: 'auto' | 'line-left' | 'center' | 'line-right'
size: number
alignment: 'start' | 'center' | 'end' | 'left' | 'right'
text: string
tree: { children: Node[] }
nonSerializable?: boolean
}
export interface ParseResult {
cues: Cue[]
errors: { message: string, line: number, col: number | undefined }[]
styles: string[]
time: number
}
// Private declaration for the vendored exports used by this package, not a new public API.
export declare class WebVTTParser {
constructor(entities?: Record<string, string>)
parse(input: string, mode: 'metadata'): ParseResult
}
export declare class WebVTTSerializer {
serialize(cues: readonly Cue<CueNode | WrappedNode>[], styles?: readonly string[]): string
}
@@ -1,9 +1,12 @@
export default function createRenderer(art, lifetime, unescape) {
let current = null
function own(url) {
import type { Subtitle, Utils } from 'artplayer'
import type { Lifetime, OwnedURL, RenderHost } from './types'
export default function createRenderer(art: RenderHost, lifetime: Lifetime, unescape: Utils['unescape']): (vtt: string) => void {
let current: OwnedURL | null = null
function own(url: string): OwnedURL {
let freed = false
let release = () => {}
const entry = {
let release: () => unknown = () => {}
const entry: OwnedURL = {
free() {
if (freed)
return
@@ -24,12 +27,13 @@ export default function createRenderer(art, lifetime, unescape) {
if (lifetime.closed)
return
const previous = current
let option
let escape
let option: Subtitle | undefined
let escape: boolean | undefined
let configured = false
current = entry
try {
option = art.option.subtitle
// ArtPlayer merges subtitle defaults; preserve errors for invalid custom hosts.
option = art.option.subtitle!
escape = option.escape
if (lifetime.closed || current !== entry)
return
@@ -52,8 +56,8 @@ export default function createRenderer(art, lifetime, unescape) {
catch (error) {
if (current === entry) {
current = previous
if (configured && option.escape === false)
option.escape = escape
if (configured && option!.escape === false)
option!.escape = escape
}
entry.free()
throw error
@@ -1,4 +1,6 @@
export default async function loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime) {
import type { Converters, Lifetime, TrackOption } from './types'
export default async function loadVtt(option: TrackOption, { getExt, srtToVtt, assToVtt }: Converters, lifetime: Lifetime): Promise<string | void> {
if (lifetime.closed)
return
const controller = typeof AbortController === 'function' ? new AbortController() : null
@@ -7,16 +9,17 @@ export default async function loadVtt(option, { getExt, srtToVtt, assToVtt }, li
const url = option.url
if (lifetime.closed)
return
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url))
// Keep native fetch coercion for historically optional URLs; do not add a default URL.
const response = await lifetime.wait(controller ? fetch(url!, { signal: controller.signal }) : fetch(url!))
if (lifetime.closed)
return
if (response.ok === false)
throw new Error(`Failed to fetch multiple subtitles: HTTP ${response.status}`)
const buffer = await lifetime.wait(response.arrayBuffer())
if (response!.ok === false)
throw new Error(`Failed to fetch multiple subtitles: HTTP ${response!.status}`)
const buffer = await lifetime.wait(response!.arrayBuffer())
if (lifetime.closed)
return
const text = new TextDecoder(option.encoding || 'utf-8').decode(buffer)
switch (option.type || getExt(option.url)) {
const text = new TextDecoder(option.encoding || 'utf-8').decode(buffer!)
switch (option.type || getExt(option.url!)) {
case 'srt': return srtToVtt(text)
case 'ass': return assToVtt(text)
case 'vtt': return text
@@ -0,0 +1,35 @@
import type Artplayer from 'artplayer'
import type { Subtitle, Utils } from 'artplayer'
import type legacyFactory from '../types/artplayer-plugin-multiple-subtitles'
import type { ParseResult } from './parser'
export type LegacyOption = Parameters<typeof legacyFactory>[0]
export type TrackOption = LegacyOption['subtitles'][number]
export interface Option {
subtitles?: TrackOption[]
}
export interface Result {
name: 'multipleSubtitles'
tracks: (names?: string[]) => void
reset: () => void
}
export interface Track extends ParseResult {
url: string | undefined
name: string | undefined
}
export type Converters = Pick<Utils, 'getExt' | 'srtToVtt' | 'assToVtt'>
export type Cleanup = () => void
export interface Lifetime {
readonly closed: boolean
own: (cleanup: Cleanup) => () => boolean
wait: <Value>(value: Value | PromiseLike<Value>) => Promise<Value | void>
dispose: Cleanup
}
export type LifetimeHost = Pick<Artplayer, 'isDestroy' | 'on' | 'off'>
export interface RenderHost {
option: { subtitle?: Subtitle }
subtitle: { init: (option: Subtitle) => unknown }
}
export interface OwnedURL {
free: Cleanup
}