download
raw
23.7 kB
import BasePlaylistController from './base-playlist-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { isVideoRange, Level, VideoRangeValues } from '../types/level';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import {
areCodecsMediaSourceSupported,
codecsSetSelectionPreferenceValue,
convertAVC1ToAVCOTI,
getCodecCompatibleName,
videoCodecPreferenceValue,
} from '../utils/codecs';
import { reassignFragmentLevelIndexes } from '../utils/level-helper';
import { getUnsupportedResult } from '../utils/mediacapabilities-helper';
import { stringify } from '../utils/safe-json-stringify';
import type ContentSteeringController from './content-steering-controller';
import type Hls from '../hls';
import type {
ErrorData,
FragBufferedData,
LevelLoadedData,
LevelsUpdatedData,
LevelSwitchingData,
ManifestLoadedData,
ManifestLoadingData,
ManifestParsedData,
} from '../types/events';
import type { HlsUrlParameters, LevelParsed } from '../types/level';
import type { MediaPlaylist } from '../types/media-playlist';
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.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.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 preferManagedMediaSource = this.hls.config.preferManagedMediaSource;
const levels: Level[] = [];
const redundantSet: { [key: string]: Level } = {};
const generatePathwaySet: { [key: string]: number } = {};
let resolutionFound = false;
let videoCodecFound = false;
let audioCodecFound = false;
data.levels.forEach((levelParsed: LevelParsed) => {
const attributes = levelParsed.attrs;
let { audioCodec, videoCodec } = levelParsed;
if (audioCodec) {
// Returns empty and set to undefined for 'mp4a.40.34' with fallback to 'audio/mpeg' SourceBuffer
levelParsed.audioCodec = audioCodec =
getCodecCompatibleName(audioCodec, preferManagedMediaSource) ||
undefined;
}
if (videoCodec) {
videoCodec = levelParsed.videoCodec = convertAVC1ToAVCOTI(videoCodec);
}
// only keep levels with supported audio/video codecs
const { width, height, unknownCodecs } = levelParsed;
const unknownUnsupportedCodecCount = unknownCodecs?.length || 0;
resolutionFound ||= !!(width && height);
videoCodecFound ||= !!videoCodec;
audioCodecFound ||= !!audioCodec;
if (
unknownUnsupportedCodecCount ||
(audioCodec && !this.isAudioSupported(audioCodec)) ||
(videoCodec && !this.isVideoSupported(videoCodec))
) {
this.log(`Some or all CODECS not supported "${attributes.CODECS}"`);
return;
}
const {
CODECS,
'FRAME-RATE': FRAMERATE,
'HDCP-LEVEL': HDCP,
'PATHWAY-ID': PATHWAY,
RESOLUTION,
'VIDEO-RANGE': VIDEO_RANGE,
} = attributes;
const contentSteeringPrefix = `${PATHWAY || '.'}-`;
const levelKey = `${contentSteeringPrefix}${levelParsed.bitrate}-${RESOLUTION}-${FRAMERATE}-${CODECS}-${VIDEO_RANGE}-${HDCP}`;
if (!redundantSet[levelKey]) {
const level = this.createLevel(levelParsed);
redundantSet[levelKey] = level;
generatePathwaySet[levelKey] = 1;
levels.push(level);
} else if (
redundantSet[levelKey].uri !== levelParsed.url &&
!levelParsed.attrs['PATHWAY-ID']
) {
// Assign Pathway IDs to Redundant Streams (default Pathways is ".". Redundant Streams "..", "...", and so on.)
// Content Steering controller to handles Pathway fallback on error
const pathwayCount = (generatePathwaySet[levelKey] += 1);
levelParsed.attrs['PATHWAY-ID'] = new Array(pathwayCount + 1).join('.');
const level = this.createLevel(levelParsed);
redundantSet[levelKey] = level;
levels.push(level);
} else {
redundantSet[levelKey].addGroupId('audio', attributes.AUDIO);
redundantSet[levelKey].addGroupId('text', attributes.SUBTITLES);
}
});
this.filterAndSortMediaOptions(
levels,
data,
resolutionFound,
videoCodecFound,
audioCodecFound,
);
}
private createLevel(levelParsed: LevelParsed): Level {
const level = new Level(levelParsed);
const supplemental = levelParsed.supplemental;
if (
supplemental?.videoCodec &&
!this.isVideoSupported(supplemental.videoCodec)
) {
const error = new Error(
`SUPPLEMENTAL-CODECS not supported "${supplemental.videoCodec}"`,
);
this.log(error.message);
level.supportedResult = getUnsupportedResult(error, []);
}
return level;
}
private isAudioSupported(codec: string): boolean {
return areCodecsMediaSourceSupported(
codec,
'audio',
this.hls.config.preferManagedMediaSource,
);
}
private isVideoSupported(codec: string): boolean {
return areCodecsMediaSourceSupported(
codec,
'video',
this.hls.config.preferManagedMediaSource,
);
}
private filterAndSortMediaOptions(
filteredLevels: Level[],
data: ManifestLoadedData,
resolutionFound: boolean,
videoCodecFound: boolean,
audioCodecFound: boolean,
) {
let audioTracks: MediaPlaylist[] = [];
let subtitleTracks: MediaPlaylist[] = [];
let levels = filteredLevels;
const statsParsing = data.stats?.parsing || {};
// 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
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.resolve().then(() => {
if (this.hls) {
let message = 'no level with compatible codecs found in manifest';
let reason = message;
if (data.levels.length) {
reason = `one or more CODECS in variant not supported: ${stringify(
data.levels
.map((level) => level.attrs.CODECS)
.filter(
(value, index, array) => array.indexOf(value) === index,
),
)}`;
this.warn(reason);
message += ` (${reason})`;
}
const error = new Error(message);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
fatal: true,
url: data.url,
error,
reason,
});
}
});
statsParsing.end = performance.now();
return;
}
if (data.audioTracks) {
audioTracks = data.audioTracks.filter(
(track) => !track.audioCodec || this.isAudioSupported(track.audioCodec),
);
// 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.videoRange !== b.videoRange) {
return (
VideoRangeValues.indexOf(a.videoRange) -
VideoRangeValues.indexOf(b.videoRange)
);
}
if (a.videoCodec !== b.videoCodec) {
const valueA = videoCodecPreferenceValue(a.videoCodec);
const valueB = videoCodecPreferenceValue(b.videoCodec);
if (valueA !== valueB) {
return valueB - valueA;
}
}
if (a.uri === b.uri && a.codecSet !== b.codecSet) {
const valueA = codecsSetSelectionPreferenceValue(a.codecSet);
const valueB = codecsSetSelectionPreferenceValue(b.codecSet);
if (valueA !== valueB) {
return valueB - valueA;
}
}
if (a.averageBitrate !== b.averageBitrate) {
return a.averageBitrate - b.averageBitrate;
}
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 === this.hls.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 config = this.hls.config;
const altAudioEnabled = !!(
config.audioStreamController && config.audioTrackController
);
const edata: ManifestParsedData = {
levels,
audioTracks,
subtitleTracks,
sessionData: data.sessionData,
sessionKeys: data.sessionKeys,
firstLevel: this._firstLevel,
stats: data.stats,
audio: audioCodecFound,
video: videoCodecFound,
altAudio:
altAudioEnabled && !audioOnly && audioTracks.some((t) => !!t.url),
};
statsParsing.end = performance.now();
this.hls.trigger(Events.MANIFEST_PARSED, edata);
}
get levels(): Level[] | null {
if (this._levels.length === 0) {
return null;
}
return this._levels;
}
get loadLevelObj(): Level | null {
return this.currentLevel;
}
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 &&
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 = {
level: newLevel,
attrs: level.attrs,
details: level.details,
bitrate: level.bitrate,
averageBitrate: level.averageBitrate,
maxBitrate: level.maxBitrate,
realBitrate: level.realBitrate,
width: level.width,
height: level.height,
codecSet: level.codecSet,
audioCodec: level.audioCodec,
videoCodec: level.videoCodec,
audioGroups: level.audioGroups,
subtitleGroups: level.subtitleGroups,
loaded: level.loaded,
loadError: level.loadError,
fragmentError: level.fragmentError,
name: level.name,
id: level.id,
uri: level.uri,
url: level.url,
urlId: 0,
audioGroupIds: level.audioGroupIds,
textGroupIds: level.textGroupIds,
};
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,
levelDetails,
);
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;
}
get pathways(): string[] {
if (this.steering) {
return this.steering.pathways();
}
return [];
}
get pathwayPriority(): string[] | null {
if (this.steering) {
return this.steering.pathwayPriority;
}
return null;
}
set pathwayPriority(pathwayPriority: string[]) {
if (this.steering) {
const pathwaysList = this.steering.pathways();
const filteredPathwayPriority = pathwayPriority.filter((pathwayId) => {
return pathwaysList.indexOf(pathwayId) !== -1;
});
if (pathwayPriority.length < 1) {
this.warn(
`pathwayPriority ${pathwayPriority} should contain at least one pathway from list: ${pathwaysList}`,
);
return;
}
this.steering.pathwayPriority = filteredPathwayPriority;
}
}
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 = data.levelInfo;
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 or prior to switch when media playlist is loaded directly
if (curLevel === this.currentLevel || data.withoutMultiVariant) {
// reset level load error counter on successful level loaded only if there is no issues with fragments
if (curLevel.fragmentError === 0) {
curLevel.loadError = 0;
}
// Ignore matching details populated by loading a Media Playlist directly
let previousDetails = curLevel.details;
if (previousDetails === data.details && previousDetails.advanced) {
previousDetails = undefined;
}
this.playlistLoaded(level, data, previousDetails);
} else if (data.deliveryDirectives?.skip) {
// received a delta playlist update that cannot be merged
details.deltaUpdateFailed = true;
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) {
super.loadPlaylist();
if (this.shouldLoadPlaylist(this.currentLevel)) {
this.scheduleLoading(this.currentLevel, hlsUrlParameters);
}
}
protected loadingPlaylist(
currentLevel: Level,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(currentLevel, hlsUrlParameters);
const url = this.getUrlWithDirectives(currentLevel.uri, hlsUrlParameters);
const currentLevelIndex = this.currentLevelIndex;
const pathwayId = currentLevel.attrs['PATHWAY-ID'];
const details = currentLevel.details;
const age = details?.age;
this.log(
`Loading level index ${currentLevelIndex}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${pathwayId ? ' Pathway ' + pathwayId : ''}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.LEVEL_LOADING, {
url,
level: currentLevelIndex,
levelInfo: currentLevel,
pathwayId: currentLevel.attrs['PATHWAY-ID'],
id: 0, // Deprecated Level urlId
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: number) {
if (this._levels.length === 1) {
return;
}
const levels = this._levels.filter((level, index) => {
if (index !== levelIndex) {
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;
}
if (this.manualLevelIndex > -1) {
this.manualLevelIndex = this.currentLevelIndex;
}
const maxLevel = levels.length - 1;
this._firstLevel = Math.min(this._firstLevel, maxLevel);
if (this._startLevel) {
this._startLevel = Math.min(this._startLevel, maxLevel);
}
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]++;
});
}

Xet Storage Details

Size:
23.7 kB
·
Xet hash:
ac5d3bea8a15e081b18c3fda61751ece38b1ba0d38fc2d952ff92dc8a39fed60

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.