src/controller/base-playlist-controller.ts
import type Hls from '../hls';
import type { NetworkComponentAPI } from '../types/component-api';
import { getSkipValue, HlsSkip, HlsUrlParameters } from '../types/level';
import { computeReloadInterval } from './level-helper';
import { logger } from '../utils/logger';
import type { LevelDetails } from '../loader/level-details';
import type { MediaPlaylist } from '../types/media-playlist';
import type {
AudioTrackLoadedData,
LevelLoadedData,
TrackLoadedData,
} from '../types/events';
import { ErrorData } from '../types/events';
import * as LevelHelper from './level-helper';
import { Events } from '../events';
import { ErrorTypes } from '../errors';
export default class BasePlaylistController implements NetworkComponentAPI {
protected hls: Hls;
protected timer: number = -1;
protected canLoad: boolean = false;
protected retryCount: number = 0;
protected log: (msg: any) => void;
protected warn: (msg: any) => void;
constructor(hls: Hls, logPrefix: string) {
this.log = logger.log.bind(logger, `${logPrefix}:`);
this.warn = logger.warn.bind(logger, `${logPrefix}:`);
this.hls = hls;
}
public destroy(): void {
this.clearTimer();
// @ts-ignore
this.hls = this.log = this.warn = null;
}
protected onError(event: Events.ERROR, data: ErrorData): void {
if (data.fatal && data.type === ErrorTypes.NETWORK_ERROR) {
this.clearTimer();
}
}
protected clearTimer(): void {
clearTimeout(this.timer);
this.timer = -1;
}
public startLoad(): void {
this.canLoad = true;
this.retryCount = 0;
this.loadPlaylist();
}
public stopLoad(): void {
this.canLoad = false;
this.clearTimer();
}
protected switchParams(
playlistUri: string,
previous?: LevelDetails
): HlsUrlParameters | undefined {
const renditionReports = previous?.renditionReports;
if (renditionReports) {
for (let i = 0; i < renditionReports.length; i++) {
const attr = renditionReports[i];
const uri = '' + attr.URI;
if (uri === playlistUri.substr(-uri.length)) {
const msn = parseInt(attr['LAST-MSN']);
let part = parseInt(attr['LAST-PART']);
if (previous && this.hls.config.lowLatencyMode) {
const currentGoal = Math.min(
previous.age - previous.partTarget,
previous.targetduration
);
if (part !== undefined && currentGoal > previous.partTarget) {
part += 1;
}
}
if (Number.isFinite(msn)) {
return new HlsUrlParameters(
msn,
Number.isFinite(part) ? part : undefined,
HlsSkip.No
);
}
}
}
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {}
protected shouldLoadTrack(track: MediaPlaylist): boolean {
return (
this.canLoad &&
track &&
!!track.url &&
(!track.details || track.details.live)
);
}
protected playlistLoaded(
index: number,
data: LevelLoadedData | AudioTrackLoadedData | TrackLoadedData,
previousDetails?: LevelDetails
) {
const { details, stats } = data;
// Set last updated date-time
const elapsed = stats.loading.end
? Math.max(0, self.performance.now() - stats.loading.end)
: 0;
details.advancedDateTime = Date.now() - elapsed;
// if current playlist is a live playlist, arm a timer to reload it
if (details.live || previousDetails?.live) {
details.reloaded(previousDetails);
if (previousDetails) {
this.log(
`live playlist ${index} ${
details.advanced
? 'REFRESHED ' + details.lastPartSn + '-' + details.lastPartIndex
: 'MISSED'
}`
);
}
// Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
if (previousDetails && details.fragments.length > 0) {
LevelHelper.mergeDetails(previousDetails, details);
}
if (!this.canLoad || !details.live) {
return;
}
let msn: number | undefined = undefined;
let part: number | undefined = undefined;
if (details.canBlockReload && details.endSN && details.advanced) {
// Load level with LL-HLS delivery directives
const lowLatencyMode = this.hls.config.lowLatencyMode;
const lastPartSn = details.lastPartSn;
const endSn = details.endSN;
const lastPartIndex = details.lastPartIndex;
const hasParts = lastPartIndex !== -1;
const lastPart = lastPartSn === endSn;
// When low latency mode is disabled, we'll skip part requests once the last part index is found
const nextSnStartIndex = lowLatencyMode ? 0 : lastPartIndex;
if (hasParts) {
msn = lastPart ? endSn + 1 : lastPartSn;
part = lastPart ? nextSnStartIndex : lastPartIndex + 1;
} else {
msn = endSn + 1;
}
// Low-Latency CDN Tune-in: "age" header and time since load indicates we're behind by more than one part
// Update directives to obtain the Playlist that has the estimated additional duration of media
const lastAdvanced = details.age;
const cdnAge = lastAdvanced + details.ageHeader;
let currentGoal = Math.min(
cdnAge - details.partTarget,
details.targetduration * 1.5
);
if (currentGoal > 0) {
if (previousDetails && currentGoal > previousDetails.tuneInGoal) {
// If we attempted to get the next or latest playlist update, but currentGoal increased,
// then we either can't catchup, or the "age" header cannot be trusted.
this.warn(
`CDN Tune-in goal increased from: ${previousDetails.tuneInGoal} to: ${currentGoal} with playlist age: ${details.age}`
);
currentGoal = 0;
} else {
const segments = Math.floor(currentGoal / details.targetduration);
msn += segments;
if (part !== undefined) {
const parts = Math.round(
(currentGoal % details.targetduration) / details.partTarget
);
part += parts;
}
this.log(
`CDN Tune-in age: ${
details.ageHeader
}s last advanced ${lastAdvanced.toFixed(
2
)}s goal: ${currentGoal} skip sn ${segments} to part ${part}`
);
}
details.tuneInGoal = currentGoal;
}
const deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part
);
this.loadPlaylist(deliveryDirectives);
return;
}
const reloadInterval = computeReloadInterval(details, stats);
this.log(
`reload live playlist ${index} in ${Math.round(reloadInterval)} ms`
);
const deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part
);
this.timer = self.setTimeout(
() => this.loadPlaylist(deliveryDirectives),
reloadInterval
);
} else {
this.clearTimer();
}
}
private getDeliveryDirectives(
details: LevelDetails,
previousDeliveryDirectives: HlsUrlParameters | null,
msn?: number,
part?: number
) {
let skip = getSkipValue(details, msn);
if (previousDeliveryDirectives?.skip && details.deltaUpdateFailed) {
msn = previousDeliveryDirectives.msn;
part = previousDeliveryDirectives.part;
skip = HlsSkip.No;
}
return new HlsUrlParameters(msn, part, skip);
}
protected retryLoadingOrFail(errorEvent: ErrorData): boolean {
const { config } = this.hls;
const retry = this.retryCount < config.levelLoadingMaxRetry;
if (retry) {
this.retryCount++;
if (
errorEvent.details.indexOf('LoadTimeOut') > -1 &&
errorEvent.context?.deliveryDirectives
) {
// The LL-HLS request already timed out so retry immediately
this.warn(
`retry playlist loading #${this.retryCount} after "${errorEvent.details}"`
);
this.loadPlaylist();
} else {
// exponential backoff capped to max retry timeout
const delay = Math.min(
Math.pow(2, this.retryCount) * config.levelLoadingRetryDelay,
config.levelLoadingMaxRetryTimeout
);
// Schedule level/track reload
this.timer = self.setTimeout(() => this.loadPlaylist(), delay);
this.warn(
`retry playlist loading #${this.retryCount} in ${delay} ms after "${errorEvent.details}"`
);
}
} else {
this.warn(`cannot recover from error "${errorEvent.details}"`);
// stopping live reloading timer if any
this.clearTimer();
// switch error to fatal
errorEvent.fatal = true;
}
return retry;
}
}