mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-26 17:01:20 -08:00
refactor(core): CORE-15 type subtitles and own request and track lifetimes
This commit is contained in:
1 parent
64d1939daa
commit
1b83081e8f
28 files changed
+11286
-405
No files matched your search
+4
-3
@@ -1,20 +1,21 @@
|
||||
import type { SubtitleOffsetHost } from '../subtitle/types'
|
||||
import { clamp, def } from '../utils'
|
||||
|
||||
export default function subtitleOffsetMix(art) {
|
||||
export default function subtitleOffsetMix(art: SubtitleOffsetHost): void {
|
||||
const { notice, i18n, template } = art
|
||||
|
||||
def(art, 'subtitleOffset', {
|
||||
get() {
|
||||
return template.$track?.offset || 0
|
||||
},
|
||||
set(value) {
|
||||
set(value: number) {
|
||||
const { cues } = art.subtitle
|
||||
if (!template.$track || cues.length === 0)
|
||||
return
|
||||
const offset = clamp(value, -10, 10)
|
||||
template.$track.offset = offset
|
||||
for (let index = 0; index < cues.length; index++) {
|
||||
const cue = cues[index]
|
||||
const cue = cues[index]!
|
||||
cue.originalStartTime = cue.originalStartTime ?? cue.startTime
|
||||
cue.originalEndTime = cue.originalEndTime ?? cue.endTime
|
||||
cue.startTime = clamp(cue.originalStartTime + offset, 0, art.duration)
|
||||
@@ -1,183 +0,0 @@
|
||||
import validator from 'option-validator'
|
||||
import scheme from './scheme'
|
||||
import {
|
||||
append,
|
||||
assToVtt,
|
||||
createElement,
|
||||
escape,
|
||||
getExt,
|
||||
remove,
|
||||
setStyle,
|
||||
setStyles,
|
||||
srtToVtt,
|
||||
vttToBlob,
|
||||
} from './utils'
|
||||
import Component from './utils/component'
|
||||
|
||||
export default class Subtitle extends Component {
|
||||
constructor(art) {
|
||||
super(art)
|
||||
this.name = 'subtitle'
|
||||
this.option = null
|
||||
this.destroyEvent = () => null
|
||||
this.init(art.option.subtitle)
|
||||
|
||||
let lastState = false
|
||||
art.on('video:timeupdate', () => {
|
||||
if (!this.url)
|
||||
return
|
||||
const state = this.art.template.$video.webkitDisplayingFullscreen
|
||||
if (typeof state !== 'boolean')
|
||||
return
|
||||
if (state !== lastState) {
|
||||
lastState = state
|
||||
this.createTrack(state ? 'subtitles' : 'metadata', this.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get url() {
|
||||
return this.art.template.$track.src
|
||||
}
|
||||
|
||||
set url(url) {
|
||||
this.switch(url)
|
||||
}
|
||||
|
||||
get textTrack() {
|
||||
return this.art.template.$video?.textTracks?.[0]
|
||||
}
|
||||
|
||||
get activeCues() {
|
||||
if (!this.textTrack)
|
||||
return []
|
||||
return Array.from(this.textTrack.activeCues)
|
||||
}
|
||||
|
||||
get cues() {
|
||||
if (!this.textTrack)
|
||||
return []
|
||||
return Array.from(this.textTrack.cues)
|
||||
}
|
||||
|
||||
style(key, value) {
|
||||
const { $subtitle } = this.art.template
|
||||
if (typeof key === 'object') {
|
||||
return setStyles($subtitle, key)
|
||||
}
|
||||
return setStyle($subtitle, key, value)
|
||||
}
|
||||
|
||||
update() {
|
||||
const {
|
||||
option: { subtitle },
|
||||
template: { $subtitle },
|
||||
} = this.art
|
||||
|
||||
$subtitle.innerHTML = ''
|
||||
if (!this.activeCues.length)
|
||||
return
|
||||
|
||||
this.art.emit('subtitleBeforeUpdate', this.activeCues)
|
||||
$subtitle.innerHTML = this.activeCues
|
||||
.map((cue, index) =>
|
||||
cue.text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => line.trim())
|
||||
.map(
|
||||
line =>
|
||||
`<div class="art-subtitle-line" data-group="${index}">
|
||||
${subtitle.escape ? escape(line) : line}
|
||||
</div>`,
|
||||
)
|
||||
.join(''),
|
||||
)
|
||||
.join('')
|
||||
this.art.emit('subtitleAfterUpdate', this.activeCues)
|
||||
}
|
||||
|
||||
async switch(url, newOption = {}) {
|
||||
const { i18n, notice, option } = this.art
|
||||
const subtitleOption = { ...option.subtitle, ...newOption, url }
|
||||
const subUrl = await this.init(subtitleOption)
|
||||
if (newOption.name) {
|
||||
notice.show = `${i18n.get('Switch Subtitle')}: ${newOption.name}`
|
||||
}
|
||||
return subUrl
|
||||
}
|
||||
|
||||
createTrack(kind, url) {
|
||||
const { template, proxy, option } = this.art
|
||||
const { $video, $track } = template
|
||||
|
||||
const $newTrack = createElement('track')
|
||||
$newTrack.default = true
|
||||
$newTrack.kind = kind
|
||||
$newTrack.src = url
|
||||
$newTrack.label = option.subtitle.name || 'Artplayer'
|
||||
$newTrack.track.mode = 'hidden'
|
||||
$newTrack.onload = () => {
|
||||
this.art.emit('subtitleLoad', this.cues, this.option)
|
||||
}
|
||||
|
||||
this.art.events.remove(this.destroyEvent)
|
||||
$track.onload = null
|
||||
remove($track)
|
||||
append($video, $newTrack)
|
||||
template.$track = $newTrack
|
||||
this.destroyEvent = proxy(this.textTrack, 'cuechange', () => this.update())
|
||||
}
|
||||
|
||||
async init(subtitleOption) {
|
||||
const {
|
||||
notice,
|
||||
template: { $subtitle },
|
||||
} = this.art
|
||||
|
||||
if (!this.textTrack)
|
||||
return null
|
||||
validator(subtitleOption, scheme.subtitle)
|
||||
if (!subtitleOption.url)
|
||||
return
|
||||
this.option = subtitleOption
|
||||
this.style(subtitleOption.style)
|
||||
|
||||
return fetch(subtitleOption.url)
|
||||
.then(response => response.arrayBuffer())
|
||||
.then((buffer) => {
|
||||
const decoder = new TextDecoder(subtitleOption.encoding)
|
||||
const text = decoder.decode(buffer)
|
||||
switch (subtitleOption.type || getExt(subtitleOption.url)) {
|
||||
case 'srt': {
|
||||
const vtt = srtToVtt(text)
|
||||
const vttNew = subtitleOption.onVttLoad(vtt)
|
||||
return vttToBlob(vttNew)
|
||||
}
|
||||
case 'ass': {
|
||||
const vtt = assToVtt(text)
|
||||
const vttNew = subtitleOption.onVttLoad(vtt)
|
||||
return vttToBlob(vttNew)
|
||||
}
|
||||
case 'vtt': {
|
||||
const vttNew = subtitleOption.onVttLoad(text)
|
||||
return vttToBlob(vttNew)
|
||||
}
|
||||
default:
|
||||
return subtitleOption.url
|
||||
}
|
||||
})
|
||||
.then((subUrl) => {
|
||||
$subtitle.innerHTML = ''
|
||||
if (this.url === subUrl)
|
||||
return subUrl
|
||||
URL.revokeObjectURL(this.url)
|
||||
this.createTrack('metadata', subUrl)
|
||||
return subUrl
|
||||
})
|
||||
.catch((err) => {
|
||||
$subtitle.innerHTML = ''
|
||||
notice.show = err
|
||||
throw err
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user