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 readonly log: (msg: any) => void;
protected readonly 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();
}
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 (!details.advanced) {
details.advancedDateTime = previousDetails.advancedDateTime;
}
}
if (!this.canLoad || !details.live) {
return;
}
if (details.canBlockReload && details.endSN && details.advanced) {
// Load level with LL-HLS delivery directives
const lowLatencyMode = this.hls.config.lowLatencyMode;
const lastPartIndex = details.lastPartIndex;
let msn;
let part;
if (lowLatencyMode) {
msn = lastPartIndex !== -1 ? details.lastPartSn : details.endSN + 1;
part = lastPartIndex !== -1 ? lastPartIndex + 1 : undefined;
} else {
// This playlist update will be late by one part (0). There is no way to know the last part number,
// or request just the next sn without a part in most implementations.
msn = lastPartIndex !== -1 ? details.lastPartSn + 1 : details.endSN + 1;
part = lastPartIndex !== -1 ? 0 : undefined;
}
// 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;
}
let skip = getSkipValue(details, msn);
if (data.deliveryDirectives?.skip) {
if (details.deltaUpdateFailed) {
msn = data.deliveryDirectives.msn;
part = data.deliveryDirectives.part;
skip = HlsSkip.No;
}
}
this.loadPlaylist(new HlsUrlParameters(msn, part, skip));
return;
}
const reloadInterval = computeReloadInterval(details, stats);
this.log(`reload live playlist ${index} in ${Math.round(reloadInterval)} ms`);
this.timer = self.setTimeout(() => this.loadPlaylist(), reloadInterval);
} else {
this.clearTimer();
}
}
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;
}
}