hls.js
Version:
JavaScript HLS client using MediaSourceExtension
415 lines (394 loc) • 14 kB
text/typescript
import { NetworkErrorAction } from './error-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
getSkipValue,
HlsSkip,
HlsUrlParameters,
type Level,
} from '../types/level';
import { getRetryDelay, isTimeoutError } from '../utils/error-helper';
import { computeReloadInterval, mergeDetails } from '../utils/level-helper';
import { Logger } from '../utils/logger';
import type Hls from '../hls';
import type { LevelDetails } from '../loader/level-details';
import type { NetworkComponentAPI } from '../types/component-api';
import type { ErrorData } from '../types/events';
import type {
AudioTrackLoadedData,
LevelLoadedData,
TrackLoadedData,
} from '../types/events';
import type { MediaPlaylist } from '../types/media-playlist';
export default class BasePlaylistController
extends Logger
implements NetworkComponentAPI
{
protected hls: Hls;
protected canLoad: boolean = false;
private timer: number = -1;
constructor(hls: Hls, logPrefix: string) {
super(logPrefix, hls.logger);
this.hls = hls;
}
public destroy() {
this.clearTimer();
// @ts-ignore
this.hls = this.log = this.warn = null;
}
private clearTimer() {
if (this.timer !== -1) {
self.clearTimeout(this.timer);
this.timer = -1;
}
}
public startLoad() {
this.canLoad = true;
this.loadPlaylist();
}
public stopLoad() {
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) {
this.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) {
// Loading is handled by the subclasses
this.clearTimer();
}
protected loadingPlaylist(
playlist: Level | MediaPlaylist,
hlsUrlParameters?: HlsUrlParameters,
) {
// Loading is handled by the subclasses
this.clearTimer();
}
protected shouldLoadPlaylist(
playlist: Level | MediaPlaylist | null | undefined,
): playlist is Level | MediaPlaylist {
return (
this.canLoad &&
!!playlist &&
!!playlist.url &&
(!playlist.details || playlist.details.live)
);
}
protected getUrlWithDirectives(
uri: string,
hlsUrlParameters: HlsUrlParameters | undefined,
): string {
if (hlsUrlParameters) {
try {
return hlsUrlParameters.addDirectives(uri);
} catch (error) {
this.warn(
`Could not construct new URL with HLS Delivery Directives: ${error}`,
);
}
}
return uri;
}
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;
// shift fragment starts with timelineOffset
const timelineOffset = this.hls.config.timelineOffset;
if (timelineOffset !== details.appliedTimelineOffset) {
const offset = Math.max(timelineOffset || 0, 0);
details.appliedTimelineOffset = offset;
details.fragments.forEach((frag) => {
frag.setStart(frag.playlistOffset + offset);
});
}
// if current playlist is a live playlist, arm a timer to reload it
if (details.live || previousDetails?.live) {
const levelOrTrack = 'levelInfo' in data ? data.levelInfo : data.track;
details.reloaded(previousDetails);
// Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
if (previousDetails && details.fragments.length > 0) {
mergeDetails(previousDetails, details, this);
const error = details.playlistParsingError;
if (error) {
this.warn(error);
const hls = this.hls;
if (!hls.config.ignorePlaylistParsingErrors) {
const { networkDetails } = data;
hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_PARSING_ERROR,
fatal: false,
url: details.url,
error,
reason: error.message,
level: (data as any).level || undefined,
parent: details.fragments[0]?.type,
networkDetails,
stats,
});
return;
}
details.playlistParsingError = null;
}
}
if (details.requestScheduled === -1) {
details.requestScheduled = stats.loading.start;
}
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.requestScheduled + reloadInterval < now) {
details.requestScheduled = now;
} else {
details.requestScheduled += reloadInterval;
}
this.log(
`live playlist ${index} ${
details.advanced
? 'REFRESHED ' + details.lastPartSn + '-' + details.lastPartIndex
: details.updated
? 'UPDATED'
: 'MISSED'
}`,
);
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 atLastPartOfSegment = lastPartSn === endSn;
if (hasParts) {
// When low latency mode is disabled, request the last part of the next segment
if (atLastPartOfSegment) {
msn = endSn + 1;
part = lowLatencyMode ? 0 : lastPartIndex;
} else {
msn = lastPartSn;
part = lowLatencyMode ? lastPartIndex + 1 : details.maxPartIndex;
}
} 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 (cdnAge > details.targetduration * 3) {
// Omit segment and part directives when the last response was more than 3 target durations ago,
this.log(
`Playlist last advanced ${lastAdvanced.toFixed(
2,
)}s ago. Omitting segment and part directives.`,
);
msn = undefined;
part = undefined;
} else if (
previousDetails?.tuneInGoal &&
cdnAge - details.partTarget > 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 || !atLastPartOfSegment) {
details.requestScheduled = now;
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
} else if (details.canBlockReload || details.canSkipUntil) {
deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part,
);
}
if (deliveryDirectives && msn !== undefined && details.canBlockReload) {
details.requestScheduled =
stats.loading.first +
Math.max(reloadInterval - elapsed * 2, reloadInterval / 2);
}
this.scheduleLoading(levelOrTrack, deliveryDirectives, details);
} else {
this.clearTimer();
}
}
protected scheduleLoading(
levelOrTrack: Level | MediaPlaylist,
deliveryDirectives?: HlsUrlParameters,
updatedDetails?: LevelDetails,
) {
const details = updatedDetails || levelOrTrack.details;
if (!details) {
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
const now = self.performance.now();
const requestScheduled = details.requestScheduled;
if (now >= requestScheduled) {
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
const estimatedTimeUntilUpdate = requestScheduled - now;
this.log(
`reload live playlist ${levelOrTrack.name || levelOrTrack.bitrate + 'bps'} in ${Math.round(
estimatedTimeUntilUpdate,
)} ms`,
);
this.clearTimer();
this.timer = self.setTimeout(
() => this.loadingPlaylist(levelOrTrack, deliveryDirectives),
estimatedTimeUntilUpdate,
);
}
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) {
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.clearTimer();
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;
}
}