hls.js
Version:
JavaScript HLS client using MediaSourceExtension
1,525 lines (1,432 loc) • 75.6 kB
text/typescript
import { ErrorActionFlags, NetworkErrorAction } from './error-controller';
import {
findFragmentByPDT,
findFragmentByPTS,
findNearestWithCC,
} from './fragment-finders';
import { FragmentState } from './fragment-tracker';
import Decrypter from '../crypt/decrypter';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
type Fragment,
isMediaFragment,
type MediaFragment,
type Part,
} from '../loader/fragment';
import FragmentLoader from '../loader/fragment-loader';
import TaskLoop from '../task-loop';
import { PlaylistLevelType } from '../types/loader';
import { ChunkMetadata } from '../types/transmuxer';
import { BufferHelper } from '../utils/buffer-helper';
import { alignStream } from '../utils/discontinuities';
import {
getAesModeFromFullSegmentMethod,
isFullSegmentEncryption,
} from '../utils/encryption-methods-util';
import {
getRetryDelay,
isUnusableKeyError,
offlineHttpStatus,
} from '../utils/error-helper';
import {
addEventListener,
removeEventListener,
} from '../utils/event-listener-helper';
import {
findPart,
getFragmentWithSN,
getPartWith,
updateFragPTSDTS,
} from '../utils/level-helper';
import { appendUint8Array } from '../utils/mp4-tools';
import TimeRanges from '../utils/time-ranges';
import type { FragmentTracker } from './fragment-tracker';
import type { HlsConfig } from '../config';
import type TransmuxerInterface from '../demux/transmuxer-interface';
import type Hls from '../hls';
import type {
FragmentLoadProgressCallback,
LoadError,
} from '../loader/fragment-loader';
import type KeyLoader from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
import type { SourceBufferName } from '../types/buffer';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
BufferAppendingData,
BufferFlushingData,
ErrorData,
FragLoadedData,
KeyLoadedData,
ManifestLoadedData,
MediaAttachedData,
MediaDetachingData,
PartsLoadedData,
} from '../types/events';
import type { Level } from '../types/level';
import type { InitSegmentData, RemuxedTrack } from '../types/remuxer';
import type { Bufferable, BufferInfo } from '../utils/buffer-helper';
import type { TimestampOffset } from '../utils/timescale-conversion';
type ResolveFragLoaded = (FragLoadedEndData) => void;
type RejectFragLoaded = (LoadError) => void;
export const State = {
STOPPED: 'STOPPED',
IDLE: 'IDLE',
KEY_LOADING: 'KEY_LOADING',
FRAG_LOADING: 'FRAG_LOADING',
FRAG_LOADING_WAITING_RETRY: 'FRAG_LOADING_WAITING_RETRY',
WAITING_TRACK: 'WAITING_TRACK',
PARSING: 'PARSING',
PARSED: 'PARSED',
ENDED: 'ENDED',
ERROR: 'ERROR',
WAITING_INIT_PTS: 'WAITING_INIT_PTS',
WAITING_LEVEL: 'WAITING_LEVEL',
};
export type InFlightData = {
frag: Fragment | null;
state: (typeof State)[keyof typeof State];
};
export default class BaseStreamController
extends TaskLoop
implements NetworkComponentAPI
{
protected hls: Hls;
protected fragPrevious: MediaFragment | null = null;
protected fragCurrent: Fragment | null = null;
protected fragmentTracker: FragmentTracker;
protected transmuxer: TransmuxerInterface | null = null;
protected _state: (typeof State)[keyof typeof State] = State.STOPPED;
protected playlistType: PlaylistLevelType;
protected media: HTMLMediaElement | null = null;
protected mediaBuffer: Bufferable | null = null;
protected config: HlsConfig;
protected bitrateTest: boolean = false;
protected lastCurrentTime: number = 0;
protected nextLoadPosition: number = 0;
protected startPosition: number = 0;
protected startTimeOffset: number | null = null;
protected retryDate: number = 0;
protected levels: Array<Level> | null = null;
protected fragmentLoader: FragmentLoader;
protected keyLoader: KeyLoader;
protected levelLastLoaded: Level | null = null;
protected startFragRequested: boolean = false;
protected decrypter: Decrypter;
protected initPTS: TimestampOffset[] = [];
protected buffering: boolean = true;
protected loadingParts: boolean = false;
private loopSn?: string | number;
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader,
logPrefix: string,
playlistType: PlaylistLevelType,
) {
super(logPrefix, hls.logger);
this.playlistType = playlistType;
this.hls = hls;
this.fragmentLoader = new FragmentLoader(hls.config);
this.keyLoader = keyLoader;
this.fragmentTracker = fragmentTracker;
this.config = hls.config;
this.decrypter = new Decrypter(hls.config);
}
protected registerListeners() {
const { hls } = this;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.ERROR, this.onError, this);
}
protected unregisterListeners() {
const { hls } = this;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.ERROR, this.onError, this);
}
protected doTick() {
this.onTickEnd();
}
protected onTickEnd() {}
public startLoad(startPosition: number): void {}
public stopLoad() {
if (this.state === State.STOPPED) {
return;
}
this.fragmentLoader.abort();
this.keyLoader.abort(this.playlistType);
const frag = this.fragCurrent;
if (frag?.loader) {
frag.abortRequests();
this.fragmentTracker.removeFragment(frag);
}
this.resetTransmuxer();
this.fragCurrent = null;
this.fragPrevious = null;
this.clearInterval();
this.clearNextTick();
this.state = State.STOPPED;
}
public get startPositionValue(): number {
const { nextLoadPosition, startPosition } = this;
if (startPosition === -1 && nextLoadPosition) {
return nextLoadPosition;
}
return startPosition;
}
public get bufferingEnabled(): boolean {
return this.buffering;
}
public pauseBuffering() {
this.buffering = false;
}
public resumeBuffering() {
this.buffering = true;
}
public get inFlightFrag(): InFlightData {
return { frag: this.fragCurrent, state: this.state };
}
protected _streamEnded(
bufferInfo: BufferInfo,
levelDetails: LevelDetails,
): boolean {
// Stream is never "ended" when playlist is live or media is detached
if (levelDetails.live || !this.media) {
return false;
}
// Stream is not "ended" when nothing is buffered past the start
const bufferEnd = bufferInfo.end || 0;
const timelineStart = this.config.timelineOffset || 0;
if (bufferEnd <= timelineStart) {
return false;
}
// Stream is not "ended" when there is a second buffered range starting before the end of the playlist
const bufferedRanges = bufferInfo.buffered;
if (
this.config.maxBufferHole &&
bufferedRanges &&
bufferedRanges.length > 1
) {
// make sure bufferInfo accounts for any gaps
bufferInfo = BufferHelper.bufferedInfo(
bufferedRanges,
bufferInfo.start,
0,
);
}
const nextStart = bufferInfo.nextStart;
const hasSecondBufferedRange =
nextStart && nextStart > timelineStart && nextStart < levelDetails.edge;
if (hasSecondBufferedRange) {
return false;
}
// Playhead is in unbuffered region. Marking EoS now could result in Safari failing to dispatch "ended" event following seek on start.
if (this.media.currentTime < bufferInfo.start) {
return false;
}
const partList = levelDetails.partList;
// Since the last part isn't guaranteed to correspond to the last playlist segment for Low-Latency HLS,
// check instead if the last part is buffered.
if (partList?.length) {
const lastPart = partList[partList.length - 1];
// Checking the midpoint of the part for potential margin of error and related issues.
// NOTE: Technically I believe parts could yield content that is < the computed duration (including potential a duration of 0)
// and still be spec-compliant, so there may still be edge cases here. Likewise, there could be issues in end of stream
// part mismatches for independent audio and video playlists/segments.
const lastPartBuffered = BufferHelper.isBuffered(
this.media,
lastPart.start + lastPart.duration / 2,
);
return lastPartBuffered;
}
const playlistType =
levelDetails.fragments[levelDetails.fragments.length - 1].type;
return this.fragmentTracker.isEndListAppended(playlistType);
}
public getLevelDetails(): LevelDetails | undefined {
if (this.levels && this.levelLastLoaded !== null) {
return this.levelLastLoaded.details;
}
}
protected get timelineOffset(): number {
const configuredTimelineOffset = this.config.timelineOffset;
if (configuredTimelineOffset) {
return (
this.getLevelDetails()?.appliedTimelineOffset ||
configuredTimelineOffset
);
}
return 0;
}
protected onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
) {
const media = (this.media = this.mediaBuffer = data.media);
addEventListener(media, 'seeking', this.onMediaSeeking);
addEventListener(media, 'ended', this.onMediaEnded);
const config = this.config;
if (this.levels && config.autoStartLoad && this.state === State.STOPPED) {
this.startLoad(config.startPosition);
}
}
protected onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
const transferringMedia = !!data.transferMedia;
const media = this.media;
if (media === null) {
return;
}
if (media.ended) {
this.log('MSE detaching and video ended, reset startPosition');
this.startPosition = this.lastCurrentTime = 0;
}
// remove video listeners
removeEventListener(media, 'seeking', this.onMediaSeeking);
removeEventListener(media, 'ended', this.onMediaEnded);
if (this.keyLoader && !transferringMedia) {
this.keyLoader.detach();
}
this.media = this.mediaBuffer = null;
this.loopSn = undefined;
if (transferringMedia) {
this.resetLoadingState();
this.resetTransmuxer();
return;
}
this.loadingParts = false;
this.fragmentTracker.removeAllFragments();
this.stopLoad();
}
protected onManifestLoading() {
this.initPTS = [];
this.levels = this.levelLastLoaded = this.fragCurrent = null;
this.lastCurrentTime = this.startPosition = 0;
this.startFragRequested = false;
}
protected onError(event: Events.ERROR, data: ErrorData) {}
protected onMediaSeeking = () => {
const { config, fragCurrent, media, mediaBuffer, state } = this;
const currentTime: number = media ? media.currentTime : 0;
const bufferInfo = BufferHelper.bufferInfo(
mediaBuffer ? mediaBuffer : media,
currentTime,
config.maxBufferHole,
);
const noFowardBuffer = !bufferInfo.len;
this.log(
`Media seeking to ${
Number.isFinite(currentTime) ? currentTime.toFixed(3) : currentTime
}, state: ${state}, ${noFowardBuffer ? 'out of' : 'in'} buffer`,
);
if (this.state === State.ENDED) {
this.resetLoadingState();
} else if (fragCurrent) {
// Seeking while frag load is in progress
const tolerance = config.maxFragLookUpTolerance;
const fragStartOffset = fragCurrent.start - tolerance;
const fragEndOffset =
fragCurrent.start + fragCurrent.duration + tolerance;
// if seeking out of buffered range or into new one
if (
noFowardBuffer ||
fragEndOffset < bufferInfo.start ||
fragStartOffset > bufferInfo.end
) {
const pastFragment = currentTime > fragEndOffset;
// if the seek position is outside the current fragment range
if (currentTime < fragStartOffset || pastFragment) {
if (pastFragment && fragCurrent.loader) {
this.log(
`Cancelling fragment load for seek (sn: ${fragCurrent.sn})`,
);
fragCurrent.abortRequests();
this.resetLoadingState();
}
this.fragPrevious = null;
}
}
}
if (media) {
// Remove gap fragments
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
// Don't set lastCurrentTime with backward seeks (allows for frag selection with strict tolerances)
const lastCurrentTime = this.lastCurrentTime;
if (currentTime > lastCurrentTime) {
this.lastCurrentTime = currentTime;
}
if (!this.loadingParts) {
const bufferEnd = Math.max(bufferInfo.end, currentTime);
const shouldLoadParts = this.shouldLoadParts(
this.getLevelDetails(),
bufferEnd,
);
if (shouldLoadParts) {
this.log(
`LL-Part loading ON after seeking to ${currentTime.toFixed(
2,
)} with buffer @${bufferEnd.toFixed(2)}`,
);
this.loadingParts = shouldLoadParts;
}
}
}
// in case seeking occurs although no media buffered, adjust startPosition and nextLoadPosition to seek target
if (!this.hls.hasEnoughToStart) {
this.log(
`Setting ${noFowardBuffer ? 'startPosition' : 'nextLoadPosition'} to ${currentTime} for seek without enough to start`,
);
this.nextLoadPosition = currentTime;
if (noFowardBuffer) {
this.startPosition = currentTime;
}
}
if (noFowardBuffer && this.state === State.IDLE) {
// Async tick to speed up processing
this.tickImmediate();
}
};
protected onMediaEnded = () => {
// reset startPosition and lastCurrentTime to restart playback @ stream beginning
this.log(`setting startPosition to 0 because media ended`);
this.startPosition = this.lastCurrentTime = 0;
};
protected onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
): void {
this.startTimeOffset = data.startTimeOffset;
}
protected onHandlerDestroying() {
this.stopLoad();
if (this.transmuxer) {
this.transmuxer.destroy();
this.transmuxer = null;
}
super.onHandlerDestroying();
// @ts-ignore
this.hls = this.onMediaSeeking = this.onMediaEnded = null;
}
protected onHandlerDestroyed() {
this.state = State.STOPPED;
if (this.fragmentLoader) {
this.fragmentLoader.destroy();
}
if (this.keyLoader) {
this.keyLoader.destroy();
}
if (this.decrypter) {
this.decrypter.destroy();
}
this.hls =
this.log =
this.warn =
this.decrypter =
this.keyLoader =
this.fragmentLoader =
this.fragmentTracker =
null as any;
super.onHandlerDestroyed();
}
protected loadFragment(
frag: MediaFragment,
level: Level,
targetBufferTime: number,
) {
this.startFragRequested = true;
this._loadFragForPlayback(frag, level, targetBufferTime);
}
private _loadFragForPlayback(
fragment: MediaFragment,
level: Level,
targetBufferTime: number,
) {
const progressCallback: FragmentLoadProgressCallback = (
data: FragLoadedData,
) => {
const frag = data.frag;
if (this.fragContextChanged(frag)) {
this.warn(
`${frag.type} sn: ${frag.sn}${
data.part ? ' part: ' + data.part.index : ''
} of ${this.fragInfo(frag, false, data.part)}) was dropped during download.`,
);
this.fragmentTracker.removeFragment(frag);
return;
}
frag.stats.chunkCount++;
this._handleFragmentLoadProgress(data);
};
this._doFragLoad(fragment, level, targetBufferTime, progressCallback)
.then((data) => {
if (!data) {
// if we're here we probably needed to backtrack or are waiting for more parts
return;
}
const state = this.state;
const frag = data.frag;
if (this.fragContextChanged(frag)) {
if (
state === State.FRAG_LOADING ||
(!this.fragCurrent && state === State.PARSING)
) {
this.fragmentTracker.removeFragment(frag);
this.state = State.IDLE;
}
return;
}
if ('payload' in data) {
this.log(
`Loaded ${frag.type} sn: ${frag.sn} of ${this.playlistLabel()} ${frag.level}`,
);
this.hls.trigger(Events.FRAG_LOADED, data);
}
// Pass through the whole payload; controllers not implementing progressive loading receive data from this callback
this._handleFragmentLoadComplete(data);
})
.catch((reason) => {
if (this.state === State.STOPPED || this.state === State.ERROR) {
return;
}
this.warn(`Frag error: ${reason?.message || reason}`);
this.resetFragmentLoading(fragment);
});
}
protected clearTrackerIfNeeded(frag: Fragment) {
const { fragmentTracker } = this;
const fragState = fragmentTracker.getState(frag);
if (fragState === FragmentState.APPENDING) {
// Lower the max buffer length and try again
const playlistType = frag.type as PlaylistLevelType;
const bufferedInfo = this.getFwdBufferInfo(
this.mediaBuffer,
playlistType,
);
const minForwardBufferLength = Math.max(
frag.duration,
bufferedInfo ? bufferedInfo.len : this.config.maxBufferLength,
);
// If backtracking, always remove from the tracker without reducing max buffer length
const backtrackFragment = (this as any).backtrackFragment as
| Fragment
| undefined;
const backtracked = backtrackFragment
? (frag.sn as number) - (backtrackFragment.sn as number)
: 0;
if (
backtracked === 1 ||
this.reduceMaxBufferLength(minForwardBufferLength, frag.duration)
) {
fragmentTracker.removeFragment(frag);
}
} else if (this.mediaBuffer?.buffered.length === 0) {
// Stop gap for bad tracker / buffer flush behavior
fragmentTracker.removeAllFragments();
} else if (fragmentTracker.hasParts(frag.type)) {
// In low latency mode, remove fragments for which only some parts were buffered
fragmentTracker.detectPartialFragments({
frag,
part: null,
stats: frag.stats,
id: frag.type,
});
if (fragmentTracker.getState(frag) === FragmentState.PARTIAL) {
fragmentTracker.removeFragment(frag);
}
}
}
protected checkLiveUpdate(details: LevelDetails) {
if (details.updated && !details.live) {
// Live stream ended, update fragment tracker
const lastFragment = details.fragments[details.fragments.length - 1];
this.fragmentTracker.detectPartialFragments({
frag: lastFragment,
part: null,
stats: lastFragment.stats,
id: lastFragment.type,
});
}
if (!details.fragments[0]) {
details.deltaUpdateFailed = true;
}
}
protected waitForLive(levelInfo: Level) {
const details = levelInfo.details;
return (
details?.live &&
details.type !== 'EVENT' &&
(this.levelLastLoaded !== levelInfo || details.expired)
);
}
protected flushMainBuffer(
startOffset: number,
endOffset: number,
type: SourceBufferName | null = null,
) {
if (!(startOffset - endOffset)) {
return;
}
// When alternate audio is playing, the audio-stream-controller is responsible for the audio buffer. Otherwise,
// passing a null type flushes both buffers
const flushScope: BufferFlushingData = { startOffset, endOffset, type };
this.hls.trigger(Events.BUFFER_FLUSHING, flushScope);
}
protected _loadInitSegment(fragment: Fragment, level: Level) {
this._doFragLoad(fragment, level)
.then((data) => {
const frag = data?.frag;
if (!frag || this.fragContextChanged(frag) || !this.levels) {
throw new Error('init load aborted');
}
return data;
})
.then((data: FragLoadedData) => {
const { hls } = this;
const { frag, payload } = data;
const decryptData = frag.decryptdata;
// check to see if the payload needs to be decrypted
if (
payload &&
payload.byteLength > 0 &&
decryptData?.key &&
decryptData.iv &&
isFullSegmentEncryption(decryptData.method)
) {
const startTime = self.performance.now();
// decrypt init segment data
return this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.catch((err) => {
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_DECRYPT_ERROR,
fatal: false,
error: err,
reason: err.message,
frag,
});
throw err;
})
.then((decryptedData) => {
const endTime = self.performance.now();
hls.trigger(Events.FRAG_DECRYPTED, {
frag,
payload: decryptedData,
stats: {
tstart: startTime,
tdecrypt: endTime,
},
});
data.payload = decryptedData;
return this.completeInitSegmentLoad(data);
});
}
return this.completeInitSegmentLoad(data);
})
.catch((reason) => {
if (this.state === State.STOPPED || this.state === State.ERROR) {
return;
}
this.warn(reason);
this.resetFragmentLoading(fragment);
});
}
private completeInitSegmentLoad(data: FragLoadedData) {
const { levels } = this;
if (!levels) {
throw new Error('init load aborted, missing levels');
}
const stats = data.frag.stats;
if (this.state !== State.STOPPED) {
this.state = State.IDLE;
}
data.frag.data = new Uint8Array(data.payload);
stats.parsing.start = stats.buffering.start = self.performance.now();
stats.parsing.end = stats.buffering.end = self.performance.now();
this.tick();
}
protected unhandledEncryptionError(
initSegment: InitSegmentData,
frag: Fragment,
): boolean {
const tracks = initSegment.tracks;
if (
tracks &&
!frag.encrypted &&
(tracks.audio?.encrypted || tracks.video?.encrypted) &&
(!this.config.emeEnabled || !this.keyLoader.emeController)
) {
const media = this.media;
const error = new Error(
__USE_EME_DRM__
? `Encrypted track with no key in ${this.fragInfo(frag)} (media ${media ? 'attached mediaKeys: ' + media.mediaKeys : 'detached'})`
: 'EME not supported (light build)',
);
this.warn(error.message);
// Ignore if media is detached or mediaKeys are set
if (!media || media.mediaKeys) {
return false;
}
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.KEY_SYSTEM_ERROR,
details: ErrorDetails.KEY_SYSTEM_NO_KEYS,
fatal: !__USE_EME_DRM__,
error,
frag,
});
this.resetTransmuxer();
return true;
}
return false;
}
protected fragContextChanged(frag: Fragment | null) {
const { fragCurrent } = this;
return (
!frag ||
!fragCurrent ||
frag.sn !== fragCurrent.sn ||
frag.level !== fragCurrent.level
);
}
protected fragBufferedComplete(frag: Fragment, part: Part | null) {
const media = this.mediaBuffer ? this.mediaBuffer : this.media;
this.log(
`Buffered ${frag.type} sn: ${frag.sn}${
part ? ' part: ' + part.index : ''
} of ${this.fragInfo(frag, false, part)} > buffer:${
media
? TimeRanges.toString(BufferHelper.getBuffered(media))
: '(detached)'
})`,
);
if (isMediaFragment(frag)) {
if (frag.type !== PlaylistLevelType.SUBTITLE) {
const el = frag.elementaryStreams;
if (!Object.keys(el).some((type) => !!el[type])) {
// empty segment
this.state = State.IDLE;
return;
}
}
const level = this.levels?.[frag.level];
if (level?.fragmentError) {
this.log(
`Resetting level fragment error count of ${level.fragmentError} on frag buffered`,
);
level.fragmentError = 0;
}
}
this.state = State.IDLE;
}
protected _handleFragmentLoadComplete(fragLoadedEndData: PartsLoadedData) {
const { transmuxer } = this;
if (!transmuxer) {
return;
}
const { frag, part, partsLoaded } = fragLoadedEndData;
// If we did not load parts, or loaded all parts, we have complete (not partial) fragment data
const complete =
!partsLoaded ||
partsLoaded.length === 0 ||
partsLoaded.some((fragLoaded) => !fragLoaded);
const chunkMeta = new ChunkMetadata(
frag.level,
frag.sn as number,
frag.stats.chunkCount + 1,
0,
part ? part.index : -1,
!complete,
);
transmuxer.flush(chunkMeta);
}
protected _handleFragmentLoadProgress(
frag: PartsLoadedData | FragLoadedData,
) {}
protected _doFragLoad(
frag: Fragment,
level: Level,
targetBufferTime: number | null = null,
progressCallback?: FragmentLoadProgressCallback,
): Promise<PartsLoadedData | FragLoadedData | null> {
this.fragCurrent = frag;
const details = level.details;
if (!this.levels || !details) {
throw new Error(
`frag load aborted, missing level${details ? '' : ' detail'}s`,
);
}
let keyLoadingPromise: Promise<KeyLoadedData | void> | null = null;
if (frag.encrypted && !frag.decryptdata?.key) {
this.log(
`Loading key for ${frag.sn} of [${details.startSN}-${details.endSN}], ${this.playlistLabel()} ${frag.level}`,
);
this.state = State.KEY_LOADING;
this.fragCurrent = frag;
keyLoadingPromise = this.keyLoader.load(frag).then((keyLoadedData) => {
if (!this.fragContextChanged(keyLoadedData.frag)) {
this.hls.trigger(Events.KEY_LOADED, keyLoadedData);
if (this.state === State.KEY_LOADING) {
this.state = State.IDLE;
}
return keyLoadedData;
}
});
this.hls.trigger(Events.KEY_LOADING, { frag });
if ((this.fragCurrent as Fragment | null) === null) {
this.log(`context changed in KEY_LOADING`);
return Promise.resolve(null);
}
} else if (!frag.encrypted) {
keyLoadingPromise = this.keyLoader.loadClear(
frag,
details.encryptedFragments,
this.startFragRequested,
);
if (keyLoadingPromise) {
this.log(`[eme] blocking frag load until media-keys acquired`);
}
}
const fragPrevious = this.fragPrevious;
if (
isMediaFragment(frag) &&
(!fragPrevious || frag.sn !== fragPrevious.sn)
) {
const shouldLoadParts = this.shouldLoadParts(level.details, frag.end);
if (shouldLoadParts !== this.loadingParts) {
this.log(
`LL-Part loading ${
shouldLoadParts ? 'ON' : 'OFF'
} loading sn ${fragPrevious?.sn}->${frag.sn}`,
);
this.loadingParts = shouldLoadParts;
}
}
targetBufferTime = Math.max(frag.start, targetBufferTime || 0);
if (this.loadingParts && isMediaFragment(frag)) {
const partList = details.partList;
if (partList && progressCallback) {
if (targetBufferTime > details.fragmentEnd && details.fragmentHint) {
frag = details.fragmentHint;
}
const partIndex = this.getNextPart(partList, frag, targetBufferTime);
if (partIndex > -1) {
const part = partList[partIndex];
frag = this.fragCurrent = part.fragment;
this.log(
`Loading ${frag.type} sn: ${frag.sn} part: ${part.index} (${partIndex}/${partList.length - 1}) of ${this.fragInfo(frag, false, part)}) cc: ${
frag.cc
} [${details.startSN}-${details.endSN}], target: ${parseFloat(
targetBufferTime.toFixed(3),
)}`,
);
this.nextLoadPosition = part.start + part.duration;
this.state = State.FRAG_LOADING;
let result: Promise<PartsLoadedData | FragLoadedData | null>;
if (keyLoadingPromise) {
result = keyLoadingPromise
.then((keyLoadedData) => {
if (
!keyLoadedData ||
this.fragContextChanged(keyLoadedData.frag)
) {
return null;
}
return this.doFragPartsLoad(
frag,
part,
level,
progressCallback,
);
})
.catch((error) => this.handleFragLoadError(error));
} else {
result = this.doFragPartsLoad(
frag,
part,
level,
progressCallback,
).catch((error: LoadError) => this.handleFragLoadError(error));
}
this.hls.trigger(Events.FRAG_LOADING, {
frag,
part,
targetBufferTime,
});
if (this.fragCurrent === null) {
return Promise.reject(
new Error(
`frag load aborted, context changed in FRAG_LOADING parts`,
),
);
}
return result;
} else if (
!frag.url ||
this.loadedEndOfParts(partList, targetBufferTime)
) {
// Fragment hint has no parts
return Promise.resolve(null);
}
}
}
if (isMediaFragment(frag) && this.loadingParts) {
this.log(
`LL-Part loading OFF after next part miss @${targetBufferTime.toFixed(
2,
)} Check buffer at sn: ${frag.sn} loaded parts: ${details.partList?.filter((p) => p.loaded).map((p) => `[${p.start}-${p.end}]`)}`,
);
this.loadingParts = false;
} else if (!frag.url) {
// Selected fragment hint for part but not loading parts
return Promise.resolve(null);
}
this.log(
`Loading ${frag.type} sn: ${frag.sn} of ${this.fragInfo(frag, false)}) cc: ${frag.cc} ${
'[' + details.startSN + '-' + details.endSN + ']'
}, target: ${parseFloat(targetBufferTime.toFixed(3))}`,
);
// Don't update nextLoadPosition for fragments which are not buffered
if (Number.isFinite(frag.sn as number) && !this.bitrateTest) {
this.nextLoadPosition = frag.start + frag.duration;
}
this.state = State.FRAG_LOADING;
// Load key before streaming fragment data
const dataOnProgress =
this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE;
let result: Promise<PartsLoadedData | FragLoadedData | null>;
if (dataOnProgress && keyLoadingPromise) {
result = keyLoadingPromise
.then((keyLoadedData) => {
if (!keyLoadedData || this.fragContextChanged(keyLoadedData.frag)) {
return null;
}
return this.fragmentLoader.load(frag, progressCallback);
})
.catch((error) => this.handleFragLoadError(error));
} else {
// load unencrypted fragment data with progress event,
// or handle fragment result after key and fragment are finished loading
result = Promise.all([
this.fragmentLoader.load(
frag,
dataOnProgress ? progressCallback : undefined,
),
keyLoadingPromise,
])
.then(([fragLoadedData]) => {
if (!dataOnProgress && progressCallback) {
progressCallback(fragLoadedData);
}
return fragLoadedData;
})
.catch((error) => this.handleFragLoadError(error));
}
this.hls.trigger(Events.FRAG_LOADING, { frag, targetBufferTime });
if (this.fragCurrent === null) {
return Promise.reject(
new Error(`frag load aborted, context changed in FRAG_LOADING`),
);
}
return result;
}
private doFragPartsLoad(
frag: Fragment,
fromPart: Part,
level: Level,
progressCallback: FragmentLoadProgressCallback,
): Promise<PartsLoadedData | null> {
return new Promise(
(resolve: ResolveFragLoaded, reject: RejectFragLoaded) => {
const partsLoaded: FragLoadedData[] = [];
const initialPartList = level.details?.partList;
const loadPart = (part: Part) => {
this.fragmentLoader
.loadPart(frag, part, progressCallback)
.then((partLoadedData: FragLoadedData) => {
partsLoaded[part.index] = partLoadedData;
const loadedPart = partLoadedData.part as Part;
this.hls.trigger(Events.FRAG_LOADED, partLoadedData);
const nextPart =
getPartWith(level.details, frag.sn as number, part.index + 1) ||
findPart(initialPartList, frag.sn as number, part.index + 1);
if (nextPart) {
loadPart(nextPart);
} else {
return resolve({
frag,
part: loadedPart,
partsLoaded,
});
}
})
.catch(reject);
};
loadPart(fromPart);
},
);
}
private handleFragLoadError(
error: LoadError | Error | (Error & { data: ErrorData }),
) {
if ('data' in error) {
const data = error.data;
if (data.frag && data.details === ErrorDetails.INTERNAL_ABORTED) {
this.handleFragLoadAborted(data.frag, data.part);
} else if (data.frag && data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
data.frag.abortRequests();
this.resetStartWhenNotLoaded();
this.resetFragmentLoading(data.frag);
} else {
this.hls.trigger(Events.ERROR, data as ErrorData);
}
} else {
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.INTERNAL_EXCEPTION,
err: error,
error,
fatal: true,
});
}
return null;
}
protected _handleTransmuxerFlush(chunkMeta: ChunkMetadata) {
const context = this.getCurrentContext(chunkMeta);
if (!context || this.state !== State.PARSING) {
if (
!this.fragCurrent &&
this.state !== State.STOPPED &&
this.state !== State.ERROR
) {
this.state = State.IDLE;
}
return;
}
const { frag, part, level } = context;
const now = self.performance.now();
frag.stats.parsing.end = now;
if (part) {
part.stats.parsing.end = now;
}
// See if part loading should be disabled/enabled based on buffer and playback position.
const levelDetails = this.getLevelDetails();
const loadingPartsAtEdge = levelDetails && frag.sn > levelDetails.endSN;
const shouldLoadParts =
loadingPartsAtEdge || this.shouldLoadParts(levelDetails, frag.end);
if (shouldLoadParts !== this.loadingParts) {
this.log(
`LL-Part loading ${
shouldLoadParts ? 'ON' : 'OFF'
} after parsing segment ending @${frag.end.toFixed(2)}`,
);
this.loadingParts = shouldLoadParts;
}
this.updateLevelTiming(frag, part, level, chunkMeta.partial);
}
private shouldLoadParts(
details: LevelDetails | undefined,
bufferEnd: number,
): boolean {
if (this.config.lowLatencyMode) {
if (!details) {
return this.loadingParts;
}
if (details.partList) {
// Buffer must be ahead of first part + duration of parts after last segment
// and playback must be at or past segment adjacent to part list
const firstPart = details.partList[0];
// Loading of VTT subtitle parts is not implemented in subtitle-stream-controller (#7460)
if (firstPart.fragment.type === PlaylistLevelType.SUBTITLE) {
return false;
}
const safePartStart =
firstPart.end + (details.fragmentHint?.duration || 0);
if (bufferEnd >= safePartStart) {
const playhead = this.hls.hasEnoughToStart
? this.media?.currentTime || this.lastCurrentTime
: this.getLoadPosition();
if (playhead > firstPart.start - firstPart.fragment.duration) {
return true;
}
}
}
}
return false;
}
protected getCurrentContext(
chunkMeta: ChunkMetadata,
): { frag: MediaFragment; part: Part | null; level: Level } | null {
const { levels, fragCurrent } = this;
const { level: levelIndex, sn, part: partIndex } = chunkMeta;
if (!levels?.[levelIndex]) {
this.warn(
`Levels object was unset while buffering fragment ${sn} of ${this.playlistLabel()} ${levelIndex}. The current chunk will not be buffered.`,
);
return null;
}
const level = levels[levelIndex];
const levelDetails = level.details;
const part =
partIndex > -1 ? getPartWith(levelDetails, sn, partIndex) : null;
const frag = part
? part.fragment
: getFragmentWithSN(levelDetails, sn, fragCurrent);
if (!frag) {
return null;
}
if (fragCurrent && fragCurrent !== frag) {
frag.stats = fragCurrent.stats;
}
return { frag, part, level };
}
protected bufferFragmentData(
data: RemuxedTrack,
frag: Fragment,
part: Part | null,
chunkMeta: ChunkMetadata,
noBacktracking?: boolean,
) {
if (this.state !== State.PARSING) {
return;
}
const { data1, data2 } = data;
let buffer = data1;
if (data2) {
// Combine the moof + mdat so that we buffer with a single append
buffer = appendUint8Array(data1, data2);
}
if (!buffer.length) {
return;
}
const offsetTimestamp = this.initPTS[frag.cc] as
| TimestampOffset
| undefined;
const offset = offsetTimestamp
? -offsetTimestamp.baseTime / offsetTimestamp.timescale
: undefined;
const segment: BufferAppendingData = {
type: data.type,
frag,
part,
chunkMeta,
offset,
parent: frag.type,
data: buffer,
};
this.hls.trigger(Events.BUFFER_APPENDING, segment);
if (data.dropped && data.independent && !part) {
if (noBacktracking) {
return;
}
// Clear buffer so that we reload previous segments sequentially if required
this.flushBufferGap(frag);
}
}
protected flushBufferGap(frag: Fragment) {
const media = this.media;
if (!media) {
return;
}
// If currentTime is not buffered, clear the back buffer so that we can backtrack as much as needed
if (!BufferHelper.isBuffered(media, media.currentTime)) {
this.flushMainBuffer(0, frag.start);
return;
}
// Remove back-buffer without interrupting playback to allow back tracking
const currentTime = media.currentTime;
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
const fragDuration = frag.duration;
const segmentFraction = Math.min(
this.config.maxFragLookUpTolerance * 2,
fragDuration * 0.25,
);
const start = Math.max(
Math.min(frag.start - segmentFraction, bufferInfo.end - segmentFraction),
currentTime + segmentFraction,
);
if (frag.start - start > segmentFraction) {
this.flushMainBuffer(start, frag.start);
}
}
protected getFwdBufferInfo(
bufferable: Bufferable | null,
type: PlaylistLevelType,
): BufferInfo | null {
const pos = this.getLoadPosition();
if (!Number.isFinite(pos)) {
return null;
}
const backwardSeek = this.lastCurrentTime > pos;
const maxBufferHole =
backwardSeek || this.media?.paused ? 0 : this.config.maxBufferHole;
return this.getFwdBufferInfoAtPos(bufferable, pos, type, maxBufferHole);
}
protected getFwdBufferInfoAtPos(
bufferable: Bufferable | null,
pos: number,
type: PlaylistLevelType,
maxBufferHole: number,
): BufferInfo | null {
const bufferInfo = BufferHelper.bufferInfo(bufferable, pos, maxBufferHole);
// Workaround flaw in getting forward buffer when maxBufferHole is smaller than gap at current pos
if (bufferInfo.len === 0 && bufferInfo.nextStart !== undefined) {
const bufferedFragAtPos = this.fragmentTracker.getBufferedFrag(pos, type);
if (
bufferedFragAtPos &&
(bufferInfo.nextStart <= bufferedFragAtPos.end || bufferedFragAtPos.gap)
) {
const gapDuration = Math.max(
Math.min(bufferInfo.nextStart, bufferedFragAtPos.end) - pos,
maxBufferHole,
);
return BufferHelper.bufferInfo(bufferable, pos, gapDuration);
}
}
return bufferInfo;
}
protected getMaxBufferLength(levelBitrate?: number): number {
const { config } = this;
let maxBufLen: number;
if (levelBitrate) {
maxBufLen = Math.max(
(8 * config.maxBufferSize) / levelBitrate,
config.maxBufferLength,
);
} else {
maxBufLen = config.maxBufferLength;
}
return Math.min(maxBufLen, config.maxMaxBufferLength);
}
protected exceedsMaxBuffer(
bufferInfo: BufferInfo,
maxBufLen: number,
selected: Fragment,
): boolean {
const nextStart = bufferInfo.nextStart;
if (nextStart && selected.start > nextStart) {
const bufferedRanges = bufferInfo.buffered;
if (bufferedRanges) {
let fullBufferLength = bufferInfo.len;
const bufferedIndex = bufferInfo.bufferedIndex;
for (let i = bufferedRanges.length - 1; i > bufferedIndex; i--) {
if (bufferedRanges[i].start < selected.start) {
fullBufferLength += bufferedRanges[i].end - bufferedRanges[i].start;
}
}
return fullBufferLength >= maxBufLen;
}
}
return false;
}
protected reduceMaxBufferLength(threshold: number, fragDuration: number) {
const config = this.config;
const minLength = Math.max(
Math.min(threshold - fragDuration, config.maxBufferLength),
fragDuration,
);
const reducedLength = Math.max(
threshold - fragDuration * 3,
config.maxMaxBufferLength / 2,
minLength,
);
if (reducedLength >= minLength) {
// reduce max buffer length as it might be too high. we do this to avoid loop flushing ...
config.maxMaxBufferLength = reducedLength;
this.warn(`Reduce max buffer length to ${reducedLength}s`);
return true;
}
return false;
}
protected getAppendedFrag(
position: number,
playlistType: PlaylistLevelType = PlaylistLevelType.MAIN,
): Fragment | null {
const fragOrPart = (this.fragmentTracker as any)
? this.fragmentTracker.getAppendedFrag(position, playlistType)
: null;
if (fragOrPart && 'fragment' in fragOrPart) {
return fragOrPart.fragment;
}
return fragOrPart;
}
protected getNextFragment(
pos: number,
levelDetails: LevelDetails,
): Fragment | null {
const fragments = levelDetails.fragments;
const fragLen = fragments.length;
if (!fragLen) {
return null;
}
// find fragment index, contiguous with end of buffer position
const { config } = this;
const playlistStart = levelDetails.fragmentStart;
const canLoadParts = config.lowLatencyMode && !!levelDetails.partList;
let frag: MediaFragment | null = null;
if (levelDetails.live) {
const initialLiveManifestSize = config.initialLiveManifestSize;
if (fragLen < initialLiveManifestSize) {
this.warn(
`Not enough fragments to start playback (have: ${fragLen}, need: ${initialLiveManifestSize})`,
);
return null;
}
// The real fragment start times for a live stream are only known after the PTS range for that level is known.
// In order to discover the range, we load the best matching fragment for that level and demux it.
// Do not load using live logic if the starting frag is requested - we want to use getFragmentAtPosition() so that
// we get the fragment matching that start time
if (
(!levelDetails.PTSKnown &&
!this.startFragRequested &&
this.startPosition === -1) ||
pos < playlistStart
) {
if (canLoadParts && !this.loadingParts) {
this.log(`LL-Part loading ON for initial live fragment`);
this.loadingParts = true;
}
frag = this.getInitialLiveFragment(levelDetails);
const configValue = this.config.startPosition;
const mainStart = this.hls.startPosition;
const liveSyncPosition = this.hls.liveSyncPosition;
const fragStart = frag?.start || 0;
let startPosition: number | undefined;
let reason: string | undefined;
if (mainStart !== -1 && mainStart >= playlistStart) {
startPosition = mainStart;
reason = mainStart === configValue ? 'config' : 'next load start';
} else if (liveSyncPosition !== null) {
startPosition = liveSyncPosition;
reason = 'live edge';
} else {
startPosition = pos;
reason = 'buffer pos';
}
if (startPosition < fragStart) {
startPosition = fragStart;
reason = 'live frag start';
}
if (startPosition < playlistStart) {
startPosition = playlistStart;
reason = 'playlist start';
}
if (
this.startPosition != startPosition ||
this.nextLoadPosition != startPosition
) {
this.log(
`Setting startPosition to ${startPosition.toFixed(3)} ${mainStart === -1 ? '' : `(from ${configValue}) `}based on ${reason}. live edge: ${liveSyncPosition} live frag start: ${fragStart.toFixed(3)} playlist start: ${playlistStart.toFixed(3)} buffer pos: ${pos}`,
);
this.startPosition = this.nextLoadPosition = startPosition;
}
}
} else if (pos <= playlistStart) {
// VoD playlist: if loadPosition before start of playlist, load first fragment
frag = fragments[0];
}
// If we haven't run into any special cases already, just load the fragment most closely matching the requested position
if (!frag) {
const end = this.loadingParts
? levelDetails.partEnd
: levelDetails.fragmentEnd;
frag = this.getFragmentAtPosition(pos, end, levelDetails);
}
let programFrag = this.filterReplacedPrimary(frag, levelDetails);
if (!programFrag && frag) {
const curSNIdx = frag.sn - levelDetails.startSN;
programFrag = this.filterReplacedPrimary(
fragments[curSNIdx + 1] || null,
levelDetails,
);
}
return this.mapToInitFragWhenRequired(programFrag);
}
protected isLoopLoading(frag: Fragment, targetBufferTime: number): boolean {
if (this.nextLoadPosition <= targetBufferTime) {
return false;
}
const trackerState = this.fragmentTracker.getState(frag);
return (
trackerState === FragmentState.OK ||
(trackerState === FragmentState.PARTIAL && !!frag.gap)
);
}
protected getNextFragmentLoopLoading(
frag: Fragment,
levelDetails: LevelDetails,
bufferInfo: BufferInfo,
playlistType: PlaylistLevelType,
maxBufLen: number,
): Fragment | null {
let nextFragment: Fragment | null = null;
if (frag.gap) {
nextFragment = this.getNextFragment(this.nextLoadPosition, levelDetails);
if (nextFragment && !nextFragment.gap && bufferInfo.nextStart) {
// Media buffered after GAP tags should not make the next buffer timerange exceed forward buffer length
const nextbufferInfo = this.getFwdBufferInfoAtPos(
this.mediaBuffer ? this.mediaBuffer : this.media,
bufferInfo.nextStart,
playlistType,
0,
);
if (
nextbufferInfo !== null &&
bufferInfo.len + nextbufferInfo.len >= maxBufLen
) {
// Returning here might result in not finding an audio and video candiate to skip to
const sn = nextFragment.sn;
if (this.loopSn !== sn) {
this.log(
`buffer full after gaps in "${playlistType}" playlist starting at sn: ${sn}`,
);
this.loopSn = sn;
}
return null;
}
}
}
this.loopSn = undefined;
return nextFragment;
}
protected get primaryPrefetch(): boolean {
if (interstitialsEnabled(this.config)) {
const playingInterstitial =
this.hls.interstitialsManager?.playingItem?.event;
if (playingInterstitial) {
return true;
}
}
return false;
}
protected filterReplacedPrimary(
frag: MediaFragment | null,
details: LevelDetails | undefined,
): MediaFragment | null {
if (!frag) {
return frag;
}
if (
interstitialsEnabled(this.config) &&
frag.type !== PlaylistLevelType.SUBTITLE
) {
// Do not load fragments outside the buffering schedule segment
const interstitials = this.hls.interstitialsManager;
const bufferingItem = interstitials?.bufferingItem;