mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-27 17:51:20 -08:00
refactor(vtt-thumbnail): [PKG-VTT-THUMB-04] type runtime data and resource boundaries
This commit is contained in:
1 parent
80588a3046
commit
7f2906ae52
22 files changed
+2940
-75
No files matched your search
+2
-1
@@ -1,6 +1,7 @@
|
||||
import type { Thumbnail } from './types'
|
||||
import parseVtt from './parseVtt'
|
||||
|
||||
// Preserve the historical source helper while registration owns its requests.
|
||||
export default async function getVttArray(vttUrl = '') {
|
||||
export default async function getVttArray(vttUrl = ''): Promise<Thumbnail[]> {
|
||||
return parseVtt(await (await fetch(vttUrl)).text(), vttUrl)
|
||||
}
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
import type { Option, Result } from './types'
|
||||
import createLifetime from './lifetime'
|
||||
import parseVtt from './parseVtt'
|
||||
import createPreview from './preview'
|
||||
import requestVtt from './request'
|
||||
|
||||
export default function artplayerPluginVttThumbnail(option) {
|
||||
return async (art) => {
|
||||
const { constructor: { utils: { setStyle, isMobile, addClass } }, template: { $progress } } = art
|
||||
export default function artplayerPluginVttThumbnail(option: Option) {
|
||||
return async (art: Artplayer): Promise<Result> => {
|
||||
const { setStyle, isMobile, addClass } = (art.constructor as typeof Artplayer).utils
|
||||
const { $progress } = art.template
|
||||
const lifetime = createLifetime(art)
|
||||
const result = { name: 'artplayerPluginVttThumbnail' }
|
||||
const result: Result = { name: 'artplayerPluginVttThumbnail' }
|
||||
try {
|
||||
if (lifetime.closed)
|
||||
return result
|
||||
+11
-8
@@ -1,11 +1,14 @@
|
||||
export default function createLifetime(art) {
|
||||
import type { Events } from 'artplayer'
|
||||
import type { Cleanup, Lifetime, LifetimeHost, OwnedEvent } 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
|
||||
})
|
||||
const lifetime = {
|
||||
const lifetime: Lifetime = {
|
||||
get closed() { return closed },
|
||||
own(cleanup) {
|
||||
if (closed)
|
||||
@@ -14,13 +17,13 @@ export default function createLifetime(art) {
|
||||
cleanups.add(cleanup)
|
||||
return () => cleanups.delete(cleanup)
|
||||
},
|
||||
listen(name, callback) {
|
||||
listen<Name extends OwnedEvent>(name: Name, callback: (...args: Events[Name]) => unknown) {
|
||||
if (closed)
|
||||
return
|
||||
lifetime.own(() => art.off(name, callback))
|
||||
art.on(name, callback)
|
||||
},
|
||||
wait(value) { return Promise.race([value, cancelled]) },
|
||||
wait<Value>(value: Value | PromiseLike<Value>) { return Promise.race([value, cancelled]) },
|
||||
dispose() {
|
||||
if (closed)
|
||||
return
|
||||
@@ -32,7 +35,7 @@ export default function createLifetime(art) {
|
||||
run(cleanup)
|
||||
},
|
||||
}
|
||||
function run(cleanup) {
|
||||
function run(cleanup: Cleanup) {
|
||||
try {
|
||||
cleanup()
|
||||
}
|
||||
+24
-22
@@ -1,35 +1,37 @@
|
||||
function invalid(line, message) {
|
||||
import type { Rectangle, Thumbnail } from './types'
|
||||
|
||||
function invalid(line: number, message: string): never {
|
||||
throw new TypeError(`Invalid VTT thumbnail at line ${line}: ${message}`)
|
||||
}
|
||||
|
||||
function timestamp(value, line) {
|
||||
function timestamp(value: string, line: number): number {
|
||||
if (!/^\d{2,}(?::\d{2}){0,2}(?:\.\d{3})?$/.test(value))
|
||||
invalid(line, 'invalid timestamp')
|
||||
const [whole, fraction = '0'] = value.split('.')
|
||||
const seconds = whole.split(':').reduce((total, part) => total * 60 + Number(part), 0) + Number(fraction) / 1000
|
||||
const seconds = whole!.split(':').reduce((total, part) => total * 60 + Number(part), 0) + Number(fraction) / 1000
|
||||
if (!Number.isSafeInteger(Math.floor(seconds)))
|
||||
invalid(line, 'timestamp exceeds the supported numeric range')
|
||||
return seconds
|
||||
}
|
||||
|
||||
function rectangle(text, vttUrl, line) {
|
||||
function rectangle(text: string, vttUrl: string, line: number): Rectangle & { url: string } {
|
||||
const match = text.match(/^(.+)#([xywh]{4})=(.*)$/)
|
||||
if (!match || new Set(match[2]).size !== 4)
|
||||
if (!match || new Set(match[2]!).size !== 4)
|
||||
invalid(line, 'expected an image URL and four distinct xywh keys')
|
||||
const values = match[3].split(',').map(value => value.trim())
|
||||
const values = match[3]!.split(',').map(value => value.trim())
|
||||
if (values.length !== 4)
|
||||
invalid(line, 'expected four rectangle coordinates')
|
||||
const rect = {}
|
||||
const rect = {} as Rectangle
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const key = match[2][index]
|
||||
const value = values[index]
|
||||
const key = match[2]![index] as keyof Rectangle
|
||||
const value = values[index]!
|
||||
if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value) || !Number.isFinite(Number(value)))
|
||||
invalid(line, 'rectangle coordinates must be finite non-negative decimals')
|
||||
if ((key === 'w' || key === 'h') && Number(value) === 0)
|
||||
invalid(line, 'rectangle width and height must be positive')
|
||||
rect[key] = value
|
||||
}
|
||||
let url = match[1]
|
||||
let url = match[1]!
|
||||
if (!/^\/|(?:https?|ftp|file):\/\//i.test(url)) {
|
||||
const segments = vttUrl.split('/')
|
||||
segments.pop()
|
||||
@@ -39,55 +41,55 @@ function rectangle(text, vttUrl, line) {
|
||||
return { url, ...rect }
|
||||
}
|
||||
|
||||
export function findThumbnail(thumbnails, second) {
|
||||
export function findThumbnail(thumbnails: readonly Thumbnail[], second: number): Thumbnail | undefined {
|
||||
return thumbnails.find(item => second >= item.start && second <= item.end)
|
||||
}
|
||||
|
||||
export default function parseVtt(text, vttUrl = '') {
|
||||
export default function parseVtt(text: unknown, vttUrl = ''): Thumbnail[] {
|
||||
if (typeof text !== 'string')
|
||||
invalid(1, 'expected text')
|
||||
const lines = text.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)
|
||||
let index = 0
|
||||
function skipEmpty() {
|
||||
while (index < lines.length && !lines[index].trim())
|
||||
while (index < lines.length && !lines[index]!.trim())
|
||||
index++
|
||||
}
|
||||
skipEmpty()
|
||||
if (index === lines.length)
|
||||
return []
|
||||
if (!/^WEBVTT(?:[\t ].*)?$/.test(lines[index].trim()) || lines[index].includes('-->'))
|
||||
if (!/^WEBVTT(?:[\t ].*)?$/.test(lines[index]!.trim()) || lines[index]!.includes('-->'))
|
||||
invalid(index + 1, 'expected WEBVTT header')
|
||||
index++
|
||||
const thumbnails = []
|
||||
const thumbnails: Thumbnail[] = []
|
||||
while (index < lines.length) {
|
||||
skipEmpty()
|
||||
if (index === lines.length)
|
||||
break
|
||||
const first = lines[index].trim()
|
||||
const first = lines[index]!.trim()
|
||||
if (/^NOTE(?:[\t ]|$)/.test(first) || first === 'STYLE' || first === 'REGION') {
|
||||
while (index < lines.length && lines[index].trim())
|
||||
while (index < lines.length && lines[index]!.trim())
|
||||
index++
|
||||
continue
|
||||
}
|
||||
const identifierLine = index + 1
|
||||
if (!first.includes('-->')) {
|
||||
index++
|
||||
if (index === lines.length || !lines[index].trim())
|
||||
if (index === lines.length || !lines[index]!.trim())
|
||||
invalid(identifierLine, 'cue identifier must be followed by timing')
|
||||
}
|
||||
const timingLine = index + 1
|
||||
const timing = lines[index].trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/)
|
||||
const timing = lines[index]!.trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/)
|
||||
if (!timing)
|
||||
invalid(timingLine, 'invalid cue timing')
|
||||
const start = timestamp(timing[1], timingLine)
|
||||
const end = timestamp(timing[2], timingLine)
|
||||
const start = timestamp(timing[1]!, timingLine)
|
||||
const end = timestamp(timing[2]!, timingLine)
|
||||
if (end < start)
|
||||
invalid(timingLine, 'cue end precedes its start')
|
||||
index++
|
||||
skipEmpty()
|
||||
if (index === lines.length)
|
||||
invalid(timingLine, 'missing sprite image')
|
||||
const image = rectangle(lines[index].trim(), vttUrl, index + 1)
|
||||
const image = rectangle(lines[index]!.trim(), vttUrl, index + 1)
|
||||
thumbnails.push({ start: Math.floor(start), end: Math.floor(end), ...image })
|
||||
index++
|
||||
}
|
||||
+12
-10
@@ -1,7 +1,9 @@
|
||||
import type { Events } from 'artplayer'
|
||||
import type { PreviewOptions, StyleKey, Thumbnail } from './types'
|
||||
import { findThumbnail } from './parseVtt'
|
||||
|
||||
export default function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }) {
|
||||
let timer = null
|
||||
export default function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }: PreviewOptions) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let generation = 0
|
||||
lifetime.own(() => {
|
||||
generation++
|
||||
@@ -11,23 +13,23 @@ export default function createPreview({ lifetime, thumbnails, progress, duration
|
||||
clearTimeout(previous)
|
||||
}
|
||||
})
|
||||
function style(control, key, value) {
|
||||
function style(control: HTMLElement, key: StyleKey, value: string | number) {
|
||||
if (!lifetime.closed)
|
||||
setStyle(control, key, value)
|
||||
setStyle<keyof CSSStyleDeclaration>(control, key, value)
|
||||
}
|
||||
function show(control, cue, width) {
|
||||
function show(control: HTMLElement, cue: Thumbnail, width: number) {
|
||||
style(control, 'backgroundImage', `url(${cue.url})`)
|
||||
style(control, 'height', `${cue.h}px`)
|
||||
style(control, 'width', `${cue.w}px`)
|
||||
style(control, 'backgroundPosition', `-${cue.x}px -${cue.y}px`)
|
||||
if (width <= cue.w / 2)
|
||||
if (width <= Number(cue.w) / 2)
|
||||
style(control, 'left', 0)
|
||||
else if (width > progress.clientWidth - cue.w / 2)
|
||||
style(control, 'left', `${progress.clientWidth - cue.w}px`)
|
||||
else if (width > progress.clientWidth - Number(cue.w) / 2)
|
||||
style(control, 'left', `${progress.clientWidth - Number(cue.w)}px`)
|
||||
else
|
||||
style(control, 'left', `${width - cue.w / 2}px`)
|
||||
style(control, 'left', `${width - Number(cue.w) / 2}px`)
|
||||
}
|
||||
return control => async (type, percentage, event) => {
|
||||
return (control: HTMLElement) => async (type: Events['setBar'][0], percentage: number, event?: Event) => {
|
||||
if (lifetime.closed)
|
||||
return
|
||||
const dragging = type === 'played' && event && isMobile
|
||||
+6
-4
@@ -1,4 +1,6 @@
|
||||
export default async function requestVtt(url = '', lifetime) {
|
||||
import type { Lifetime } from './types'
|
||||
|
||||
export default async function requestVtt(url = '', lifetime: Lifetime): Promise<string | void> {
|
||||
if (lifetime.closed)
|
||||
return
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
||||
@@ -9,11 +11,11 @@ export default async function requestVtt(url = '', lifetime) {
|
||||
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 VTT thumbnails: HTTP ${response.status}`)
|
||||
if (response!.ok === false)
|
||||
throw new Error(`Failed to fetch VTT thumbnails: HTTP ${response!.status}`)
|
||||
if (lifetime.closed)
|
||||
return
|
||||
return await lifetime.wait(response.text())
|
||||
return await lifetime.wait(response!.text())
|
||||
}
|
||||
finally {
|
||||
release()
|
||||
@@ -0,0 +1,45 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
import type { Events, Utils } from 'artplayer'
|
||||
|
||||
export interface Option {
|
||||
vtt?: string
|
||||
style?: Partial<CSSStyleDeclaration>
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
name: 'artplayerPluginVttThumbnail'
|
||||
}
|
||||
|
||||
export interface Rectangle {
|
||||
x: string
|
||||
y: string
|
||||
w: string
|
||||
h: string
|
||||
}
|
||||
|
||||
export interface Thumbnail extends Rectangle {
|
||||
start: number
|
||||
end: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export type Cleanup = () => void
|
||||
export type OwnedEvent = 'destroy' | 'setBar'
|
||||
export interface Lifetime {
|
||||
readonly closed: boolean
|
||||
own: (cleanup: Cleanup) => () => boolean
|
||||
listen: <Name extends OwnedEvent>(name: Name, callback: (...args: Events[Name]) => unknown) => void
|
||||
wait: <Value>(value: Value | PromiseLike<Value>) => Promise<Value | void>
|
||||
dispose: Cleanup
|
||||
}
|
||||
|
||||
export type LifetimeHost = Pick<Artplayer, 'on' | 'off' | 'isDestroy'>
|
||||
export type StyleKey = 'backgroundImage' | 'height' | 'width' | 'backgroundPosition' | 'left' | 'display'
|
||||
export interface PreviewOptions {
|
||||
lifetime: Lifetime
|
||||
thumbnails: readonly Thumbnail[]
|
||||
progress: Pick<HTMLElement, 'clientWidth'>
|
||||
duration: () => number
|
||||
setStyle: Utils['setStyle']
|
||||
isMobile: boolean
|
||||
}
|
||||
Reference in new issue
Block a user