refactor(hls): [PKG-HLS-03] separate menus and SDK lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 00:18:19 +08:00
1 parent 33f6d19b77
commit ba93b98be5
24 files changed
+5849 -382

No files matched your search

@@ -1,152 +1,113 @@
import $audio from './audio.svg?raw'
import { audioModel, qualityModel } from './mapping'
import { createMenu } from './menu'
import $quality from './quality.svg?raw'
function uniqBy(array, property) {
const seen = new Map()
return array.filter((item) => {
const key = item[property]
if (key === undefined) {
return true
}
return !seen.has(key) && seen.set(key, 1)
})
}
import { subscribeHls } from './sdk-events'
export default function artplayerPluginHlsControl(option = {}) {
return (art) => {
const { $video } = art.template
const { errorHandle } = art.constructor.utils
let closed = false
let revision = 0
let engine
let unsubscribe = () => {}
let selecting = 0
let pending
const retired = new WeakSet()
const active = hls => !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 updateQuality(hls) {
if (!hls.levels.length)
return
const config = option.quality || {}
const auto = config.auto || 'Auto'
const title = config.title || 'Quality'
const getName = config.getName || (level => level.name || `${level.height}P`)
const defaultLevel = hls.levels[hls.currentLevel]
const defaultHtml = defaultLevel ? getName(defaultLevel) : auto
const selector = uniqBy(
hls.levels.map((item, index) => {
return {
html: getName(item, index),
value: index,
default: hls.currentLevel === index,
}
}),
'html',
).sort((a, b) => b.value - a.value)
selector.push({
html: auto,
value: -1,
default: hls.currentLevel === -1,
})
const onSelect = (item) => {
hls.currentLevel = item.value
art.notice.show = `${title}: ${item.html}`
if (config.control)
art.controls.check(item)
if (config.setting)
art.setting.check(item)
return item.html
function select(callback) {
selecting++
let succeeded = false
try {
const result = callback()
succeeded = true
return result
}
if (config.control) {
art.controls.update({
name: 'hls-quality',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'hls-quality',
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect,
})
finally {
selecting--
if (!selecting) {
const hls = pending
pending = undefined
if (succeeded && hls)
refresh(hls, false)
}
}
}
function updateAudio(hls) {
if (!hls.audioTracks.length)
function refresh(hls, force) {
if (!active(hls))
return
const config = option.audio || {}
const auto = config.auto || 'Auto'
const title = config.title || 'Audio'
const getName = config.getName || (track => track.name || track.lang || track.language)
const defaultTrack = hls.audioTracks[hls.audioTrack]
const defaultHtml = defaultTrack ? getName(defaultTrack) : auto
const selector = uniqBy(
hls.audioTracks.map((item, index) => {
return {
html: getName(item, index),
value: item.id,
default: hls.audioTrack === item.id,
}
}),
'html',
)
const onSelect = (item) => {
hls.audioTrack = item.value
art.notice.show = `${title}: ${item.html}`
if (config.control)
art.controls.check(item)
if (config.setting)
art.setting.check(item)
return item.html
}
if (config.control) {
art.controls.update({
name: 'hls-audio',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'hls-audio',
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect,
})
}
const version = ++revision
const config = option.quality || {}
const model = qualityModel(hls, config)
if (!active(hls) || version !== revision)
return
quality.update(hls, config, model, force)
if (!active(hls) || version !== revision)
return
const audioConfig = option.audio || {}
const audioView = audioModel(hls, audioConfig)
if (active(hls) && version === revision)
audio.update(hls, audioConfig, audioView, force)
}
function update() {
errorHandle(art.hls?.media === $video, 'Cannot find instance of HLS from "art.hls"')
updateQuality(art.hls)
updateAudio(art.hls)
if (closed)
return
const hls = art.hls
errorHandle(hls?.media === $video, 'Cannot find instance of HLS from "art.hls"')
if (retired.has(hls))
return
if (engine !== hls) {
unsubscribe()
engine = hls
try {
unsubscribe = subscribeHls(hls, () => {
if (selecting)
pending = hls
else
refresh(hls, false)
}, () => {
retired.add(hls)
unsubscribe()
if (engine === hls) {
engine = undefined
if (!closed) {
quality.clear()
audio.clear()
}
}
})
}
catch (error) {
engine = undefined
throw error
}
}
refresh(hls, true)
}
function destroy() {
if (closed)
return
closed = true
revision++
pending = undefined
quality.invalidate()
audio.invalidate()
unsubscribe()
art.off('ready', update)
art.off('restart', update)
art.off('destroy', destroy)
}
art.on('ready', update)
art.on('restart', update)
return {
name: 'artplayerPluginHlsControl',
update,
}
art.on('destroy', destroy)
return { name: 'artplayerPluginHlsControl', update }
}
}
@@ -0,0 +1,46 @@
function uniqueLabels(items) {
const seen = new Map()
return items.filter((item) => {
if (item.html === undefined)
return true
const first = seen.get(item.html)
if (first) {
if (item.default) {
first.default = true
first.value = item.value
}
return false
}
seen.set(item.html, item)
return true
})
}
export function qualitySelection(hls) {
return hls.autoLevelEnabled === true ? -1 : hls.currentLevel
}
export function qualityModel(hls, config) {
if (!hls.levels.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (level => level.name || `${level.height}P`)
const selected = qualitySelection(hls)
const level = hls.levels[selected]
const html = level ? getName(level) : auto
const selector = uniqueLabels(hls.levels.map((item, index) => ({ html: getName(item, index), value: index, default: selected === index })))
.sort((a, b) => b.value - a.value)
selector.push({ html: auto, value: -1, default: selected === -1 })
return { html, title: config.title || 'Quality', selector }
}
export function audioModel(hls, config) {
if (!hls.audioTracks.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (track => 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 })))
return { html, title: config.title || 'Audio', selector }
}
@@ -0,0 +1,87 @@
export function createMenu(art, name, field, icon, active, select) {
let current
const owned = { control: false, setting: false }
function remove(surface) {
if (!owned[surface])
return
owned[surface] = false
art[surface === 'control' ? 'controls' : 'setting'].remove(name)
}
function clear() {
current = undefined
remove('control')
remove('setting')
}
function check(state, item) {
for (const entry of state.model.selector)
entry.default = entry === item
if (state.config.control)
art.controls.check(item)
if (state !== current || !active(state.hls))
return
if (state.config.setting)
art.setting.check(item)
}
function update(hls, config, model, force) {
if (!model) {
clear()
return
}
const previous = current
const selectedIndex = model.selector.findIndex(item => item.default)
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)
if (reusable) {
previous.config = config
const target = previous.model.selector[selectedIndex]
if (target && (!target.default || previous.model.html !== model.html)) {
previous.model.html = model.html
check(previous, target)
}
return
}
const state = { hls, config, model }
current = state
const valid = () => current === state && active(hls)
const onSelect = item => select(() => {
if (!valid())
return item.html
hls[field] = item.value
if (!valid())
return item.html
model.html = item.html
art.notice.show = `${model.title}: ${item.html}`
if (valid())
check(state, item)
return item.html
})
if (config.control) {
owned.control = true
art.controls.update({ name, position: 'right', html: model.html, style: { padding: '0 10px' }, selector: model.selector, onSelect })
}
else {
remove('control')
}
if (!valid())
return
if (config.setting) {
owned.setting = true
art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect })
}
else {
remove('setting')
}
}
function invalidate() {
current = undefined
}
return { update, clear, invalidate }
}
@@ -0,0 +1,37 @@
export function subscribeHls(hls, refresh, destroyed) {
const events = hls.constructor?.Events
if (!events || typeof hls.on !== 'function' || typeof hls.off !== 'function')
return () => {}
const subscriptions = []
let active = true
function release() {
if (!active)
return
active = false
for (const [event, callback] of subscriptions)
hls.off(event, callback)
subscriptions.length = 0
}
try {
for (const key of ['MANIFEST_PARSED', 'LEVELS_UPDATED', 'LEVEL_SWITCHED', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'DESTROYING']) {
const event = events[key]
if (typeof event !== 'string' || subscriptions.some(([name]) => name === event))
continue
const callback = () => {
if (!active)
return
if (key === 'DESTROYING')
destroyed()
else
refresh()
}
subscriptions.push([event, callback])
hls.on(event, callback)
}
}
catch (error) {
release()
throw error
}
return release
}