refactor(dash): [PKG-DASH-03] adapt SDK generations and own menu lifecycles

This commit is contained in:
Harvey Zhao committed 2026-09-12 04:59:19 +08:00
1 parent 762cc63431
commit 7de04573b6
24 files changed
+2107 -533

No files matched your search

@@ -1,179 +1,107 @@
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)
})
}
export default function artplayerPluginDashControl(option = {}) {
return (art) => {
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 = []
function updateQuality(dash) {
const qualities = dash.getRepresentationsByType('video')
if (!qualities || !qualities.length)
return
const config = option.quality || {}
const auto = config.auto || 'Auto'
const title = config.title || 'Quality'
const getName = config.getName || (level => `${level.height}p`)
const currentRepresentation = dash.getCurrentRepresentationForType('video')
const currentId = currentRepresentation && currentRepresentation.id
const currentAuto = dash.getSettings().streaming.abr.autoSwitchBitrate.video
const defaultHtml = !currentAuto && currentRepresentation ? getName(currentRepresentation) : auto
const selector = uniqBy(
qualities.map((item, index) => {
return {
html: getName(item),
value: index,
id: item.id,
default: !currentAuto && !!currentId && item.id === currentId,
}
}),
'html',
).sort((a, b) => b.value - a.value)
selector.push({
html: auto,
value: 'auto',
default: currentAuto,
})
const onSelect = (item) => {
if (item.value === 'auto') {
dash.updateSettings({
streaming: {
abr: {
autoSwitchBitrate: {
video: true,
},
},
},
})
function clear(current = () => true) {
let failure
for (const cleanup of [quality.clear, audio.clear]) {
if (!current())
break
try {
cleanup()
}
else {
dash.updateSettings({
streaming: {
abr: {
autoSwitchBitrate: {
video: false,
},
},
},
})
dash.setRepresentationForTypeById('video', item.id)
catch (error) {
failure ||= error
}
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: 'dash-quality',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'dash-quality',
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect,
})
}
}
function updateAudio(dash) {
const audioTracks = dash.getTracksFor('audio')
if (!audioTracks || !audioTracks.length)
return
const config = option.audio || {}
const auto = config.auto || 'Auto'
const title = config.title || 'Audio'
const getName = config.getName || (track => track.lang || track.id)
const currentTrack = dash.getCurrentTrackFor('audio') || audioTracks[0]
const defaultHtml = currentTrack ? getName(currentTrack) : auto
const selector = uniqBy(
audioTracks.map((item) => {
return {
html: getName(item),
value: item,
default: currentTrack === item,
}
}),
'html',
)
const onSelect = (item) => {
dash.setCurrentTrack(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: 'dash-audio',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'dash-audio',
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect,
})
}
if (failure)
throw failure
}
function update() {
errorHandle(art.dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"')
updateQuality(art.dash)
updateAudio(art.dash)
if (closed || art.isDestroy)
return
const version = ++revision
const dash = art.dash
const current = () => !closed && !art.isDestroy && version === revision && art.dash === dash
const valid = () => current() && dash.getVideoElement() === $video && current()
try {
errorHandle(dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"')
if (!current())
return
const qualityConfig = option.quality || {}
const qualities = qualityModel(dash, qualityConfig, valid)
if (!valid())
return
quality.update(qualityConfig, qualities, valid)
if (!valid())
return
const audioConfig = option.audio || {}
const tracks = audioModel(dash, audioConfig, valid)
if (valid())
audio.update(audioConfig, tracks, valid)
}
catch (error) {
if (current()) {
const cleanupVersion = ++revision
try {
clear(() => revision === cleanupVersion)
}
catch (cleanupError) {
console.warn('ArtPlayer DASH cleanup failed:', cleanupError)
}
}
throw error
}
}
art.on('ready', update)
art.on('restart', update)
return {
name: 'artplayerPluginDashControl',
update,
function destroy() {
if (closed)
return
closed = true
revision++
let failure
const actions = [clear, ...subscriptions.splice(0).map(([name, callback]) => () => art.off(name, callback))]
for (const action of actions) {
try {
action()
}
catch (error) {
failure ||= error
}
}
if (failure)
throw failure
}
try {
for (const entry of [['ready', update], ['restart', update], ['destroy', destroy]]) {
if (closed)
break
subscriptions.push(entry)
art.on(...entry)
}
}
catch (error) {
try {
destroy()
}
catch (cleanupError) {
console.warn('ArtPlayer DASH subscription cleanup failed:', cleanupError)
}
throw error
}
return { name: 'artplayerPluginDashControl', update }
}
}
@@ -0,0 +1,79 @@
import { qualityAdapter } from './sdk'
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)
Object.assign(first, item)
return false
}
seen.set(item.html, item)
return true
})
}
export function qualityModel(dash, config, active) {
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 selected = adapter.current(levels)
if (!active())
return null
const automatic = dash.getSettings().streaming.abr.autoSwitchBitrate.video
if (!active())
return null
const html = !automatic && selected ? getName(selected) : auto
if (!active())
return null
const items = []
for (const [index, level] of levels.entries()) {
const label = getName(level)
if (!active())
return null
items.push({ html: label, ...adapter.item(level, index, selected, automatic) })
}
const selector = uniqueLabels(items)
.sort((left, right) => right.value - left.value)
selector.push({ html: auto, value: 'auto', default: automatic })
return { html, title: config.title || 'Quality', selector, select: adapter.select }
}
function selectedTrack(tracks, current) {
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]))
return matching.length === 1 ? matching[0] : undefined
}
export function audioModel(dash, config, active) {
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]
if (!active())
return null
const html = current ? getName(current) : auto
if (!active())
return null
const selected = selectedTrack(tracks, current)
const items = []
for (const track of tracks) {
const label = getName(track)
if (!active())
return null
items.push({ html: label, value: track, default: track === selected })
}
const selector = uniqueLabels(items)
return { html, title: config.title || 'Audio', selector, select: item => dash.setCurrentTrack(item.value) }
}
@@ -0,0 +1,72 @@
export function createMenu(art, name, icon) {
let current
const owned = new Map()
function remove(surface, callback = owned.get(surface)) {
if (!callback || owned.get(surface) !== callback)
return
owned.delete(surface)
const registry = art[surface]
if (surface === 'controls' && registry.cache?.get && registry.cache.get(name)?.option?.onSelect !== callback)
return
if (surface === 'setting' && registry.find && registry.find(name)?.onSelect !== callback)
return
registry.remove(name)
}
function clear() {
current = undefined
let failure
for (const [surface, callback] of [...owned]) {
try {
remove(surface, callback)
}
catch (error) {
failure ||= error
}
}
if (failure)
throw failure
}
function update(config, model, active) {
if (!model) {
clear()
return
}
const state = {}
current = state
const valid = () => state === current && active()
const onSelect = (item) => {
if (!valid())
return item.html
model.select(item, valid)
if (!valid())
return item.html
art.notice.show = `${model.title}: ${item.html}`
if (valid() && config.control)
art.controls.check(item)
if (valid() && config.setting)
art.setting.check(item)
return item.html
}
if (config.control) {
owned.set('controls', onSelect)
art.controls.update({ name, position: 'right', html: model.html, style: { padding: '0 10px' }, selector: model.selector, onSelect })
}
else {
remove('controls')
}
if (!valid())
return
if (config.setting) {
owned.set('setting', onSelect)
art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect })
}
else {
remove('setting')
}
}
return { update, clear }
}
@@ -0,0 +1,27 @@
export function qualityAdapter(dash) {
const modern = typeof dash.getRepresentationsByType === 'function'
return {
levels: () => modern ? dash.getRepresentationsByType('video') : dash.getBitrateInfoListFor('video'),
current(levels) {
if (modern)
return dash.getCurrentRepresentationForType('video')
const quality = dash.getQualityFor('video')
return levels.find(level => level.qualityIndex === quality)
},
item(level, index, selected, automatic) {
return modern
? { value: index, id: level.id, default: !automatic && selected != null && level.id === selected.id }
: { value: level.qualityIndex, default: !automatic && selected != null && level.qualityIndex === selected.qualityIndex }
},
select(item, valid) {
const automatic = item.value === 'auto'
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: automatic } } } })
if (!automatic && valid()) {
if (modern)
dash.setRepresentationForTypeById('video', item.id)
else
dash.setQualityFor('video', item.value)
}
},
}
}