refactor(dash): [PKG-DASH-04] migrate strict TypeScript and compatible declarations

This commit is contained in:
Harvey Zhao committed 2026-09-12 05:18:47 +08:00
1 parent 7de04573b6
commit 6840544e0e
38 files changed
+1744 -102

No files matched your search

@@ -1,20 +1,25 @@
import type Artplayer from 'artplayer'
import type { AudioTrack, Option, QualityLevel, Result } from '../types/artplayer-plugin-dash-control'
import type { AudioItem, Cleanup, EventName, Host, QualityItem, Valid } from './types'
import $audio from './audio.svg?raw'
import { audioModel, qualityModel } from './mapping'
import { createMenu } from './menu'
import $quality from './quality.svg?raw'
export default function artplayerPluginDashControl(option = {}) {
return (art) => {
export default function artplayerPluginDashControl<Level extends object = QualityLevel, Track extends object = AudioTrack>(option: Option<Level, Track> = {}) {
return (player: Artplayer): Result => {
// art.dash is caller-owned; update keeps the historical SDK/media validation boundary.
const art = player as unknown as Host<Level, Track>
const { $video } = art.template
const { errorHandle } = art.constructor.utils
let closed = false
let revision = 0
const quality = createMenu(art, 'dash-quality', $quality)
const audio = createMenu(art, 'dash-audio', $audio)
const subscriptions = []
const quality = createMenu<QualityItem>(art, 'dash-quality', $quality)
const audio = createMenu<AudioItem<Track>>(art, 'dash-audio', $audio)
const subscriptions: [EventName, Cleanup][] = []
function clear(current = () => true) {
let failure
function clear(current: Valid = () => true): void {
let failure: unknown
for (const cleanup of [quality.clear, audio.clear]) {
if (!current())
break
@@ -29,11 +34,11 @@ export default function artplayerPluginDashControl(option = {}) {
throw failure
}
function update() {
function update(): void {
if (closed || art.isDestroy)
return
const version = ++revision
const dash = art.dash
const dash = art.dash!
const current = () => !closed && !art.isDestroy && version === revision && art.dash === dash
const valid = () => current() && dash.getVideoElement() === $video && current()
try {
@@ -66,12 +71,12 @@ export default function artplayerPluginDashControl(option = {}) {
}
}
function destroy() {
function destroy(): void {
if (closed)
return
closed = true
revision++
let failure
let failure: unknown
const actions = [clear, ...subscriptions.splice(0).map(([name, callback]) => () => art.off(name, callback))]
for (const action of actions) {
try {
@@ -86,7 +91,8 @@ export default function artplayerPluginDashControl(option = {}) {
}
try {
for (const entry of [['ready', update], ['restart', update], ['destroy', destroy]]) {
const entries: [EventName, Cleanup][] = [['ready', update], ['restart', update], ['destroy', destroy]]
for (const entry of entries) {
if (closed)
break
subscriptions.push(entry)
@@ -1,7 +1,9 @@
import type { Config } from '../types/artplayer-plugin-dash-control'
import type { AudioFields, AudioItem, Dash, Label, MenuModel, QualityFields, QualityItem, SelectorItem, Valid } from './types'
import { qualityAdapter } from './sdk'
function uniqueLabels(items) {
const seen = new Map()
function uniqueLabels<Item extends SelectorItem>(items: Item[]): Item[] {
const seen = new Map<Label, Item>()
return items.filter((item) => {
if (item.html === undefined)
return true
@@ -16,13 +18,13 @@ function uniqueLabels(items) {
})
}
export function qualityModel(dash, config, active) {
export function qualityModel<Level extends object, Track extends object>(dash: Dash<Level, Track>, config: Config<Level>, active: Valid): MenuModel<QualityItem> | null {
const adapter = qualityAdapter(dash)
const levels = adapter.levels()
if (!active() || !levels?.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (level => `${level.height}p`)
const getName = config.getName || ((level: QualityFields) => `${level.height}p`)
const selected = adapter.current(levels)
if (!active())
return null
@@ -32,7 +34,7 @@ export function qualityModel(dash, config, active) {
const html = !automatic && selected ? getName(selected) : auto
if (!active())
return null
const items = []
const items: QualityItem[] = []
for (const [index, level] of levels.entries()) {
const label = getName(level)
if (!active())
@@ -40,34 +42,37 @@ export function qualityModel(dash, config, active) {
items.push({ html: label, ...adapter.item(level, index, selected, automatic) })
}
const selector = uniqueLabels(items)
.sort((left, right) => right.value - left.value)
// The synthetic Auto item is appended after sorting SDK numeric keys.
.sort((left, right) => (right.value as number) - (left.value as number))
selector.push({ html: auto, value: 'auto', default: automatic })
return { html, title: config.title || 'Quality', selector, select: adapter.select }
}
function selectedTrack(tracks, current) {
function selectedTrack<Track extends AudioFields>(tracks: Track[], current: Track): Track | undefined {
if (tracks.includes(current))
return current
if (current.id == null && current.index == null)
return undefined
const matching = tracks.filter(track => ['id', 'index', 'lang'].every(key => current[key] == null || track[key] === current[key]))
const fields = ['id', 'index', 'lang'] as const
const matching = tracks.filter(track => fields.every(key => current[key] == null || track[key] === current[key]))
return matching.length === 1 ? matching[0] : undefined
}
export function audioModel(dash, config, active) {
export function audioModel<Level extends object, Track extends object>(dash: Dash<Level, Track>, config: Config<Track>, active: Valid): MenuModel<AudioItem<Track>> | null {
const tracks = dash.getTracksFor('audio')
if (!active() || !tracks?.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (track => track.lang || track.id)
const current = dash.getCurrentTrackFor('audio') || tracks[0]
const getName = config.getName || ((track: AudioFields) => track.lang || track.id)
// The non-empty list check above protects the fallback item.
const current = dash.getCurrentTrackFor('audio') || tracks[0]!
if (!active())
return null
const html = current ? getName(current) : auto
if (!active())
return null
const selected = selectedTrack(tracks, current)
const items = []
const items: AudioItem<Track>[] = []
for (const track of tracks) {
const label = getName(track)
if (!active())
@@ -1,8 +1,10 @@
export function createMenu(art, name, icon) {
let current
const owned = new Map()
import type { DisplayConfig, Label, MenuHost, MenuModel, SelectorItem, Valid } from './types'
function remove(surface, callback = owned.get(surface)) {
export function createMenu<Item extends SelectorItem>(art: MenuHost, name: string, icon: string) {
let current: object | undefined
const owned = new Map<'controls' | 'setting', (item: Item) => Label>()
function remove(surface: 'controls' | 'setting', callback = owned.get(surface)): void {
if (!callback || owned.get(surface) !== callback)
return
owned.delete(surface)
@@ -14,9 +16,9 @@ export function createMenu(art, name, icon) {
registry.remove(name)
}
function clear() {
function clear(): void {
current = undefined
let failure
let failure: unknown
for (const [surface, callback] of [...owned]) {
try {
remove(surface, callback)
@@ -29,7 +31,7 @@ export function createMenu(art, name, icon) {
throw failure
}
function update(config, model, active) {
function update(config: DisplayConfig, model: MenuModel<Item> | null, active: Valid): void {
if (!model) {
clear()
return
@@ -37,7 +39,7 @@ export function createMenu(art, name, icon) {
const state = {}
current = state
const valid = () => state === current && active()
const onSelect = (item) => {
const onSelect = (item: Item): Label => {
if (!valid())
return item.html
model.select(item, valid)
@@ -1,11 +1,14 @@
export function qualityAdapter(dash) {
import type { Dash, QualityAdapter } from './types'
export function qualityAdapter<Level extends object, Track extends object>(dash: Dash<Level, Track>): QualityAdapter<Level> {
// Select one SDK method family. Invalid/incomplete SDKs keep synchronous method errors.
const modern = typeof dash.getRepresentationsByType === 'function'
return {
levels: () => modern ? dash.getRepresentationsByType('video') : dash.getBitrateInfoListFor('video'),
levels: () => modern ? dash.getRepresentationsByType!('video') : dash.getBitrateInfoListFor!('video'),
current(levels) {
if (modern)
return dash.getCurrentRepresentationForType('video')
const quality = dash.getQualityFor('video')
return dash.getCurrentRepresentationForType!('video')
const quality = dash.getQualityFor!('video')
return levels.find(level => level.qualityIndex === quality)
},
item(level, index, selected, automatic) {
@@ -18,9 +21,9 @@ export function qualityAdapter(dash) {
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: automatic } } } })
if (!automatic && valid()) {
if (modern)
dash.setRepresentationForTypeById('video', item.id)
dash.setRepresentationForTypeById!('video', item.id)
else
dash.setQualityFor('video', item.value)
dash.setQualityFor!('video', item.value as number | undefined)
}
},
}
@@ -0,0 +1,99 @@
import type { Config } from '../types/artplayer-plugin-dash-control'
export type Label = string | number | undefined
export type Identifier = string | number
export type Valid = () => boolean
export type Cleanup = () => void
export type EventName = 'ready' | 'restart' | 'destroy'
export interface QualityFields {
height?: number
id?: Identifier
qualityIndex?: number
}
export interface AudioFields {
id?: Identifier
index?: number
lang?: string
}
export interface Dash<Level extends object = object, Track extends object = object> {
getVideoElement: () => object | null
getSettings: () => { streaming: { abr: { autoSwitchBitrate: { video: boolean } } } }
updateSettings: (settings: { streaming: { abr: { autoSwitchBitrate: { video: boolean } } } }) => unknown
getRepresentationsByType?: (type: 'video') => (Level & QualityFields)[] | null | undefined
getCurrentRepresentationForType?: (type: 'video') => (Level & QualityFields) | null | undefined
setRepresentationForTypeById?: (type: 'video', id: Identifier | undefined) => unknown
getBitrateInfoListFor?: (type: 'video') => (Level & QualityFields)[] | null | undefined
getQualityFor?: (type: 'video') => number
setQualityFor?: (type: 'video', index: number | undefined) => unknown
getTracksFor: (type: 'audio') => (Track & AudioFields)[] | null | undefined
getCurrentTrackFor: (type: 'audio') => (Track & AudioFields) | null | undefined
setCurrentTrack: (track: Track & AudioFields) => unknown
}
export interface SelectorItem {
html: Label
default: boolean
}
export interface QualityItem extends SelectorItem {
value: number | 'auto' | undefined
id?: Identifier
}
export interface AudioItem<Track extends object> extends SelectorItem {
value: Track & AudioFields
}
export interface QualityAdapter<Level extends object> {
levels: () => (Level & QualityFields)[] | null | undefined
current: (levels: (Level & QualityFields)[]) => (Level & QualityFields) | null | undefined
item: (level: Level & QualityFields, index: number, selected: (Level & QualityFields) | null | undefined, automatic: boolean) => Omit<QualityItem, 'html'>
select: (item: QualityItem, valid: Valid) => void
}
export interface MenuModel<Item extends SelectorItem> {
html: Label
title: string
selector: Item[]
select: (item: Item, valid: Valid) => unknown
}
export interface MenuOption<Item extends SelectorItem> {
name: string
html: Label
selector: Item[]
onSelect: (item: Item) => Label
position?: 'right'
style?: { padding: string }
tooltip?: Label
icon?: string
width?: number
}
export interface Registry {
update: <Item extends SelectorItem>(option: MenuOption<Item>) => unknown
remove: (name: string) => unknown
check: (item: SelectorItem) => unknown
cache?: { get?: (name: string) => { option?: { onSelect?: unknown } } | undefined }
find?: (name: string) => { onSelect?: unknown } | undefined
}
export interface MenuHost {
controls: Registry
setting: Registry
notice: { show: string }
}
export interface Host<Level extends object, Track extends object> extends MenuHost {
template: { $video: object }
constructor: { utils: { errorHandle: (condition: unknown, message: string) => void } }
isDestroy?: boolean
dash?: Dash<Level, Track>
on: (name: EventName, callback: Cleanup) => unknown
off: (name: EventName, callback: Cleanup) => unknown
}
export type DisplayConfig = Pick<Config, 'control' | 'setting'>