import { loadSDK, PartialRequired, type Player, type PlayerPlugin, type Source } from '@oplayer/core' //@ts-ignore import type shaka from 'shaka-player' export type Matcher = (source: Source) => boolean export interface ShakaPluginOptions { library?: string matcher?: Matcher /** *shaka config * @type {object} */ config?: any requestFilter?: shaka.extern.RequestFilter /** *default: 'menu' */ qualityControlType?: 'menu' | 'setting' qualityControl?: boolean audioControl?: boolean textControl?: boolean } const defaultMatcher: Matcher = (source) => { if (source.format && ['m3u8', 'mpd', 'shaka'].includes(source.format)) { return true } return ( (source.format === 'auto' || typeof source.format === 'undefined') && /(m3u8|mpd|shaka)(#|\?|$)/i.test(source.src) ) } class ShakaPlugin implements PlayerPlugin { key = 'shaka' name = 'oplayer-plugin-shaka' version = __VERSION__ static library: typeof shaka player!: Player instance?: shaka.Player & { eventManager: shaka.util.EventManager } options: PartialRequired = { matcher: defaultMatcher, qualityControl: true, audioControl: true, textControl: true, qualityControlType: 'menu' } constructor(options?: ShakaPluginOptions) { Object.assign(this.options, options) } apply(player: Player) { this.player = player return this } async load(player: Player, source: Source) { if (!this.options.matcher(source)) return false const { library, config, requestFilter, qualityControl, audioControl, textControl, qualityControlType } = this.options if (!ShakaPlugin.library) { ShakaPlugin.library = (globalThis as any).shaka || (library ? await loadSDK(library, 'shaka') : (await import('shaka-player/dist/shaka-player.compiled.js')).default) ShakaPlugin.library.polyfill.installAll() } const ShakaPlayer = ShakaPlugin.library.Player if (!ShakaPlayer.isBrowserSupported()) return false this.instance = new ShakaPlayer() as unknown as shaka.Player & { eventManager: shaka.util.EventManager timer: any } await this.instance.attach(player.$video) if (config) { this.instance.configure(config) } if (requestFilter) { this.instance.getNetworkingEngine()?.registerRequestFilter(requestFilter) } const eventManager = (this.instance.eventManager = new ShakaPlugin.library.util.EventManager()) eventManager.listen(this.instance, 'loading', (event) => { player.emit('loading', event) }) eventManager.listen(this.instance, 'loaded', (event) => { player.emit('loaded', event) }) eventManager.listen(this.instance, 'error', (event) => { player.emit('error', { pluginName: ShakaPlugin.name, ...event }) }) eventManager.listenOnce(player.$video, 'seeking', () => { // ignore first seeking ? setTimeout(() => { player.emit('seeked') }) }) try { await this.instance.load(source.src) } catch (error: any) { player.emit('error', { pluginName: ShakaPlugin.name, ...error }) } if (player.options.isLive) { eventManager.listenOnce(player.$video, 'loadedmetadata', () => { player.$video.currentTime = this.seekRange.end }) const button = player.$root.querySelector('[aria-label="time"')?.parentElement const dot = button?.firstElementChild as HTMLSpanElement | undefined if (button && dot) { eventManager.listen(button, 'click', () => { player.$video.currentTime = this.seekRange.end }) const backText = player.locales.get('Back to Live') const updateIsLive = () => { const timeBehindLiveEdge = this.seekRange.end - player.$video.currentTime // var streamPosition = Date.now() / 1000 - timeBehindLiveEdge if (timeBehindLiveEdge > 5) { dot.style.backgroundColor = '#ccc' button.ariaLabel = backText } else { dot.style.cssText = '' button.removeAttribute('aria-label') } } this.instance.eventManager.listen(player.$video, 'timeupdate', updateIsLive) } Object.defineProperty(player, 'duration', { get: () => { if (this.instance) return this._duration return player.$video.duration } }) Object.defineProperty(player, 'currentTime', { get: () => { if (this.instance) return this.getCurrentTime() else return player.$video.currentTime } }) Object.defineProperty(player, 'seek', { value: (v: number) => { if (this.instance) player.$video.currentTime = this.seekRange.start + v else player.$video.currentTime = v } }) } if (player.context.ui) { if (qualityControl) { this.setupQuality(player, this.instance, qualityControlType) // eventManager.listen(this.instance, 'variantchanged', () => {}) // eventManager.listen(this.instance, 'trackschanged', () => {}) } if (audioControl) { this.setupAudioSelection(player, this.instance) // eventManager.listen(this.instance, 'audiotrackschanged', () => {}) } if (textControl) { this.setupTextSelection(player, this.instance) // eventManager.listen(this.instance, 'texttrackvisibility', () => {}) // eventManager.listen(this.instance, 'textchanged', (e) => {}) // eventManager.listen(this.instance, 'trackschanged', () => {}) } } return this } getCurrentTime() { if (!this.instance) return 0 const mediaElement = this.instance.getMediaElement() return mediaElement ? mediaElement.currentTime - this.seekRange.start : 0 } get seekRange() { if (!this.instance) return { start: 0, end: 0 } return this.instance.seekRange() } get _duration() { if (!this.instance) return 0 return this.seekRange.end - this.seekRange.start } async destroy() { ;['Quality', 'Language', 'Subtitle'].forEach((it) => this.player.context.ui.setting.unregister(`${ShakaPlugin.name}-${it}`) ) this.player.context.ui.menu.unregister(`${ShakaPlugin.name}-${'Quality'}`) this.instance?.eventManager.removeAll() await this.instance?.unload() await this.instance?.destroy() this.instance = undefined } setupQuality = ( player: Player, instance: shaka.Player, qualityControlType: ShakaPluginOptions['qualityControlType'] ) => { // https://github.com/shaka-project/shaka-player/blob/1f336dd319ad23a6feb785f2ab05a8bc5fc8e2a2/ui/resolution_selection.js#L90 let tracks: shaka.extern.Track[] = [] if (instance.getLoadMode() != ShakaPlugin.library.Player.LoadMode.SRC_EQUALS) { tracks = instance.getVariantTracks() } const selectedTrack = tracks.find((track) => track.active) if (selectedTrack) { tracks = tracks.filter((track) => { if (track.language != selectedTrack.language) { return false } if ( track.channelsCount && selectedTrack.channelsCount && track.channelsCount != selectedTrack.channelsCount ) { return false } if (JSON.stringify(track.audioRoles) != JSON.stringify(selectedTrack.audioRoles)) { return false } return true }) } if (instance.isAudioOnly()) { tracks = tracks.filter((track, idx) => { return tracks.findIndex((t) => t.bandwidth == track.bandwidth) == idx }) } else { const audiosIds = [...new Set(tracks.map((t) => t.audioId))].filter((t) => t !== null) if (audiosIds.length > 1) { tracks = tracks.filter((track, idx) => { const otherIdx = tracks.findIndex((t) => { const ret = t.height == track.height && t.videoBandwidth == track.videoBandwidth && t.frameRate == track.frameRate && t.hdr == track.hdr && t.videoLayout == track.videoLayout return ret }) return otherIdx == idx }) } else { tracks = tracks.filter((track, idx) => { const otherIdx = tracks.findIndex((t) => { const ret = t.height == track.height && t.bandwidth == track.bandwidth && t.frameRate == track.frameRate && t.hdr == track.hdr && t.videoLayout == track.videoLayout return ret }) return otherIdx == idx }) } } if (!(tracks.length > 1)) return if (instance.isAudioOnly()) { tracks.sort((t1, t2) => { return t2.bandwidth - t1.bandwidth }) } else { tracks.sort((t1, t2) => { if (t2.height == t1.height || t1.height == null || t2.height == null) { return t2.bandwidth - t1.bandwidth } return t2.height - t1.height }) } const abrEnabled = instance.getConfiguration().abr.enabled const settings = tracks.map((t) => { return { name: !instance.isAudioOnly() && t.height && t.width ? this.getResolutionLabel_(t, tracks) : t.bandwidth ? Math.round(t.bandwidth / 1000) + ' kbits/s' : 'Unknown', default: !abrEnabled && t == selectedTrack, value: t } }) const ctrl = qualityControlType == 'menu' ? player.context.ui.menu : player.context.ui.setting const autoText = player.locales.get('Auto') ctrl.unregister(`${ShakaPlugin.name}-Quality`) ctrl.register({ icon: qualityControlType == 'setting' ? player.context.ui.icons.quality : undefined, name: qualityControlType == 'setting' ? 'Quality' : !abrEnabled && selectedTrack ? this.getResolutionLabel_(selectedTrack, []) : autoText, type: 'selector', key: `${ShakaPlugin.name}-Quality`, children: [ { name: player.locales.get('Auto'), default: abrEnabled, value: -1 } ].concat(settings as any), onChange: ({ value }: { value: shaka.extern.Track | -1 }, dom: HTMLButtonElement) => { const isAuto = value == -1 instance.configure({ abr: { enabled: isAuto } }) if (!isAuto) { dom.textContent = this.getResolutionLabel_(value, []) instance.selectVariantTrack(value, /* clearBuffer */ true) } else { dom.textContent = autoText // setupQuality(player, instance) } } }) } setupAudioSelection = (player: Player, instance: shaka.Player) => { const audioTracks = instance.getAudioTracks() if (!(audioTracks.length > 1)) return const levels = audioTracks .sort((a, b) => { return a.language.localeCompare(b.language) }) .map((level) => { return { //@ts-expect-error name: `${level.language} ${ShakaPlugin.library.util.MimeUtils.getNormalizedCodec?.(level.codecs) || level.codecs}`, default: level.active, value: level } }) this.settingUpdater({ player, name: 'Language', icon: player.context.ui.icons.lang, settings: levels, onChange({ value }) { instance.selectAudioTrack(value) } }) } setupTextSelection = (player: Player, instance: shaka.Player) => { const tracks = instance.getTextTracks() if (!(tracks.length > 1)) return const isTextTrackVisible = instance.isTextTrackVisible() const levels = [ { name: player.locales.get('Off'), default: !isTextTrackVisible, value: -1 } ].concat( tracks .sort((a, b) => { return a.language.localeCompare(b.language) }) .map((level) => { return { name: level.language, default: isTextTrackVisible && level.active, value: level } }) as any ) this.settingUpdater({ player, name: 'Subtitle', icon: player.context.ui.icons.lang, settings: levels, onChange({ value }) { if (value != -1) instance.selectTextTrack(value) instance.setTextTrackVisibility(value != -1) } }) } settingUpdater(arg: { icon: string name: string settings: { name: string default: boolean value: any }[] player: Player onChange: (it: { value: any }) => void }) { const { name, icon, onChange, player, settings } = arg player.context.ui.setting.unregister(`${ShakaPlugin.name}-${name}`) player.context.ui.setting.register({ name: player.locales.get(name), icon, onChange, type: 'selector', key: `${ShakaPlugin.name}-${name}`, children: settings }) } getResolutionLabel_(track: shaka.extern.Track, tracks: shaka.extern.Track[]) { const trackHeight = track.height || 0 const trackWidth = track.width || 0 let height = trackHeight const aspectRatio = trackWidth / trackHeight if (aspectRatio > 16 / 9) { height = Math.round((trackWidth * 9) / 16) } let text = height + 'p' if (height == 2160) { text = '4K' } const frameRates = new Set() for (const item of tracks) { if (item.frameRate) { frameRates.add(Math.round(item.frameRate)) } } if (frameRates.size > 1) { const frameRate = track.frameRate if (frameRate && (frameRate >= 50 || frameRate <= 20)) { text += Math.round(frameRate) } } if (track.hdr == 'PQ' || track.hdr == 'HLG') { text += ' (HDR)' } if (track.videoLayout == 'CH-STEREO') { text += ' (3D)' } const hasDuplicateResolution = tracks.some((otherTrack) => { return otherTrack != track && otherTrack.height == track.height }) if (hasDuplicateResolution && this.options.qualityControlType == 'setting') { const hasDuplicateBandwidth = tracks.some((otherTrack) => { return ( otherTrack != track && otherTrack.height == track.height && (otherTrack.videoBandwidth || otherTrack.bandwidth) == (track.videoBandwidth || track.bandwidth) ) }) if (!hasDuplicateBandwidth) { const bandwidth = track.videoBandwidth || track.bandwidth text += ' (' + Math.round(bandwidth / 1000) + ' kbits/s)' } } return text } } export default function create(options?: ShakaPluginOptions) { return new ShakaPlugin(options) }