refactor(hls): [PKG-HLS-04] migrate modules and compatible types

This commit is contained in:
Harvey Zhao committed 2026-09-12 00:49:50 +08:00
1 parent ba93b98be5
commit 2f9bee4ee0
34 files changed
+4860 -88

No files matched your search

@@ -1,25 +1,30 @@
import type Artplayer from 'artplayer'
import type { AudioTrack, Option, QualityLevel, Result } from '../types/artplayer-plugin-hls-control'
import type { Cleanup, Hls, Host } from './types'
import $audio from './audio.svg?raw'
import { audioModel, qualityModel } from './mapping'
import { createMenu } from './menu'
import $quality from './quality.svg?raw'
import { subscribeHls } from './sdk-events'
export default function artplayerPluginHlsControl(option = {}) {
return (art) => {
export default function artplayerPluginHlsControl<Level extends object = QualityLevel, Track extends object = AudioTrack>(option: Option<Level, Track> = {}) {
return (player: Artplayer): Result => {
// The consumer owns the optional SDK; update validates its media identity before use.
const art = player as unknown as Host<Level, Track>
const { $video } = art.template
const { errorHandle } = art.constructor.utils
let closed = false
let revision = 0
let engine
let unsubscribe = () => {}
let engine: Hls<Level, Track> | undefined
let unsubscribe: Cleanup = () => {}
let selecting = 0
let pending
let pending: Hls<Level, Track> | undefined
const retired = new WeakSet()
const active = hls => !closed && !retired.has(hls) && art.hls === hls && hls.media === $video
const active = (hls: Hls<Level, Track>) => !closed && !retired.has(hls) && art.hls === hls && hls.media === $video
const quality = createMenu(art, 'hls-quality', 'currentLevel', $quality, active, select)
const audio = createMenu(art, 'hls-audio', 'audioTrack', $audio, active, select)
function select(callback) {
function select<T>(callback: () => T): T {
selecting++
let succeeded = false
try {
@@ -38,7 +43,7 @@ export default function artplayerPluginHlsControl(option = {}) {
}
}
function refresh(hls, force) {
function refresh(hls: Hls<Level, Track>, force: boolean): void {
if (!active(hls))
return
const version = ++revision
@@ -55,10 +60,10 @@ export default function artplayerPluginHlsControl(option = {}) {
audio.update(hls, audioConfig, audioView, force)
}
function update() {
function update(): void {
if (closed)
return
const hls = art.hls
const hls = art.hls!
errorHandle(hls?.media === $video, 'Cannot find instance of HLS from "art.hls"')
if (retired.has(hls))
return
@@ -91,7 +96,7 @@ export default function artplayerPluginHlsControl(option = {}) {
refresh(hls, true)
}
function destroy() {
function destroy(): void {
if (closed)
return
closed = true
@@ -1,5 +1,8 @@
function uniqueLabels(items) {
const seen = new Map()
import type { Config } from '../types/artplayer-plugin-hls-control'
import type { AudioFields, Hls, LevelFields, MenuModel, SelectorItem } from './types'
function uniqueLabels(items: SelectorItem[]): SelectorItem[] {
const seen = new Map<string, SelectorItem>()
return items.filter((item) => {
if (item.html === undefined)
return true
@@ -16,15 +19,15 @@ function uniqueLabels(items) {
})
}
export function qualitySelection(hls) {
export function qualitySelection(hls: Pick<Hls, 'currentLevel' | 'autoLevelEnabled'>): number {
return hls.autoLevelEnabled === true ? -1 : hls.currentLevel
}
export function qualityModel(hls, config) {
export function qualityModel<Level extends object>(hls: Pick<Hls<Level>, 'levels' | 'currentLevel' | 'autoLevelEnabled'>, config: Config<Level>): MenuModel | null {
if (!hls.levels.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (level => level.name || `${level.height}P`)
const getName = config.getName || ((level: LevelFields) => level.name || `${level.height}P`)
const selected = qualitySelection(hls)
const level = hls.levels[selected]
const html = level ? getName(level) : auto
@@ -34,11 +37,11 @@ export function qualityModel(hls, config) {
return { html, title: config.title || 'Quality', selector }
}
export function audioModel(hls, config) {
export function audioModel<Track extends object>(hls: Pick<Hls<object, Track>, 'audioTracks' | 'audioTrack'>, config: Config<Track>): MenuModel | null {
if (!hls.audioTracks.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (track => track.name || track.lang || track.language)
const getName = config.getName || ((track: AudioFields) => track.name || track.lang || track.language)
const track = hls.audioTracks[hls.audioTrack]
const html = track ? getName(track) : auto
const selector = uniqueLabels(hls.audioTracks.map((item, index) => ({ html: getName(item, index), value: item.id, default: hls.audioTrack === item.id })))
@@ -1,21 +1,23 @@
export function createMenu(art, name, field, icon, active, select) {
let current
import type { DisplayConfig, MenuHost, MenuModel, MenuState, SelectorItem } from './types'
export function createMenu<Engine extends { currentLevel: number, audioTrack: number }>(art: MenuHost, name: string, field: 'currentLevel' | 'audioTrack', icon: string, active: (hls: Engine) => boolean, select: <T>(callback: () => T) => T) {
let current: MenuState<Engine> | undefined
const owned = { control: false, setting: false }
function remove(surface) {
function remove(surface: 'control' | 'setting'): void {
if (!owned[surface])
return
owned[surface] = false
art[surface === 'control' ? 'controls' : 'setting'].remove(name)
}
function clear() {
function clear(): void {
current = undefined
remove('control')
remove('setting')
}
function check(state, item) {
function check(state: MenuState<Engine>, item: SelectorItem): void {
for (const entry of state.model.selector)
entry.default = entry === item
if (state.config.control)
@@ -26,18 +28,19 @@ export function createMenu(art, name, field, icon, active, select) {
art.setting.check(item)
}
function update(hls, config, model, force) {
function update(hls: Engine, config: DisplayConfig, model: MenuModel | null, force: boolean): void {
if (!model) {
clear()
return
}
const previous = current
const selectedIndex = model.selector.findIndex(item => item.default)
// Equal lengths below protect the same-index reads in the comparison.
const reusable = !force && previous?.hls === hls && previous.model.title === model.title
&& (selectedIndex !== -1 || (!previous.model.selector.some(item => item.default) && previous.model.html === model.html))
&& owned.control === Boolean(config.control) && owned.setting === Boolean(config.setting)
&& previous.model.selector.length === model.selector.length
&& previous.model.selector.every((item, index) => item.html === model.selector[index].html && item.value === model.selector[index].value)
&& previous.model.selector.every((item, index) => item.html === model.selector[index]!.html && item.value === model.selector[index]!.value)
if (reusable) {
previous.config = config
const target = previous.model.selector[selectedIndex]
@@ -50,7 +53,7 @@ export function createMenu(art, name, field, icon, active, select) {
const state = { hls, config, model }
current = state
const valid = () => current === state && active(hls)
const onSelect = item => select(() => {
const onSelect = (item: SelectorItem) => select(() => {
if (!valid())
return item.html
hls[field] = item.value
@@ -80,7 +83,7 @@ export function createMenu(art, name, field, icon, active, select) {
}
}
function invalidate() {
function invalidate(): void {
current = undefined
}
return { update, clear, invalidate }
@@ -1,19 +1,23 @@
export function subscribeHls(hls, refresh, destroyed) {
import type { Cleanup, Hls, SdkEvent } from './types'
export function subscribeHls(hls: Hls, refresh: Cleanup, destroyed: Cleanup): Cleanup {
const events = hls.constructor?.Events
if (!events || typeof hls.on !== 'function' || typeof hls.off !== 'function')
return () => {}
const subscriptions = []
// Keep the checked SDK object so method calls retain their original receiver.
const source = hls as Hls & Required<Pick<Hls, 'on' | 'off'>>
const subscriptions: [string, Cleanup][] = []
let active = true
function release() {
function release(): void {
if (!active)
return
active = false
for (const [event, callback] of subscriptions)
hls.off(event, callback)
source.off(event, callback)
subscriptions.length = 0
}
try {
for (const key of ['MANIFEST_PARSED', 'LEVELS_UPDATED', 'LEVEL_SWITCHED', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'DESTROYING']) {
for (const key of ['MANIFEST_PARSED', 'LEVELS_UPDATED', 'LEVEL_SWITCHED', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'DESTROYING'] satisfies SdkEvent[]) {
const event = events[key]
if (typeof event !== 'string' || subscriptions.some(([name]) => name === event))
continue
@@ -26,7 +30,7 @@ export function subscribeHls(hls, refresh, destroyed) {
refresh()
}
subscriptions.push([event, callback])
hls.on(event, callback)
source.on(event, callback)
}
}
catch (error) {
@@ -0,0 +1,79 @@
import type { Config } from '../types/artplayer-plugin-hls-control'
export interface LevelFields {
height?: number
name?: string
}
export interface AudioFields {
id: number
name?: string
lang?: string
language?: string
}
export type SdkEvent = 'MANIFEST_PARSED' | 'LEVELS_UPDATED' | 'LEVEL_SWITCHED' | 'AUDIO_TRACKS_UPDATED' | 'AUDIO_TRACK_SWITCHED' | 'DESTROYING'
export type Cleanup = () => void
export interface Hls<Level extends object = object, Track extends object = object> {
media: object | null
levels: (Level & LevelFields)[]
audioTracks: (Track & AudioFields)[]
currentLevel: number
audioTrack: number
autoLevelEnabled?: boolean
constructor?: { Events?: Partial<Record<SdkEvent, unknown>> }
on?: (event: string, callback: Cleanup) => unknown
off?: (event: string, callback: Cleanup) => unknown
}
export interface SelectorItem {
html: string | undefined
value: number
default: boolean
}
export interface MenuModel {
html: string | undefined
title: string
selector: SelectorItem[]
}
export interface MenuOption {
name: string
html: string | undefined
selector: SelectorItem[]
onSelect: (item: SelectorItem) => string | undefined
position?: 'right'
style?: { padding: string }
tooltip?: string
icon?: string
width?: number
}
export interface Registry {
update: (option: MenuOption) => unknown
remove: (name: string) => unknown
check: (item: SelectorItem) => unknown
}
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 } }
hls?: Hls<Level, Track>
on: (event: 'ready' | 'restart' | 'destroy', callback: Cleanup) => unknown
off: (event: 'ready' | 'restart' | 'destroy', callback: Cleanup) => unknown
}
export type DisplayConfig = Pick<Config, 'control' | 'setting'>
export interface MenuState<Engine> {
hls: Engine
config: DisplayConfig
model: MenuModel
}