@oplayer/dash
Version:
Dash plugin for oplayer
340 lines (287 loc) • 10.1 kB
text/typescript
import { loadSDK, type Player, type PlayerPlugin, type RequiredPartial, type Source } from '@oplayer/core'
import type {
BitrateInfo,
MediaPlayerClass,
MediaPlayerSettingClass,
ProtectionDataSet,
QualityChangeRenderedEvent
} from 'dashjs'
const PLUGIN_NAME = 'oplayer-plugin-dash'
export type Matcher = (video: HTMLVideoElement, source: Source) => boolean
export interface DashPluginOptions {
library?: string
matcher?: Matcher
/**
* default auto
*/
defaultQuality?: (levels: BitrateInfo[]) => number
/**
* default browser language
*/
defaultAudio?: (tracks: dashjs.MediaInfo[]) => number
/**
* default browser language
*/
defaultSubtitle?: (tracks: dashjs.MediaInfo[]) => number
/**
* config for dashjs
*
* @type {MediaPlayerSettingClass}
*/
config?: MediaPlayerSettingClass
drm?: ProtectionDataSet
// qualityLabelBuilder?: (instance: MediaPlayerClass) => {
// name: string
// default: boolean
// value: any
// }[]
/**
* enable quality control for the stream, does not apply to the native (iPhone) clients.
* @default: true
*/
qualityControl?: boolean
/**
* control how the stream quality is switched. default: immediate
* @value immediate: Trigger an immediate quality level switch to new quality level. This will abort the current fragment request if any, flush the whole buffer, and fetch fragment matching with current position and requested quality level.
* @value smooth: Trigger a quality level switch for next fragment. This could eventually flush already buffered next fragment.
*/
qualitySwitch?: 'immediate' | 'smooth'
/**
* @default: false
*/
withBitrate?: boolean
/**
* @default: true
*/
audioControl?: boolean
/**
* @default: true
*/
textControl?: boolean
}
const defaultMatcher: Matcher = (_, source) =>
source.format === 'dash' ||
source.format === 'mpd' ||
((source.format === 'auto' || typeof source.format === 'undefined') && /.mpd(#|\?|$)/i.test(source.src))
class DashPlugin implements PlayerPlugin {
key = 'dash'
name = PLUGIN_NAME
version = __VERSION__
static library: typeof import('dashjs')
player!: Player
instance?: MediaPlayerClass
options: RequiredPartial<DashPluginOptions, 'config' | 'library' | 'drm'> = {
textControl: true,
audioControl: true,
qualityControl: true,
withBitrate: false,
qualitySwitch: 'immediate',
matcher: defaultMatcher,
defaultQuality: () => -1,
defaultAudio: () => -1,
defaultSubtitle: () => -1
}
constructor(options?: DashPluginOptions) {
Object.assign(this.options, options)
}
apply(player: Player) {
this.player = player
return this
}
async load({ $video }: Player, source: Source) {
const { matcher, library } = this.options
if (!matcher($video, source)) return false
if (!DashPlugin.library) {
DashPlugin.library =
globalThis.dashjs || (library ? await loadSDK(library, 'dashjs') : (await import('dashjs')).default)
}
if (!DashPlugin.library.supportsMediaSource()) return false
this.instance = DashPlugin.library.MediaPlayer().create()
const { player, instance } = this
const { drm, config } = this.options
if (config) instance.updateSettings(config)
if (drm) instance.setProtectionData(drm)
instance.initialize($video, source.src, $video.autoplay)
instance.on(DashPlugin.library.MediaPlayer.events.ERROR, function (event: any) {
const err = event.event || event.error
const message = event.event ? event.event.message || event.type : undefined
player.emit('error', { pluginName: PLUGIN_NAME, message, ...err })
})
if (player.context.ui?.setting) {
// @ts-ignore
if (instance.getBitrateInfoListFor) {
generateSetting(player, instance, this.options)
} else {
console.warn('https://github.com/shiyiya/oplayer/issues/155')
}
}
return this
}
destroy() {
if (this.instance) {
const { player, instance } = this
if (player.context.ui?.setting) removeSetting(player)
instance.destroy()
}
}
}
function getSettingsByType(instance: MediaPlayerClass, type: 'video', withBitrate?: boolean) {
const bitrateInfoList = instance.getBitrateInfoListFor(type)
const isAuto = Boolean(instance.getSettings().streaming?.abr?.autoSwitchBitrate?.video)
const videoQuality = instance.getQualityFor('video')
if (bitrateInfoList.length > 1) {
return bitrateInfoList
.toSorted((a, b) => b.bitrate - a.bitrate)
.map((it) => {
let name = it.height + 'p'
if (withBitrate) {
const kb = it.bitrate / 1000
const useMb = kb > 1000
const number = useMb ? (kb / 1000).toFixed(2) : Math.floor(kb)
name += ` (${number}${useMb ? 'm' : 'k'}bps)`
}
return {
name,
default: isAuto ? false : videoQuality == it.qualityIndex,
value: it.qualityIndex
}
})
}
return []
}
const generateSetting = (player: Player, instance: MediaPlayerClass, options: DashPlugin['options']) => {
instance.on(DashPlugin.library.MediaPlayer.events.STREAM_INITIALIZED, function () {
if (options.qualityControl) {
const quality = instance.getBitrateInfoListFor('video')
if (quality.length < 2) return
const defaultLevel = options.defaultQuality(quality)
if (defaultLevel != -1) instance.setQualityFor('video', defaultLevel)
settingUpdater({
name: 'Quality',
icon: player.context.ui.icons.quality,
settings: () =>
[
{
name: player.locales.get('Auto'),
default: Boolean(instance.getSettings().streaming?.abr?.autoSwitchBitrate?.video),
value: -1
}
].concat(getSettingsByType(instance, 'video', options.withBitrate)),
onChange({ value }) {
instance.updateSettings({
streaming: { abr: { autoSwitchBitrate: { video: value == -1 } } }
})
if (value != -1) {
instance.setQualityFor('video', value, options.qualitySwitch == 'immediate')
}
}
})
instance.on(
DashPlugin.library.MediaPlayer.events.QUALITY_CHANGE_RENDERED,
function qualityMenuUpdater(data: QualityChangeRenderedEvent) {
if (data.mediaType !== 'video' || !instance.getSettings().streaming?.abr?.autoSwitchBitrate?.video)
return
const height = instance.getBitrateInfoListFor('video')[data.newQuality]?.height
const levelName = player.locales.get('Auto') + (height ? ` (${height}p)` : '')
player.context.ui?.setting.updateLabel(`${PLUGIN_NAME}-Quality`, levelName)
}
)
}
if (options.audioControl) {
const audioTracks = instance.getTracksFor('audio')
if (audioTracks.length < 2) return
let defaultAudio: number | undefined = options.defaultAudio(audioTracks)
if (defaultAudio == -1) {
defaultAudio = audioTracks.find(({ lang }) => {
return lang === navigator.language || lang === navigator.language.split('-')[0]
})?.id as unknown as number
}
if (defaultAudio != -1 && defaultAudio != undefined) {
instance.setCurrentTrack(audioTracks.find((t) => (t.id as unknown as number) == defaultAudio)!)
}
const currentAudio = instance.getCurrentTrackFor('audio')
settingUpdater({
name: 'Language',
icon: player.context.ui.icons.lang,
settings() {
return audioTracks.map((it) => ({
name: it.lang || 'unknown',
default: currentAudio?.index != null && currentAudio.index == it.index,
value: it
}))
},
onChange({ value }) {
instance.setCurrentTrack(value)
}
})
}
if (options.textControl) {
const textTracks = instance.getTracksFor('text')
if (textTracks.length < 1) return
let defaultSubtitle: number | undefined = options.defaultSubtitle(textTracks)
if (defaultSubtitle == -1) {
defaultSubtitle = textTracks.find(({ lang }) => {
return lang === navigator.language || lang === navigator.language.split('-')[0]
})?.id as unknown as number
}
if (defaultSubtitle != -1 && defaultSubtitle != undefined) {
instance.enableText(true)
instance.setTextTrack(defaultSubtitle)
}
const currentTrack = instance.getCurrentTrackFor('text')
settingUpdater({
name: 'Subtitle',
icon: player.context.ui.icons.subtitle,
settings() {
return [
{
name: player.locales.get('Off'),
default: !instance.isTextEnabled(),
value: -1 as any
}
].concat(
textTracks.map((it) => ({
name: it.lang || 'unknown',
default: currentTrack?.index != null && currentTrack.index == it.index,
value: it.index
}))
)
},
onChange({ value }) {
instance.enableText(value != -1)
if (value != -1) instance.setTextTrack(value)
}
})
}
})
function settingUpdater(arg: {
icon: string
name: string
settings: () => {
name: string
default: boolean
value: any
}[]
onChange: (it: { value: any }) => void
}) {
const settings = arg.settings()
const { name, icon, onChange } = arg
player.context.ui.setting.unregister(`${PLUGIN_NAME}-${name}`)
player.context.ui.setting.register({
name: player.locales.get(name),
icon,
onChange,
type: 'selector',
key: `${PLUGIN_NAME}-${name}`,
children: settings
})
}
}
const removeSetting = (player: Player) => {
;['Quality', 'Language', 'Subtitle'].forEach((it) =>
player.context.ui.setting.unregister(`${PLUGIN_NAME}-${it}`)
)
}
export default function create(options?: DashPluginOptions) {
return new DashPlugin(options)
}