Files
sociology_app/node_modules/hls.js/src/controller/base-playlist-controller.ts
2024-07-17 14:06:06 +08:00

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;
}
}