refactor(mediabunny): [PKG-MB-07] own HLS selection intent and menu lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 20:51:31 +08:00
1 parent ccee9157a7
commit 8a2b56e829
27 files changed
+4217 -680

No files matched your search

@@ -199,14 +199,15 @@ export default class MediaBunnyEngine implements EnginePort {
async getHlsState() {
const media = this.media
const source = this.loadSeq
if (this.destroyed)
return null
try {
const state = await getHlsState(media)
return !this.destroyed && this.media === media ? state : null
return !this.destroyed && this.loadSeq === source && this.media === media ? state : null
}
catch (error) {
if (this.destroyed || this.media !== media)
if (this.destroyed || this.loadSeq !== source || this.media !== media)
return null
throw error
}
@@ -21,6 +21,8 @@ export interface EngineOptions {
// Stable coordinator-facing surface used by the public shim.
export interface EnginePort {
readonly loadSeq: number
readonly destroyed: boolean
readonly currentTime: number
readonly duration: number
readonly videoWidth: number
@@ -2,7 +2,6 @@ import type { Input } from 'mediabunny'
import type { PlaybackMedia } from './media'
export type SelectedTracks = Pick<PlaybackMedia, 'videoTrack' | 'audioTrack' | 'videoMode' | 'audioMode'>
interface SelectionHost {
input: Input | null
media: PlaybackMedia | null
@@ -10,65 +9,86 @@ interface SelectionHost {
loadSeq: number
replaceTracks: (tracks: SelectedTracks) => Promise<void>
}
type ResolveTracks = (input: Input, media: PlaybackMedia, value: unknown, current: () => boolean) => Promise<SelectedTracks | null>
export async function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
const requests = new WeakMap<SelectionHost, object>()
async function select(host: SelectionHost, value: unknown, resolve: ResolveTracks): Promise<void> {
const { input, loadSeq } = host
if (!host.media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getVideoTracks()
if (!current())
const token = {}
requests.set(host, token)
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && requests.get(host) === token
while (current()) {
const media: PlaybackMedia | null = host.media
if (!media?.isHls)
return
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
if (!current() || !videoTrack)
return
let audioTrack = media.audioTrack
let audioMode = media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
let selection: SelectedTracks | null
try {
selection = await resolve(input, media, value, current)
}
catch (error) {
if (!current())
return
if (host.media === media)
throw error
continue
}
selection = { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
}
catch (error) {
if (!current())
return
throw error
if (host.media !== media)
continue
if (selection) {
try {
await host.replaceTracks(selection)
}
catch (error) {
if (current())
throw error
}
}
return
}
if (current())
await host.replaceTracks(selection)
}
export async function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getAudioTracks()
if (!current())
return
const audioTrack = value === 'auto'
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
: tracks.find(track => track.id === value) ?? media.audioTrack
if (!current() || !audioTrack)
return
let videoTrack = media.videoTrack
let videoMode = media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
async function quality(input: Input, media: PlaybackMedia, value: unknown, current: () => boolean): Promise<SelectedTracks | null> {
const tracks = await input.getVideoTracks()
if (!current())
return null
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
if (!current() || !videoTrack)
return null
let audioTrack = media.audioTrack
let audioMode = media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
}
catch (error) {
if (!current())
return
throw error
}
if (current())
await host.replaceTracks(selection)
return { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
}
async function audio(input: Input, media: PlaybackMedia, value: unknown, current: () => boolean): Promise<SelectedTracks | null> {
const tracks = await input.getAudioTracks()
if (!current())
return null
const audioTrack = value === 'auto'
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
: tracks.find(track => track.id === value) ?? media.audioTrack
if (!current() || !audioTrack)
return null
let videoTrack = media.videoTrack
let videoMode = media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
return { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
}
export function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
return select(host, value, quality)
}
export function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
return select(host, value, audio)
}
@@ -0,0 +1,64 @@
import type { DisplayConfig, Label, MenuHost, MenuModel, SelectorItem } from './m3u8-types'
export function releaseAll(actions: (() => void)[]): void {
const failures: unknown[] = []
for (const action of actions) {
try {
action()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
export default function createMenu(art: MenuHost, name: string, icon: string) {
const owned = { control: false, setting: false }
let model: MenuModel | null = null
function remove(surface: 'control' | 'setting'): void {
if (!owned[surface])
return
owned[surface] = false
if (surface === 'control') {
if (art.controls.cache.has(name))
art.controls.remove(name)
}
else if (art.setting.find(name)) {
art.setting.remove(name)
}
}
function clear(): void {
model = null
releaseAll([() => remove('control'), () => remove('setting')])
}
function update(next: MenuModel | null, config: DisplayConfig, active: () => boolean, select: (item: SelectorItem, title: string) => Promise<Label>): void {
if (!next) {
clear()
return
}
model = next
const valid = () => model === next && active()
const onSelect = async (item: SelectorItem): Promise<Label> => {
if (!valid() || !next.selector.some(entry => entry.value === item.value))
return item.html
return select(item, next.title)
}
if (config.control) {
owned.control = true
art.controls.update({ name, position: 'right', html: next.html, style: { padding: '0 10px' }, selector: next.selector, onSelect })
}
else {
remove('control')
}
if (!valid())
return
if (config.setting) {
owned.setting = true
art.setting.update({ name, tooltip: next.html, html: next.title, icon, width: 200, selector: next.selector, onSelect })
}
else {
remove('setting')
}
}
return { update, clear }
}
@@ -0,0 +1,56 @@
import type { ProxyOptions } from './engine-types'
import type { Label, MenuModel, SelectorItem, State } from './m3u8-types'
type Options = NonNullable<ProxyOptions['m3u8']>
function unique(items: SelectorItem[]): SelectorItem[] {
const positions = new Map<Label, number>()
const output: SelectorItem[] = []
for (const item of items) {
const position = positions.get(item.html)
if (item.html !== undefined && position !== undefined) {
if (item.default)
output[position] = item
}
else {
positions.set(item.html, output.length)
output.push(item)
}
}
return output
}
export function qualityModel(state: State | null, config: NonNullable<Options['quality']>, active: () => boolean): MenuModel | null {
if ((!config.control && !config.setting) || !state?.levels.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (level => level.name || `${level.height}P`)
const html = state.currentLevel ? getName(state.currentLevel) : auto
const items: SelectorItem[] = []
for (const level of state.levels) {
if (!active())
return null
items.push({ html: getName(level), value: level.id, default: state.currentLevel?.id === level.id })
}
const heights = new Map(state.levels.map(level => [level.id, level.height]))
const selector = unique(items).sort((left, right) => (right.value === 'auto' ? 0 : heights.get(right.value) || 0) - (left.value === 'auto' ? 0 : heights.get(left.value) || 0))
selector.push({ html: auto, value: 'auto', default: !state.currentLevel })
return { title: config.title || 'Quality', html, selector }
}
export function audioModel(state: State | null, config: NonNullable<Options['audio']>, active: () => boolean): MenuModel | null {
if ((!config.control && !config.setting) || !state || state.audios.length < 2)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (track => track.name || track.lang || track.language)
const html = state.currentAudio ? getName(state.currentAudio) : auto
const items: SelectorItem[] = []
for (const track of state.audios) {
if (!active())
return null
items.push({ html: getName(track), value: track.id, default: state.currentAudio?.id === track.id })
}
const selector = unique(items)
selector.push({ html: auto, value: 'auto', default: !state.currentAudio })
return { title: config.title || 'Audio', html, selector }
}
@@ -0,0 +1,53 @@
import type { HlsState, ProxyOptions } from './engine-types'
export type State = NonNullable<HlsState>
export type Level = State['levels'][number]
export type Audio = State['audios'][number]
export type Label = string | null | undefined
export interface DisplayConfig {
control?: boolean
setting?: boolean
}
export interface SelectorItem {
html: Label
value: number | 'auto'
default: boolean
}
export interface MenuModel {
title: string
html: Label
selector: SelectorItem[]
}
export interface MenuOption {
name: string
html: Label
selector: SelectorItem[]
onSelect: (item: SelectorItem) => Promise<Label>
position?: 'right'
style?: { padding: string }
tooltip?: Label
icon?: string
width?: number
}
interface Registry {
update: (menu: MenuOption) => unknown
remove: (name: string) => unknown
}
export interface MenuHost {
controls: Registry & { cache: { has: (name: string) => boolean } }
setting: Registry & { find: (name: string) => unknown }
notice: { show: string }
on: (name: string, callback: () => unknown) => unknown
off: (name: string, callback: () => unknown) => unknown
}
export interface MenuShim {
engine: { readonly loadSeq: number, readonly destroyed: boolean }
getM3u8State: () => Promise<HlsState>
switchM3u8Quality: (value: unknown) => Promise<void>
switchM3u8Audio: (value: unknown) => Promise<void>
}
export interface SetupOptions {
art: MenuHost
shim: MenuShim
option: ProxyOptions
}
@@ -1,191 +0,0 @@
import $audio from '../../artplayer-plugin-hls-control/src/audio.svg?raw'
import $quality from '../../artplayer-plugin-hls-control/src/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 function setupM3u8Controls({ art, shim, option }) {
if (!option.m3u8)
return
function removeControl(name) {
if (art.controls.cache.has(name)) {
art.controls.remove(name)
}
}
function removeSetting(name) {
if (art.setting.find(name)) {
art.setting.remove(name)
}
}
function clearQuality() {
removeControl('mediabunny-quality')
removeSetting('mediabunny-quality')
}
function clearAudio() {
removeControl('mediabunny-audio')
removeSetting('mediabunny-audio')
}
function canRender(config = {}) {
return config.control || config.setting
}
async function updateQuality(state) {
const config = option.m3u8?.quality || {}
if (!canRender(config) || !state?.levels.length) {
clearQuality()
return
}
const auto = config.auto || 'Auto'
const title = config.title || 'Quality'
const getName = config.getName || (level => level.name || `${level.height}P`)
const defaultHtml = state.currentLevel
? getName(state.currentLevel)
: auto
const selector = uniqBy(
state.levels.map((item) => {
return {
html: getName(item),
value: item.id,
default: state.currentLevel?.id === item.id,
}
}),
'html',
).sort((a, b) => {
const left = state.levels.find(item => item.id === a.value)
const right = state.levels.find(item => item.id === b.value)
return (right?.height || 0) - (left?.height || 0)
})
selector.push({
html: auto,
value: 'auto',
default: !state.currentLevel,
})
const onSelect = async (item) => {
await shim.switchM3u8Quality(item.value)
art.notice.show = `${title}: ${item.html}`
await update()
return item.html
}
if (config.control) {
art.controls.update({
name: 'mediabunny-quality',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'mediabunny-quality',
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect,
})
}
}
async function updateAudio(state) {
const config = option.m3u8?.audio || {}
if (!canRender(config) || !state?.audios.length || state.audios.length < 2) {
clearAudio()
return
}
const auto = config.auto || 'Auto'
const title = config.title || 'Audio'
const getName = config.getName || (track => track.name || track.lang || track.language)
const defaultHtml = state.currentAudio
? getName(state.currentAudio)
: auto
const selector = uniqBy(
state.audios.map((item) => {
return {
html: getName(item),
value: item.id,
default: state.currentAudio?.id === item.id,
}
}),
'html',
)
selector.push({
html: auto,
value: 'auto',
default: !state.currentAudio,
})
const onSelect = async (item) => {
await shim.switchM3u8Audio(item.value)
art.notice.show = `${title}: ${item.html}`
await update()
return item.html
}
if (config.control) {
art.controls.update({
name: 'mediabunny-audio',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'mediabunny-audio',
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect,
})
}
}
async function update() {
const state = await shim.getM3u8State()
if (!state) {
clearQuality()
clearAudio()
return
}
await Promise.all([
updateQuality(state),
updateAudio(state),
])
}
art.on('video:loadedmetadata', update)
art.on('restart', update)
return { update }
}
@@ -0,0 +1,104 @@
import type { Label, SelectorItem, SetupOptions } from './m3u8-types'
import $audio from '../../artplayer-plugin-hls-control/src/audio.svg?raw'
import $quality from '../../artplayer-plugin-hls-control/src/quality.svg?raw'
import createMenu, { releaseAll } from './m3u8-menu'
import { audioModel, qualityModel } from './m3u8-model'
export function setupM3u8Controls({ art, shim, option }: SetupOptions) {
if (!option.m3u8)
return
let closed = false
let revision = 0
let selection = 0
const quality = createMenu(art, 'mediabunny-quality', $quality)
const audio = createMenu(art, 'mediabunny-audio', $audio)
const current = (source: number) => !closed && !shim.engine.destroyed && shim.engine.loadSeq === source
async function update(): Promise<void> {
if (closed || shim.engine.destroyed)
return
const version = ++revision
const source = shim.engine.loadSeq
const active = () => version === revision && current(source)
try {
const state = await shim.getM3u8State()
if (!active())
return
const qualityConfig = option.m3u8?.quality || {}
const qualityView = qualityModel(state, qualityConfig, active)
if (!active())
return
quality.update(qualityView, qualityConfig, () => current(source), (item, title) => select('quality', item, title))
if (!active())
return
const audioConfig = option.m3u8?.audio || {}
const audioView = audioModel(state, audioConfig, active)
if (active())
audio.update(audioView, audioConfig, () => current(source), (item, title) => select('audio', item, title))
}
catch (error) {
if (active())
throw error
}
}
async function select(kind: 'quality' | 'audio', item: SelectorItem, title: string): Promise<Label> {
const intent = ++selection
const source = shim.engine.loadSeq
revision++
const active = () => intent === selection && current(source)
try {
if (kind === 'quality')
await shim.switchM3u8Quality(item.value)
else
await shim.switchM3u8Audio(item.value)
if (active()) {
await update()
if (active())
art.notice.show = `${title}: ${item.html}`
}
}
catch (error) {
if (active()) {
await refresh()
if (active())
throw error
}
}
return item.html
}
function refresh(): Promise<void> {
return update().catch(error => console.warn('MediaBunny HLS menu update:', error))
}
function invalidate(): void {
revision++
selection++
try {
releaseAll([quality.clear, audio.clear])
}
catch (error) { console.warn('MediaBunny HLS menu cleanup:', error) }
}
function destroy(): void {
if (closed)
return
closed = true
invalidate()
try {
releaseAll([
() => art.off('video:loadedmetadata', refresh),
() => art.off('restart', refresh),
() => art.off('video:loadstart', invalidate),
() => art.off('video:error', invalidate),
() => art.off('destroy', destroy),
])
}
catch (error) { console.warn('MediaBunny HLS listener cleanup:', error) }
}
art.on('video:loadedmetadata', refresh)
art.on('restart', refresh)
art.on('video:loadstart', invalidate)
art.on('video:error', invalidate)
art.on('destroy', destroy)
return { update }
}