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, mergeDetails } 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 { 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.slice(-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) {
- mergeDetails(previousDetails, details);
- }
- if (!this.canLoad || !details.live) {
- return;
- }
- let deliveryDirectives: HlsUrlParameters;
- 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 {
- deliveryDirectives = this.getDeliveryDirectives(
- details,
- data.deliveryDirectives,
- msn,
- part
- );
- }
- let reloadInterval = computeReloadInterval(details, stats);
- if (msn !== undefined && details.canBlockReload) {
- reloadInterval -= details.partTarget || 1;
- }
- this.log(
- `reload live playlist ${index} in ${Math.round(reloadInterval)} ms`
- );
- this.timer = self.setTimeout(
- () => this.loadPlaylist(deliveryDirectives),
- reloadInterval
- );
- } else {
- this.clearTimer();
- }
- }
-
- private getDeliveryDirectives(
- details: LevelDetails,
- previousDeliveryDirectives: HlsUrlParameters | null,
- msn?: number,
- part?: number
- ): HlsUrlParameters {
- 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;
- }
- }