349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import type Hls from '../hls';
|
|
import type { NetworkComponentAPI } from '../types/component-api';
|
|
import { getSkipValue, HlsSkip, HlsUrlParameters, Level } from '../types/level';
|
|
import { computeReloadInterval, mergeDetails } from '../utils/level-helper';
|
|
import { ErrorData } from '../types/events';
|
|
import { getRetryDelay, isTimeoutError } from '../utils/error-helper';
|
|
import { NetworkErrorAction } from './error-controller';
|
|
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';
|
|
|
|
export default class BasePlaylistController implements NetworkComponentAPI {
|
|
protected hls: Hls;
|
|
protected timer: number = -1;
|
|
protected requestScheduled: number = -1;
|
|
protected canLoad: boolean = false;
|
|
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 clearTimer(): void {
|
|
if (this.timer !== -1) {
|
|
self.clearTimeout(this.timer);
|
|
this.timer = -1;
|
|
}
|
|
}
|
|
|
|
public startLoad(): void {
|
|
this.canLoad = true;
|
|
this.requestScheduled = -1;
|
|
this.loadPlaylist();
|
|
}
|
|
|
|
public stopLoad(): void {
|
|
this.canLoad = false;
|
|
this.clearTimer();
|
|
}
|
|
|
|
protected switchParams(
|
|
playlistUri: string,
|
|
previous: LevelDetails | undefined,
|
|
current: LevelDetails | undefined,
|
|
): HlsUrlParameters | undefined {
|
|
const renditionReports = previous?.renditionReports;
|
|
if (renditionReports) {
|
|
let foundIndex = -1;
|
|
for (let i = 0; i < renditionReports.length; i++) {
|
|
const attr = renditionReports[i];
|
|
let uri: string;
|
|
try {
|
|
uri = new self.URL(attr.URI, previous.url).href;
|
|
} catch (error) {
|
|
logger.warn(
|
|
`Could not construct new URL for Rendition Report: ${error}`,
|
|
);
|
|
uri = attr.URI || '';
|
|
}
|
|
// Use exact match. Otherwise, the last partial match, if any, will be used
|
|
// (Playlist URI includes a query string that the Rendition Report does not)
|
|
if (uri === playlistUri) {
|
|
foundIndex = i;
|
|
break;
|
|
} else if (uri === playlistUri.substring(0, uri.length)) {
|
|
foundIndex = i;
|
|
}
|
|
}
|
|
if (foundIndex !== -1) {
|
|
const attr = renditionReports[foundIndex];
|
|
const msn = parseInt(attr['LAST-MSN']) || previous?.lastPartSn;
|
|
let part = parseInt(attr['LAST-PART']) || previous?.lastPartIndex;
|
|
if (this.hls.config.lowLatencyMode) {
|
|
const currentGoal = Math.min(
|
|
previous.age - previous.partTarget,
|
|
previous.targetduration,
|
|
);
|
|
if (part >= 0 && currentGoal > previous.partTarget) {
|
|
part += 1;
|
|
}
|
|
}
|
|
const skip = current && getSkipValue(current);
|
|
return new HlsUrlParameters(msn, part >= 0 ? part : undefined, skip);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
|
|
if (this.requestScheduled === -1) {
|
|
this.requestScheduled = self.performance.now();
|
|
}
|
|
// Loading is handled by the subclasses
|
|
}
|
|
|
|
protected shouldLoadPlaylist(
|
|
playlist: Level | MediaPlaylist | null | undefined,
|
|
): boolean {
|
|
return (
|
|
this.canLoad &&
|
|
!!playlist &&
|
|
!!playlist.url &&
|
|
(!playlist.details || playlist.details.live)
|
|
);
|
|
}
|
|
|
|
protected shouldReloadPlaylist(
|
|
playlist: Level | MediaPlaylist | null | undefined,
|
|
): boolean {
|
|
return (
|
|
this.timer === -1 &&
|
|
this.requestScheduled === -1 &&
|
|
this.shouldLoadPlaylist(playlist)
|
|
);
|
|
}
|
|
|
|
protected playlistLoaded(
|
|
index: number,
|
|
data: LevelLoadedData | AudioTrackLoadedData | TrackLoadedData,
|
|
previousDetails?: LevelDetails,
|
|
) {
|
|
const { details, stats } = data;
|
|
|
|
// Set last updated date-time
|
|
const now = self.performance.now();
|
|
const elapsed = stats.loading.first
|
|
? Math.max(0, now - stats.loading.first)
|
|
: 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
|
|
: details.updated
|
|
? 'UPDATED'
|
|
: 'MISSED'
|
|
}`,
|
|
);
|
|
}
|
|
// Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
|
|
if (previousDetails && details.fragments.length > 0) {
|
|
mergeDetails(previousDetails, details);
|
|
}
|
|
if (!this.canLoad || !details.live) {
|
|
return;
|
|
}
|
|
let deliveryDirectives: HlsUrlParameters | undefined;
|
|
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;
|
|
}
|
|
deliveryDirectives = this.getDeliveryDirectives(
|
|
details,
|
|
data.deliveryDirectives,
|
|
msn,
|
|
part,
|
|
);
|
|
if (lowLatencyMode || !lastPart) {
|
|
this.loadPlaylist(deliveryDirectives);
|
|
return;
|
|
}
|
|
} else if (details.canBlockReload || details.canSkipUntil) {
|
|
deliveryDirectives = this.getDeliveryDirectives(
|
|
details,
|
|
data.deliveryDirectives,
|
|
msn,
|
|
part,
|
|
);
|
|
}
|
|
const bufferInfo = this.hls.mainForwardBufferInfo;
|
|
const position = bufferInfo ? bufferInfo.end - bufferInfo.len : 0;
|
|
const distanceToLiveEdgeMs = (details.edge - position) * 1000;
|
|
const reloadInterval = computeReloadInterval(
|
|
details,
|
|
distanceToLiveEdgeMs,
|
|
);
|
|
if (details.updated && now > this.requestScheduled + reloadInterval) {
|
|
this.requestScheduled = stats.loading.start;
|
|
}
|
|
|
|
if (msn !== undefined && details.canBlockReload) {
|
|
this.requestScheduled =
|
|
stats.loading.first +
|
|
reloadInterval -
|
|
(details.partTarget * 1000 || 1000);
|
|
} else if (
|
|
this.requestScheduled === -1 ||
|
|
this.requestScheduled + reloadInterval < now
|
|
) {
|
|
this.requestScheduled = now;
|
|
} else if (this.requestScheduled - now <= 0) {
|
|
this.requestScheduled += reloadInterval;
|
|
}
|
|
let estimatedTimeUntilUpdate = this.requestScheduled - now;
|
|
estimatedTimeUntilUpdate = Math.max(0, estimatedTimeUntilUpdate);
|
|
this.log(
|
|
`reload live playlist ${index} in ${Math.round(
|
|
estimatedTimeUntilUpdate,
|
|
)} ms`,
|
|
);
|
|
// this.log(
|
|
// `live reload ${details.updated ? 'REFRESHED' : 'MISSED'}
|
|
// reload in ${estimatedTimeUntilUpdate / 1000}
|
|
// round trip ${(stats.loading.end - stats.loading.start) / 1000}
|
|
// diff ${
|
|
// (reloadInterval -
|
|
// (estimatedTimeUntilUpdate +
|
|
// stats.loading.end -
|
|
// stats.loading.start)) /
|
|
// 1000
|
|
// }
|
|
// reload interval ${reloadInterval / 1000}
|
|
// target duration ${details.targetduration}
|
|
// distance to edge ${distanceToLiveEdgeMs / 1000}`
|
|
// );
|
|
|
|
this.timer = self.setTimeout(
|
|
() => this.loadPlaylist(deliveryDirectives),
|
|
estimatedTimeUntilUpdate,
|
|
);
|
|
} else {
|
|
this.clearTimer();
|
|
}
|
|
}
|
|
|
|
private getDeliveryDirectives(
|
|
details: LevelDetails,
|
|
previousDeliveryDirectives: HlsUrlParameters | null,
|
|
msn?: number,
|
|
part?: number,
|
|
): HlsUrlParameters {
|
|
let skip = getSkipValue(details);
|
|
if (previousDeliveryDirectives?.skip && details.deltaUpdateFailed) {
|
|
msn = previousDeliveryDirectives.msn;
|
|
part = previousDeliveryDirectives.part;
|
|
skip = HlsSkip.No;
|
|
}
|
|
return new HlsUrlParameters(msn, part, skip);
|
|
}
|
|
|
|
protected checkRetry(errorEvent: ErrorData): boolean {
|
|
const errorDetails = errorEvent.details;
|
|
const isTimeout = isTimeoutError(errorEvent);
|
|
const errorAction = errorEvent.errorAction;
|
|
const { action, retryCount = 0, retryConfig } = errorAction || {};
|
|
const retry =
|
|
!!errorAction &&
|
|
!!retryConfig &&
|
|
(action === NetworkErrorAction.RetryRequest ||
|
|
(!errorAction.resolved &&
|
|
action === NetworkErrorAction.SendAlternateToPenaltyBox));
|
|
if (retry) {
|
|
this.requestScheduled = -1;
|
|
if (retryCount >= retryConfig.maxNumRetry) {
|
|
return false;
|
|
}
|
|
if (isTimeout && errorEvent.context?.deliveryDirectives) {
|
|
// The LL-HLS request already timed out so retry immediately
|
|
this.warn(
|
|
`Retrying playlist loading ${retryCount + 1}/${
|
|
retryConfig.maxNumRetry
|
|
} after "${errorDetails}" without delivery-directives`,
|
|
);
|
|
this.loadPlaylist();
|
|
} else {
|
|
const delay = getRetryDelay(retryConfig, retryCount);
|
|
// Schedule level/track reload
|
|
this.timer = self.setTimeout(() => this.loadPlaylist(), delay);
|
|
this.warn(
|
|
`Retrying playlist loading ${retryCount + 1}/${
|
|
retryConfig.maxNumRetry
|
|
} after "${errorDetails}" in ${delay}ms`,
|
|
);
|
|
}
|
|
// `levelRetry = true` used to inform other controllers that a retry is happening
|
|
errorEvent.levelRetry = true;
|
|
errorAction.resolved = true;
|
|
}
|
|
return retry;
|
|
}
|
|
}
|