UNPKG

@tianfeng98/hls.js

Version:

HLS.js is a JavaScript library that supports playing MPEG-TS and HEVC encoded HLS streams in browsers with support for MSE.

704 lines (638 loc) 21.4 kB
import { ManifestLoadedData, ManifestParsedData, LevelLoadedData, TrackSwitchedData, ErrorData, LevelSwitchingData, LevelsUpdatedData, ManifestLoadingData, FragBufferedData, } from '../types/events'; import { Level, VideoRangeValues, isVideoRange } from '../types/level'; import { Events } from '../events'; import { ErrorTypes, ErrorDetails } from '../errors'; import { areCodecsMediaSourceSupported, codecsSetSelectionPreferenceValue, getCodecCompatibleName, } from '../utils/codecs'; import BasePlaylistController from './base-playlist-controller'; import { PlaylistContextType, PlaylistLevelType } from '../types/loader'; import ContentSteeringController from './content-steering-controller'; import { reassignFragmentLevelIndexes } from '../utils/level-helper'; import { hlsDefaultConfig } from '../config'; import type Hls from '../hls'; import type { HlsUrlParameters, LevelParsed } from '../types/level'; import type { MediaPlaylist } from '../types/media-playlist'; let chromeOrFirefox: boolean; export default class LevelController extends BasePlaylistController { private _levels: Level[] = []; private _firstLevel: number = -1; private _maxAutoLevel: number = -1; private _startLevel?: number; private currentLevel: Level | null = null; private currentLevelIndex: number = -1; private manualLevelIndex: number = -1; private steering: ContentSteeringController | null; public onParsedComplete!: Function; constructor( hls: Hls, contentSteeringController: ContentSteeringController | null, ) { super(hls, '[level-controller]'); this.steering = contentSteeringController; this._registerListeners(); } private _registerListeners() { const { hls } = this; hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this); hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this); hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this); hls.on(Events.AUDIO_TRACK_SWITCHED, this.onAudioTrackSwitched, this); hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this); hls.on(Events.ERROR, this.onError, this); } private _unregisterListeners() { const { hls } = this; hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this); hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this); hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this); hls.off(Events.AUDIO_TRACK_SWITCHED, this.onAudioTrackSwitched, this); hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this); hls.off(Events.ERROR, this.onError, this); } public destroy() { this._unregisterListeners(); this.steering = null; this.resetLevels(); super.destroy(); } public stopLoad(): void { const levels = this._levels; // clean up live level details to force reload them, and reset load errors levels.forEach((level) => { level.loadError = 0; level.fragmentError = 0; }); super.stopLoad(); } private resetLevels() { this._startLevel = undefined; this.manualLevelIndex = -1; this.currentLevelIndex = -1; this.currentLevel = null; this._levels = []; this._maxAutoLevel = -1; } private onManifestLoading( event: Events.MANIFEST_LOADING, data: ManifestLoadingData, ) { this.resetLevels(); } protected onManifestLoaded( event: Events.MANIFEST_LOADED, data: ManifestLoadedData, ) { const levels: Level[] = []; const levelSet: { [key: string]: Level } = {}; let levelFromSet: Level; // regroup redundant levels together data.levels.forEach((levelParsed: LevelParsed) => { const attributes = levelParsed.attrs; // erase audio codec info if browser does not support mp4a.40.34. // demuxer will autodetect codec and fallback to mpeg/audio if (levelParsed.audioCodec?.indexOf('mp4a.40.34') !== -1) { chromeOrFirefox ||= /chrome|firefox/i.test(navigator.userAgent); if (chromeOrFirefox) { levelParsed.audioCodec = undefined; } } if (levelParsed.audioCodec) { levelParsed.audioCodec = getCodecCompatibleName( levelParsed.audioCodec, this.hls.config.preferManagedMediaSource, ); } const { AUDIO, CODECS, 'FRAME-RATE': FRAMERATE, 'HDCP-LEVEL': HDCP, 'PATHWAY-ID': PATHWAY, RESOLUTION, SUBTITLES, 'VIDEO-RANGE': VIDEO_RANGE, } = attributes; const contentSteeringPrefix = __USE_CONTENT_STEERING__ ? `${PATHWAY || '.'}-` : ''; const levelKey = `${contentSteeringPrefix}${levelParsed.bitrate}-${RESOLUTION}-${FRAMERATE}-${CODECS}-${VIDEO_RANGE}-${HDCP}`; levelFromSet = levelSet[levelKey]; let fallbackIndex = -1; if (!levelFromSet) { levelFromSet = new Level(levelParsed); levelSet[levelKey] = levelFromSet; levels.push(levelFromSet); } else if ( (fallbackIndex = levelFromSet.url.indexOf(levelParsed.url)) === -1 ) { levelFromSet.addFallback(levelParsed); } levelFromSet.addGroupId('audio', AUDIO, fallbackIndex); levelFromSet.addGroupId('text', SUBTITLES, fallbackIndex); }); this.filterAndSortMediaOptions(levels, data); } private filterAndSortMediaOptions( unfilteredLevels: Level[], data: ManifestLoadedData, ) { const { preferManagedMediaSource } = this.hls.config; let audioTracks: MediaPlaylist[] = []; let subtitleTracks: MediaPlaylist[] = []; let resolutionFound = false; let videoCodecFound = false; let audioCodecFound = false; // only keep levels with supported audio/video codecs let levels = unfilteredLevels.filter( ({ audioCodec, videoCodec, width, height, unknownCodecs }) => { resolutionFound ||= !!(width && height); videoCodecFound ||= !!videoCodec; audioCodecFound ||= !!audioCodec; return ( !unknownCodecs?.length && (!audioCodec || areCodecsMediaSourceSupported( audioCodec, 'audio', preferManagedMediaSource, )) && (!videoCodec || areCodecsMediaSourceSupported( videoCodec, 'video', preferManagedMediaSource, )) ); }, ); // remove audio-only and invalid video-range levels if we also have levels with video codecs or RESOLUTION signalled if ((resolutionFound || videoCodecFound) && audioCodecFound) { levels = levels.filter( ({ videoCodec, videoRange, width, height }) => (!!videoCodec || !!(width && height)) && isVideoRange(videoRange), ); } if (levels.length === 0) { // Dispatch error after MANIFEST_LOADED is done propagating Promise.resolve().then(() => { if (this.hls) { if (unfilteredLevels.length) { this.warn( `One or more CODECS in variant not supported: ${JSON.stringify( unfilteredLevels[0].attrs, )}`, ); } const error = new Error( 'no level with compatible codecs found in manifest', ); this.hls.trigger(Events.ERROR, { type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, fatal: true, url: data.url, error, reason: error.message, }); } }); return; } if (data.audioTracks) { audioTracks = data.audioTracks.filter( (track) => !track.audioCodec || areCodecsMediaSourceSupported( track.audioCodec, 'audio', preferManagedMediaSource, ), ); // Assign ids after filtering as array indices by group-id assignTrackIdsByGroup(audioTracks); } if (data.subtitles) { subtitleTracks = data.subtitles; assignTrackIdsByGroup(subtitleTracks); } // start bitrate is the first bitrate of the manifest const unsortedLevels = levels.slice(0); // sort levels from lowest to highest levels.sort((a, b) => { if (a.attrs['HDCP-LEVEL'] !== b.attrs['HDCP-LEVEL']) { return (a.attrs['HDCP-LEVEL'] || '') > (b.attrs['HDCP-LEVEL'] || '') ? 1 : -1; } // sort on height before bitrate for cap-level-controller if (resolutionFound && a.height !== b.height) { return a.height - b.height; } if (a.frameRate !== b.frameRate) { return a.frameRate - b.frameRate; } if (a.codecSet !== b.codecSet) { const valueA = codecsSetSelectionPreferenceValue(a.codecSet); const valueB = codecsSetSelectionPreferenceValue(b.codecSet); if (valueA !== valueB) { return valueB - valueA; } } if (a.videoRange !== b.videoRange) { return ( VideoRangeValues.indexOf(a.videoRange) - VideoRangeValues.indexOf(b.videoRange) ); } if (a.bitrate !== b.bitrate) { return a.bitrate - b.bitrate; } return 0; }); let firstLevelInPlaylist = unsortedLevels[0]; if (this.steering) { levels = this.steering.filterParsedLevels(levels); if (levels.length !== unsortedLevels.length) { for (let i = 0; i < unsortedLevels.length; i++) { if (unsortedLevels[i].pathwayId === levels[0].pathwayId) { firstLevelInPlaylist = unsortedLevels[i]; break; } } } } this._levels = levels; // find index of first level in sorted levels for (let i = 0; i < levels.length; i++) { if (levels[i] === firstLevelInPlaylist) { this._firstLevel = i; const firstLevelBitrate = firstLevelInPlaylist.bitrate; const bandwidthEstimate = this.hls.bandwidthEstimate; this.log( `manifest loaded, ${levels.length} level(s) found, first bitrate: ${firstLevelBitrate}`, ); // Update default bwe to first variant bitrate as long it has not been configured or set if (this.hls.userConfig?.abrEwmaDefaultEstimate === undefined) { const startingBwEstimate = Math.min( firstLevelBitrate, this.hls.config.abrEwmaDefaultEstimateMax, ); if ( startingBwEstimate > bandwidthEstimate && bandwidthEstimate === hlsDefaultConfig.abrEwmaDefaultEstimate ) { this.hls.bandwidthEstimate = startingBwEstimate; } } break; } } // Audio is only alternate if manifest include a URI along with the audio group tag, // and this is not an audio-only stream where levels contain audio-only const audioOnly = audioCodecFound && !videoCodecFound; const edata: ManifestParsedData = { levels, audioTracks, subtitleTracks, sessionData: data.sessionData, sessionKeys: data.sessionKeys, firstLevel: this._firstLevel, stats: data.stats, audio: audioCodecFound, video: videoCodecFound, altAudio: !audioOnly && audioTracks.some((t) => !!t.url), }; this.hls.trigger(Events.MANIFEST_PARSED, edata); // Initiate loading after all controllers have received MANIFEST_PARSED if (this.hls.config.autoStartLoad || this.hls.forceStartLoad) { this.hls.startLoad(this.hls.config.startPosition); } } get levels(): Level[] | null { if (this._levels.length === 0) { return null; } return this._levels; } get level(): number { return this.currentLevelIndex; } set level(newLevel: number) { const levels = this._levels; if (levels.length === 0) { return; } // check if level idx is valid if (newLevel < 0 || newLevel >= levels.length) { // invalid level id given, trigger error const error = new Error('invalid level idx'); const fatal = newLevel < 0; this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.LEVEL_SWITCH_ERROR, level: newLevel, fatal, error, reason: error.message, }); if (fatal) { return; } newLevel = Math.min(newLevel, levels.length - 1); } const lastLevelIndex = this.currentLevelIndex; const lastLevel = this.currentLevel; const lastPathwayId = lastLevel ? lastLevel.attrs['PATHWAY-ID'] : undefined; const level = levels[newLevel]; const pathwayId = level.attrs['PATHWAY-ID']; this.currentLevelIndex = newLevel; this.currentLevel = level; if ( lastLevelIndex === newLevel && level.details && lastLevel && lastPathwayId === pathwayId ) { return; } this.log( `Switching to level ${newLevel} (${ level.height ? level.height + 'p ' : '' }${level.videoRange ? level.videoRange + ' ' : ''}${ level.codecSet ? level.codecSet + ' ' : '' }@${level.bitrate})${ pathwayId ? ' with Pathway ' + pathwayId : '' } from level ${lastLevelIndex}${ lastPathwayId ? ' with Pathway ' + lastPathwayId : '' }`, ); const levelSwitchingData: LevelSwitchingData = Object.assign({}, level, { level: newLevel, maxBitrate: level.maxBitrate, attrs: level.attrs, uri: level.uri, urlId: level.urlId, }); // @ts-ignore delete levelSwitchingData._attrs; // @ts-ignore delete levelSwitchingData._urlId; // @ts-ignore delete levelSwitchingData._avgBitrate; this.hls.trigger(Events.LEVEL_SWITCHING, levelSwitchingData); // check if we need to load playlist for this level const levelDetails = level.details; if (!levelDetails || levelDetails.live) { // level not retrieved yet, or live playlist we need to (re)load it const hlsUrlParameters = this.switchParams(level.uri, lastLevel?.details); this.loadPlaylist(hlsUrlParameters); } } get manualLevel(): number { return this.manualLevelIndex; } set manualLevel(newLevel) { this.manualLevelIndex = newLevel; if (this._startLevel === undefined) { this._startLevel = newLevel; } if (newLevel !== -1) { this.level = newLevel; } } get firstLevel(): number { return this._firstLevel; } set firstLevel(newLevel) { this._firstLevel = newLevel; } get startLevel(): number { // Setting hls.startLevel (this._startLevel) overrides config.startLevel if (this._startLevel === undefined) { const configStartLevel = this.hls.config.startLevel; if (configStartLevel !== undefined) { return configStartLevel; } return this.hls.firstAutoLevel; } return this._startLevel; } set startLevel(newLevel: number) { this._startLevel = newLevel; } protected onError(event: Events.ERROR, data: ErrorData) { if (data.fatal || !data.context) { return; } if ( data.context.type === PlaylistContextType.LEVEL && data.context.level === this.level ) { this.checkRetry(data); } } // reset errors on the successful load of a fragment protected onFragBuffered( event: Events.FRAG_BUFFERED, { frag }: FragBufferedData, ) { if (frag !== undefined && frag.type === PlaylistLevelType.MAIN) { const el = frag.elementaryStreams; if (!Object.keys(el).some((type) => !!el[type])) { return; } const level = this._levels[frag.level]; if (level?.loadError) { this.log( `Resetting level error count of ${level.loadError} on frag buffered`, ); level.loadError = 0; } } } protected onLevelLoaded(event: Events.LEVEL_LOADED, data: LevelLoadedData) { const { level, details } = data; const curLevel = this._levels[level]; if (!curLevel) { this.warn(`Invalid level index ${level}`); if (data.deliveryDirectives?.skip) { details.deltaUpdateFailed = true; } return; } // only process level loaded events matching with expected level if (level === this.currentLevelIndex) { // reset level load error counter on successful level loaded only if there is no issues with fragments if (curLevel.fragmentError === 0) { curLevel.loadError = 0; } this.playlistLoaded(level, data, curLevel.details); } else if (data.deliveryDirectives?.skip) { // received a delta playlist update that cannot be merged details.deltaUpdateFailed = true; } } protected onAudioTrackSwitched( event: Events.AUDIO_TRACK_SWITCHED, data: TrackSwitchedData, ) { const currentLevel = this.currentLevel; if (!currentLevel) { return; } const audioGroupId = this.hls.audioTracks[data.id].groupId; if ( currentLevel.audioGroupIds && currentLevel.audioGroupId !== audioGroupId ) { let urlId = -1; for (let i = 0; i < currentLevel.audioGroupIds.length; i++) { if (currentLevel.audioGroupIds[i] === audioGroupId) { urlId = i; break; } } if (urlId !== -1 && urlId !== currentLevel.urlId) { currentLevel.urlId = urlId; if (this.canLoad) { this.startLoad(); } } } } protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) { super.loadPlaylist(); const currentLevelIndex = this.currentLevelIndex; const currentLevel = this.currentLevel; if (currentLevel && this.shouldLoadPlaylist(currentLevel)) { const id = currentLevel.urlId; let url = currentLevel.uri; if (hlsUrlParameters) { try { url = hlsUrlParameters.addDirectives(url); } catch (error) { this.warn( `Could not construct new URL with HLS Delivery Directives: ${error}`, ); } } const pathwayId = currentLevel.attrs['PATHWAY-ID']; this.log( `Loading level index ${currentLevelIndex}${ hlsUrlParameters?.msn !== undefined ? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part : '' } with${pathwayId ? ' Pathway ' + pathwayId : ''} URI ${id + 1}/${ currentLevel.url.length } ${url}`, ); // console.log('Current audio track group ID:', this.hls.audioTracks[this.hls.audioTrack].groupId); // console.log('New video quality level audio group id:', levelObject.attrs.AUDIO, level); this.clearTimer(); this.hls.trigger(Events.LEVEL_LOADING, { url, level: currentLevelIndex, id, deliveryDirectives: hlsUrlParameters || null, }); } } get nextLoadLevel() { if (this.manualLevelIndex !== -1) { return this.manualLevelIndex; } else { return this.hls.nextAutoLevel; } } set nextLoadLevel(nextLevel) { this.level = nextLevel; if (this.manualLevelIndex === -1) { this.hls.nextAutoLevel = nextLevel; } } removeLevel(levelIndex, urlId) { const filterLevelAndGroupByIdIndex = (url, id) => id !== urlId; const levels = this._levels.filter((level, index) => { if (index !== levelIndex) { return true; } if (level.url.length > 1 && urlId !== undefined) { level.url = level.url.filter(filterLevelAndGroupByIdIndex); if (level.audioGroupIds) { level.audioGroupIds = level.audioGroupIds.filter( filterLevelAndGroupByIdIndex, ); } if (level.textGroupIds) { level.textGroupIds = level.textGroupIds.filter( filterLevelAndGroupByIdIndex, ); } level.urlId = 0; return true; } if (this.steering) { this.steering.removeLevel(level); } if (level === this.currentLevel) { this.currentLevel = null; this.currentLevelIndex = -1; if (level.details) { level.details.fragments.forEach((f) => (f.level = -1)); } } return false; }); reassignFragmentLevelIndexes(levels); this._levels = levels; if (this.currentLevelIndex > -1 && this.currentLevel?.details) { this.currentLevelIndex = this.currentLevel.details.fragments[0].level; } this.hls.trigger(Events.LEVELS_UPDATED, { levels }); } private onLevelsUpdated( event: Events.LEVELS_UPDATED, { levels }: LevelsUpdatedData, ) { this._levels = levels; } public checkMaxAutoUpdated() { const { autoLevelCapping, maxAutoLevel, maxHdcpLevel } = this.hls; if (this._maxAutoLevel !== maxAutoLevel) { this._maxAutoLevel = maxAutoLevel; this.hls.trigger(Events.MAX_AUTO_LEVEL_UPDATED, { autoLevelCapping, levels: this.levels, maxAutoLevel, minAutoLevel: this.hls.minAutoLevel, maxHdcpLevel, }); } } } function assignTrackIdsByGroup(tracks: MediaPlaylist[]): void { const groups = {}; tracks.forEach((track) => { const groupId = track.groupId || ''; track.id = groups[groupId] = groups[groupId] || 0; groups[groupId]++; }); }