Home Reference Source

src/controller/base-stream-controller.ts

import TaskLoop from '../task-loop';
import { FragmentState } from './fragment-tracker';
import { Bufferable, BufferHelper } from '../utils/buffer-helper';
import { logger } from '../utils/logger';
import { Events } from '../events';
import { ErrorDetails } from '../errors';
import { ChunkMetadata } from '../types/transmuxer';
import { appendUint8Array } from '../utils/mp4-tools';
import { alignStream } from '../utils/discontinuities';
import {
  findFragmentByPDT,
  findFragmentByPTS,
  findFragWithCC,
} from './fragment-finders';
import {
  getFragmentWithSN,
  getPartWith,
  updateFragPTSDTS,
} from './level-helper';
import TransmuxerInterface from '../demux/transmuxer-interface';
import { Fragment, Part } from '../loader/fragment';
import FragmentLoader, {
  FragmentLoadProgressCallback,
  LoadError,
} from '../loader/fragment-loader';
import { LevelDetails } from '../loader/level-details';
import {
  BufferAppendingData,
  ErrorData,
  FragLoadedData,
  PartsLoadedData,
  KeyLoadedData,
  MediaAttachingData,
  BufferFlushingData,
} from '../types/events';
import Decrypter from '../crypt/decrypter';
import TimeRanges from '../utils/time-ranges';
import { PlaylistLevelType } from '../types/loader';
import type { FragmentTracker } from './fragment-tracker';
import type { Level } from '../types/level';
import type { RemuxedTrack } from '../types/remuxer';
import type Hls from '../hls';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { NetworkComponentAPI } from '../types/component-api';
import type { SourceBufferName } from '../types/buffer';

type ResolveFragLoaded = (FragLoadedEndData) => void;
type RejectFragLoaded = (LoadError) => void;

export const State = {
  STOPPED: 'STOPPED',
  IDLE: 'IDLE',
  KEY_LOADING: 'KEY_LOADING',
  FRAG_LOADING: 'FRAG_LOADING',
  FRAG_LOADING_WAITING_RETRY: 'FRAG_LOADING_WAITING_RETRY',
  WAITING_TRACK: 'WAITING_TRACK',
  PARSING: 'PARSING',
  PARSED: 'PARSED',
  BACKTRACKING: 'BACKTRACKING',
  ENDED: 'ENDED',
  ERROR: 'ERROR',
  WAITING_INIT_PTS: 'WAITING_INIT_PTS',
  WAITING_LEVEL: 'WAITING_LEVEL',
};

export default class BaseStreamController
  extends TaskLoop
  implements NetworkComponentAPI
{
  protected hls: Hls;

  protected fragPrevious: Fragment | null = null;
  protected fragCurrent: Fragment | null = null;
  protected fragmentTracker: FragmentTracker;
  protected transmuxer: TransmuxerInterface | null = null;
  protected _state: string = State.STOPPED;
  protected media?: any;
  protected mediaBuffer?: any;
  protected config: HlsConfig;
  protected bitrateTest: boolean = false;
  protected lastCurrentTime: number = 0;
  protected nextLoadPosition: number = 0;
  protected startPosition: number = 0;
  protected loadedmetadata: boolean = false;
  protected fragLoadError: number = 0;
  protected retryDate: number = 0;
  protected levels: Array<Level> | null = null;
  protected fragmentLoader!: FragmentLoader;
  protected levelLastLoaded: number | null = null;
  protected startFragRequested: boolean = false;
  protected decrypter: Decrypter;
  protected initPTS: Array<number> = [];
  protected onvseeking: EventListener | null = null;
  protected onvended: EventListener | null = null;

  private readonly logPrefix: string = '';
  protected log: (msg: any) => void;
  protected warn: (msg: any) => void;

  constructor(hls: Hls, fragmentTracker: FragmentTracker, logPrefix: string) {
    super();
    this.logPrefix = logPrefix;
    this.log = logger.log.bind(logger, `${logPrefix}:`);
    this.warn = logger.warn.bind(logger, `${logPrefix}:`);
    this.hls = hls;
    this.fragmentLoader = new FragmentLoader(hls.config);
    this.fragmentTracker = fragmentTracker;
    this.config = hls.config;
    this.decrypter = new Decrypter(hls as HlsEventEmitter, hls.config);
    hls.on(Events.KEY_LOADED, this.onKeyLoaded, this);
  }

  protected doTick() {
    this.onTickEnd();
  }

  protected onTickEnd() {}

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  public startLoad(startPosition: number): void {}

  public stopLoad() {
    this.fragmentLoader.abort();
    const frag = this.fragCurrent;
    if (frag) {
      this.fragmentTracker.removeFragment(frag);
    }
    this.resetTransmuxer();
    this.fragCurrent = null;
    this.fragPrevious = null;
    this.clearInterval();
    this.clearNextTick();
    this.state = State.STOPPED;
  }

  protected _streamEnded(bufferInfo, levelDetails) {
    const { fragCurrent, fragmentTracker } = this;
    // we just got done loading the final fragment and there is no other buffered range after ...
    // rationale is that in case there are any buffered ranges after, it means that there are unbuffered portion in between
    // so we should not switch to ENDED in that case, to be able to buffer them
    if (
      !levelDetails.live &&
      fragCurrent &&
      fragCurrent.sn === levelDetails.endSN &&
      !bufferInfo.nextStart
    ) {
      const fragState = fragmentTracker.getState(fragCurrent);
      return (
        fragState === FragmentState.PARTIAL || fragState === FragmentState.OK
      );
    }
    return false;
  }

  protected onMediaAttached(
    event: Events.MEDIA_ATTACHED,
    data: MediaAttachingData
  ) {
    const media = (this.media = this.mediaBuffer = data.media);
    this.onvseeking = this.onMediaSeeking.bind(this);
    this.onvended = this.onMediaEnded.bind(this);
    media.addEventListener('seeking', this.onvseeking as EventListener);
    media.addEventListener('ended', this.onvended as EventListener);
    const config = this.config;
    if (this.levels && config.autoStartLoad && this.state === State.STOPPED) {
      this.startLoad(config.startPosition);
    }
  }

  protected onMediaDetaching() {
    const media = this.media;
    if (media?.ended) {
      this.log('MSE detaching and video ended, reset startPosition');
      this.startPosition = this.lastCurrentTime = 0;
    }

    // remove video listeners
    if (media) {
      media.removeEventListener('seeking', this.onvseeking);
      media.removeEventListener('ended', this.onvended);
      this.onvseeking = this.onvended = null;
    }
    this.media = this.mediaBuffer = null;
    this.loadedmetadata = false;
    this.fragmentTracker.removeAllFragments();
    this.stopLoad();
  }

  protected onMediaSeeking() {
    const { config, fragCurrent, media, mediaBuffer, state } = this;
    const currentTime: number = media ? media.currentTime : 0;
    const bufferInfo = BufferHelper.bufferInfo(
      mediaBuffer || media,
      currentTime,
      config.maxBufferHole
    );

    this.log(
      `media seeking to ${
        Number.isFinite(currentTime) ? currentTime.toFixed(3) : currentTime
      }, state: ${state}`
    );

    if (state === State.ENDED) {
      this.resetLoadingState();
    } else if (fragCurrent && !bufferInfo.len) {
      // check if we are seeking to a unbuffered area AND if frag loading is in progress
      const tolerance = config.maxFragLookUpTolerance;
      const fragStartOffset = fragCurrent.start - tolerance;
      const fragEndOffset =
        fragCurrent.start + fragCurrent.duration + tolerance;
      const pastFragment = currentTime > fragEndOffset;
      // check if the seek position is past current fragment, and if so abort loading
      if (currentTime < fragStartOffset || pastFragment) {
        if (pastFragment && fragCurrent.loader) {
          this.log(
            'seeking outside of buffer while fragment load in progress, cancel fragment load'
          );
          fragCurrent.loader.abort();
        }
        this.resetLoadingState();
      }
    }

    if (media) {
      this.lastCurrentTime = currentTime;
    }

    // in case seeking occurs although no media buffered, adjust startPosition and nextLoadPosition to seek target
    if (!this.loadedmetadata && !bufferInfo.len) {
      this.nextLoadPosition = this.startPosition = currentTime;
    }

    // Async tick to speed up processing
    this.tickImmediate();
  }

  protected onMediaEnded() {
    // reset startPosition and lastCurrentTime to restart playback @ stream beginning
    this.startPosition = this.lastCurrentTime = 0;
  }

  onKeyLoaded(event: Events.KEY_LOADED, data: KeyLoadedData) {
    if (
      this.state !== State.KEY_LOADING ||
      data.frag !== this.fragCurrent ||
      !this.levels
    ) {
      return;
    }
    this.state = State.IDLE;
    const levelDetails = this.levels[data.frag.level].details;
    if (levelDetails) {
      this.loadFragment(data.frag, levelDetails, data.frag.start);
    }
  }

  protected onHandlerDestroying() {
    this.stopLoad();
    super.onHandlerDestroying();
  }

  protected onHandlerDestroyed() {
    this.state = State.STOPPED;
    this.hls.off(Events.KEY_LOADED, this.onKeyLoaded, this);
    if (this.fragmentLoader) {
      this.fragmentLoader.destroy();
    }
    if (this.decrypter) {
      this.decrypter.destroy();
    }

    this.hls =
      this.log =
      this.warn =
      this.decrypter =
      this.fragmentLoader =
      this.fragmentTracker =
        null as any;
    super.onHandlerDestroyed();
  }

  protected loadKey(frag: Fragment, details: LevelDetails) {
    this.log(
      `Loading key for ${frag.sn} of [${details.startSN}-${details.endSN}], ${
        this.logPrefix === '[stream-controller]' ? 'level' : 'track'
      } ${frag.level}`
    );
    this.state = State.KEY_LOADING;
    this.fragCurrent = frag;
    this.hls.trigger(Events.KEY_LOADING, { frag });
  }

  protected loadFragment(
    frag: Fragment,
    levelDetails: LevelDetails,
    targetBufferTime: number
  ) {
    this._loadFragForPlayback(frag, levelDetails, targetBufferTime);
  }

  private _loadFragForPlayback(
    frag: Fragment,
    levelDetails: LevelDetails,
    targetBufferTime: number
  ) {
    const progressCallback: FragmentLoadProgressCallback = (
      data: FragLoadedData
    ) => {
      if (this.fragContextChanged(frag)) {
        this.warn(
          `Fragment ${frag.sn}${
            data.part ? ' p: ' + data.part.index : ''
          } of level ${frag.level} was dropped during download.`
        );
        this.fragmentTracker.removeFragment(frag);
        return;
      }
      frag.stats.chunkCount++;
      this._handleFragmentLoadProgress(data);
    };

    this._doFragLoad(frag, levelDetails, targetBufferTime, progressCallback)
      .then((data) => {
        if (!data) {
          // if we're here we probably needed to backtrack or are waiting for more parts
          return;
        }
        this.fragLoadError = 0;
        const state = this.state;
        if (this.fragContextChanged(frag)) {
          if (
            state === State.FRAG_LOADING ||
            state === State.BACKTRACKING ||
            (!this.fragCurrent && state === State.PARSING)
          ) {
            this.fragmentTracker.removeFragment(frag);
            this.state = State.IDLE;
          }
          return;
        }

        if ('payload' in data) {
          this.log(`Loaded fragment ${frag.sn} of level ${frag.level}`);
          this.hls.trigger(Events.FRAG_LOADED, data);

          // Tracker backtrack must be called after onFragLoaded to update the fragment entity state to BACKTRACKED
          // This happens after handleTransmuxComplete when the worker or progressive is disabled
          if (this.state === State.BACKTRACKING) {
            this.fragmentTracker.backtrack(frag, data);
            this.resetFragmentLoading(frag);
            return;
          }
        }

        // Pass through the whole payload; controllers not implementing progressive loading receive data from this callback
        this._handleFragmentLoadComplete(data);
      })
      .catch((reason) => {
        this.warn(reason);
        this.resetFragmentLoading(frag);
      });
  }

  protected flushMainBuffer(
    startOffset: number,
    endOffset: number,
    type: SourceBufferName | null = null
  ) {
    if (!(startOffset - endOffset)) {
      return;
    }
    // When alternate audio is playing, the audio-stream-controller is responsible for the audio buffer. Otherwise,
    // passing a null type flushes both buffers
    const flushScope: BufferFlushingData = { startOffset, endOffset, type };
    // Reset load errors on flush
    this.fragLoadError = 0;
    this.hls.trigger(Events.BUFFER_FLUSHING, flushScope);
  }

  protected _loadInitSegment(frag: Fragment) {
    this._doFragLoad(frag)
      .then((data) => {
        if (!data || this.fragContextChanged(frag) || !this.levels) {
          throw new Error('init load aborted');
        }

        return data;
      })
      .then((data: FragLoadedData) => {
        const { hls } = this;
        const { payload } = data;
        const decryptData = frag.decryptdata;

        // check to see if the payload needs to be decrypted
        if (
          payload &&
          payload.byteLength > 0 &&
          decryptData &&
          decryptData.key &&
          decryptData.iv &&
          decryptData.method === 'AES-128'
        ) {
          const startTime = self.performance.now();
          // decrypt the subtitles
          return this.decrypter
            .webCryptoDecrypt(
              new Uint8Array(payload),
              decryptData.key.buffer,
              decryptData.iv.buffer
            )
            .then((decryptedData) => {
              const endTime = self.performance.now();
              hls.trigger(Events.FRAG_DECRYPTED, {
                frag,
                payload: decryptedData,
                stats: {
                  tstart: startTime,
                  tdecrypt: endTime,
                },
              });
              data.payload = decryptedData;

              return data;
            });
        }

        return data;
      })
      .then((data: FragLoadedData) => {
        const { fragCurrent, hls, levels } = this;
        if (!levels) {
          throw new Error('init load aborted, missing levels');
        }

        const details = levels[frag.level].details as LevelDetails;
        console.assert(
          details,
          'Level details are defined when init segment is loaded'
        );

        const stats = frag.stats;
        this.state = State.IDLE;
        this.fragLoadError = 0;
        frag.data = new Uint8Array(data.payload);
        stats.parsing.start = stats.buffering.start = self.performance.now();
        stats.parsing.end = stats.buffering.end = self.performance.now();

        // Silence FRAG_BUFFERED event if fragCurrent is null
        if (data.frag === fragCurrent) {
          hls.trigger(Events.FRAG_BUFFERED, {
            stats,
            frag: fragCurrent,
            part: null,
            id: frag.type,
          });
        }
        this.tick();
      })
      .catch((reason) => {
        this.warn(reason);
        this.resetFragmentLoading(frag);
      });
  }

  protected fragContextChanged(frag: Fragment | null) {
    const { fragCurrent } = this;
    return (
      !frag ||
      !fragCurrent ||
      frag.level !== fragCurrent.level ||
      frag.sn !== fragCurrent.sn ||
      frag.urlId !== fragCurrent.urlId
    );
  }

  protected fragBufferedComplete(frag: Fragment, part: Part | null) {
    const media = this.mediaBuffer ? this.mediaBuffer : this.media;
    this.log(
      `Buffered ${frag.type} sn: ${frag.sn}${
        part ? ' part: ' + part.index : ''
      } of ${this.logPrefix === '[stream-controller]' ? 'level' : 'track'} ${
        frag.level
      } ${TimeRanges.toString(BufferHelper.getBuffered(media))}`
    );
    this.state = State.IDLE;
    this.tick();
  }

  protected _handleFragmentLoadComplete(fragLoadedEndData: PartsLoadedData) {
    const { transmuxer } = this;
    if (!transmuxer) {
      return;
    }
    const { frag, part, partsLoaded } = fragLoadedEndData;
    // If we did not load parts, or loaded all parts, we have complete (not partial) fragment data
    const complete =
      !partsLoaded ||
      partsLoaded.length === 0 ||
      partsLoaded.some((fragLoaded) => !fragLoaded);
    const chunkMeta = new ChunkMetadata(
      frag.level,
      frag.sn as number,
      frag.stats.chunkCount + 1,
      0,
      part ? part.index : -1,
      !complete
    );
    transmuxer.flush(chunkMeta);
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected _handleFragmentLoadProgress(frag: FragLoadedData) {}

  protected _doFragLoad(
    frag: Fragment,
    details?: LevelDetails,
    targetBufferTime: number | null = null,
    progressCallback?: FragmentLoadProgressCallback
  ): Promise<PartsLoadedData | FragLoadedData | null> {
    if (!this.levels) {
      throw new Error('frag load aborted, missing levels');
    }
    targetBufferTime = Math.max(frag.start, targetBufferTime || 0);
    if (this.config.lowLatencyMode && details) {
      const partList = details.partList;
      if (partList && progressCallback) {
        if (targetBufferTime > frag.end && details.fragmentHint) {
          frag = details.fragmentHint;
        }
        const partIndex = this.getNextPart(partList, frag, targetBufferTime);
        if (partIndex > -1) {
          const part = partList[partIndex];
          this.log(
            `Loading part sn: ${frag.sn} p: ${part.index} cc: ${
              frag.cc
            } of playlist [${details.startSN}-${
              details.endSN
            }] parts [0-${partIndex}-${partList.length - 1}] ${
              this.logPrefix === '[stream-controller]' ? 'level' : 'track'
            }: ${frag.level}, target: ${parseFloat(
              targetBufferTime.toFixed(3)
            )}`
          );
          this.nextLoadPosition = part.start + part.duration;
          this.state = State.FRAG_LOADING;
          this.hls.trigger(Events.FRAG_LOADING, {
            frag,
            part: partList[partIndex],
            targetBufferTime,
          });
          return this.doFragPartsLoad(
            frag,
            partList,
            partIndex,
            progressCallback
          ).catch((error: LoadError) => this.handleFragLoadError(error));
        } else if (
          !frag.url ||
          this.loadedEndOfParts(partList, targetBufferTime)
        ) {
          // Fragment hint has no parts
          return Promise.resolve(null);
        }
      }
    }

    this.log(
      `Loading fragment ${frag.sn} cc: ${frag.cc} ${
        details ? 'of [' + details.startSN + '-' + details.endSN + '] ' : ''
      }${this.logPrefix === '[stream-controller]' ? 'level' : 'track'}: ${
        frag.level
      }, target: ${parseFloat(targetBufferTime.toFixed(3))}`
    );
    // Don't update nextLoadPosition for fragments which are not buffered
    if (Number.isFinite(frag.sn as number) && !this.bitrateTest) {
      this.nextLoadPosition = frag.start + frag.duration;
    }
    this.state = State.FRAG_LOADING;
    this.hls.trigger(Events.FRAG_LOADING, { frag, targetBufferTime });

    return this.fragmentLoader
      .load(frag, progressCallback)
      .catch((error: LoadError) => this.handleFragLoadError(error));
  }

  private doFragPartsLoad(
    frag: Fragment,
    partList: Part[],
    partIndex: number,
    progressCallback: FragmentLoadProgressCallback
  ): Promise<PartsLoadedData | null> {
    return new Promise(
      (resolve: ResolveFragLoaded, reject: RejectFragLoaded) => {
        const partsLoaded: FragLoadedData[] = [];
        const loadPartIndex = (index: number) => {
          const part = partList[index];
          this.fragmentLoader
            .loadPart(frag, part, progressCallback)
            .then((partLoadedData: FragLoadedData) => {
              partsLoaded[part.index] = partLoadedData;
              const loadedPart = partLoadedData.part as Part;
              this.hls.trigger(Events.FRAG_LOADED, partLoadedData);
              const nextPart = partList[index + 1];
              if (nextPart && nextPart.fragment === frag) {
                loadPartIndex(index + 1);
              } else {
                return resolve({
                  frag,
                  part: loadedPart,
                  partsLoaded,
                });
              }
            })
            .catch(reject);
        };
        loadPartIndex(partIndex);
      }
    );
  }

  private handleFragLoadError({ data }: LoadError) {
    if (data && data.details === ErrorDetails.INTERNAL_ABORTED) {
      this.handleFragLoadAborted(data.frag, data.part);
    } else {
      this.hls.trigger(Events.ERROR, data as ErrorData);
    }
    return null;
  }

  protected _handleTransmuxerFlush(chunkMeta: ChunkMetadata) {
    const context = this.getCurrentContext(chunkMeta);
    if (!context || this.state !== State.PARSING) {
      if (!this.fragCurrent) {
        this.state = State.IDLE;
      }
      return;
    }
    const { frag, part, level } = context;
    const now = self.performance.now();
    frag.stats.parsing.end = now;
    if (part) {
      part.stats.parsing.end = now;
    }
    this.updateLevelTiming(frag, part, level, chunkMeta.partial);
  }

  protected getCurrentContext(
    chunkMeta: ChunkMetadata
  ): { frag: Fragment; part: Part | null; level: Level } | null {
    const { levels } = this;
    const { level: levelIndex, sn, part: partIndex } = chunkMeta;
    if (!levels || !levels[levelIndex]) {
      this.warn(
        `Levels object was unset while buffering fragment ${sn} of level ${levelIndex}. The current chunk will not be buffered.`
      );
      return null;
    }
    const level = levels[levelIndex];
    const part = partIndex > -1 ? getPartWith(level, sn, partIndex) : null;
    const frag = part
      ? part.fragment
      : getFragmentWithSN(level, sn, this.fragCurrent);
    if (!frag) {
      return null;
    }
    return { frag, part, level };
  }

  protected bufferFragmentData(
    data: RemuxedTrack,
    frag: Fragment,
    part: Part | null,
    chunkMeta: ChunkMetadata
  ) {
    if (!data || this.state !== State.PARSING) {
      return;
    }

    const { data1, data2 } = data;
    let buffer = data1;
    if (data1 && data2) {
      // Combine the moof + mdat so that we buffer with a single append
      buffer = appendUint8Array(data1, data2);
    }

    if (!buffer || !buffer.length) {
      return;
    }

    const segment: BufferAppendingData = {
      type: data.type,
      frag,
      part,
      chunkMeta,
      parent: frag.type,
      data: buffer,
    };
    this.hls.trigger(Events.BUFFER_APPENDING, segment);

    if (data.dropped && data.independent && !part) {
      // Clear buffer so that we reload previous segments sequentially if required
      this.flushBufferGap(frag);
    }
  }

  protected flushBufferGap(frag: Fragment) {
    const media = this.media;
    if (!media) {
      return;
    }
    // If currentTime is not buffered, clear the back buffer so that we can backtrack as much as needed
    if (!BufferHelper.isBuffered(media, media.currentTime)) {
      this.flushMainBuffer(0, frag.start);
      return;
    }
    // Remove back-buffer without interrupting playback to allow back tracking
    const currentTime = media.currentTime;
    const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
    const fragDuration = frag.duration;
    const segmentFraction = Math.min(
      this.config.maxFragLookUpTolerance * 2,
      fragDuration * 0.25
    );
    const start = Math.max(
      Math.min(frag.start - segmentFraction, bufferInfo.end - segmentFraction),
      currentTime + segmentFraction
    );
    if (frag.start - start > segmentFraction) {
      this.flushMainBuffer(start, frag.start);
    }
  }

  protected getFwdBufferInfo(
    bufferable: Bufferable,
    type: PlaylistLevelType
  ): {
    len: number;
    start: number;
    end: number;
    nextStart?: number;
  } | null {
    const { config } = this;
    const pos = this.getLoadPosition();
    if (!Number.isFinite(pos)) {
      return null;
    }
    const bufferInfo = BufferHelper.bufferInfo(
      bufferable,
      pos,
      config.maxBufferHole
    );
    // Workaround flaw in getting forward buffer when maxBufferHole is smaller than gap at current pos
    if (bufferInfo.len === 0 && bufferInfo.nextStart !== undefined) {
      const bufferedFragAtPos = this.fragmentTracker.getBufferedFrag(pos, type);
      if (bufferedFragAtPos && bufferInfo.nextStart < bufferedFragAtPos.end) {
        return BufferHelper.bufferInfo(
          bufferable,
          pos,
          Math.max(bufferInfo.nextStart, config.maxBufferHole)
        );
      }
    }
    return bufferInfo;
  }

  protected getMaxBufferLength(levelBitrate?: number): number {
    const { config } = this;
    let maxBufLen;
    if (levelBitrate) {
      maxBufLen = Math.max(
        (8 * config.maxBufferSize) / levelBitrate,
        config.maxBufferLength
      );
    } else {
      maxBufLen = config.maxBufferLength;
    }
    return Math.min(maxBufLen, config.maxMaxBufferLength);
  }

  protected reduceMaxBufferLength(threshold?: number) {
    const config = this.config;
    const minLength = threshold || config.maxBufferLength;
    if (config.maxMaxBufferLength >= minLength) {
      // reduce max buffer length as it might be too high. we do this to avoid loop flushing ...
      config.maxMaxBufferLength /= 2;
      this.warn(`Reduce max buffer length to ${config.maxMaxBufferLength}s`);
      return true;
    }
    return false;
  }

  protected getNextFragment(
    pos: number,
    levelDetails: LevelDetails
  ): Fragment | null {
    const fragments = levelDetails.fragments;
    const fragLen = fragments.length;

    if (!fragLen) {
      return null;
    }

    // find fragment index, contiguous with end of buffer position
    const { config } = this;
    const start = fragments[0].start;
    let frag;

    if (levelDetails.live) {
      const initialLiveManifestSize = config.initialLiveManifestSize;
      if (fragLen < initialLiveManifestSize) {
        this.warn(
          `Not enough fragments to start playback (have: ${fragLen}, need: ${initialLiveManifestSize})`
        );
        return null;
      }
      // The real fragment start times for a live stream are only known after the PTS range for that level is known.
      // In order to discover the range, we load the best matching fragment for that level and demux it.
      // Do not load using live logic if the starting frag is requested - we want to use getFragmentAtPosition() so that
      // we get the fragment matching that start time
      if (
        !levelDetails.PTSKnown &&
        !this.startFragRequested &&
        this.startPosition === -1
      ) {
        frag = this.getInitialLiveFragment(levelDetails, fragments);
        this.startPosition = frag
          ? this.hls.liveSyncPosition || frag.start
          : pos;
      }
    } else if (pos <= start) {
      // VoD playlist: if loadPosition before start of playlist, load first fragment
      frag = fragments[0];
    }

    // If we haven't run into any special cases already, just load the fragment most closely matching the requested position
    if (!frag) {
      const end = config.lowLatencyMode
        ? levelDetails.partEnd
        : levelDetails.fragmentEnd;
      frag = this.getFragmentAtPosition(pos, end, levelDetails);
    }

    // If an initSegment is present, it must be buffered first
    if (frag?.initSegment && !frag?.initSegment.data && !this.bitrateTest) {
      frag = frag.initSegment;
    }

    return frag;
  }

  getNextPart(
    partList: Part[],
    frag: Fragment,
    targetBufferTime: number
  ): number {
    let nextPart = -1;
    let contiguous = false;
    let independentAttrOmitted = true;
    for (let i = 0, len = partList.length; i < len; i++) {
      const part = partList[i];
      independentAttrOmitted = independentAttrOmitted && !part.independent;
      if (nextPart > -1 && targetBufferTime < part.start) {
        break;
      }
      const loaded = part.loaded;
      if (
        !loaded &&
        (contiguous || part.independent || independentAttrOmitted) &&
        part.fragment === frag
      ) {
        nextPart = i;
      }
      contiguous = loaded;
    }
    return nextPart;
  }

  private loadedEndOfParts(
    partList: Part[],
    targetBufferTime: number
  ): boolean {
    const lastPart = partList[partList.length - 1];
    return lastPart && targetBufferTime > lastPart.start && lastPart.loaded;
  }

  /*
   This method is used find the best matching first fragment for a live playlist. This fragment is used to calculate the
   "sliding" of the playlist, which is its offset from the start of playback. After sliding we can compute the real
   start and end times for each fragment in the playlist (after which this method will not need to be called).
  */
  protected getInitialLiveFragment(
    levelDetails: LevelDetails,
    fragments: Array<Fragment>
  ): Fragment | null {
    const fragPrevious = this.fragPrevious;
    let frag: Fragment | null = null;
    if (fragPrevious) {
      if (levelDetails.hasProgramDateTime) {
        // Prefer using PDT, because it can be accurate enough to choose the correct fragment without knowing the level sliding
        this.log(
          `Live playlist, switching playlist, load frag with same PDT: ${fragPrevious.programDateTime}`
        );
        frag = findFragmentByPDT(
          fragments,
          fragPrevious.endProgramDateTime,
          this.config.maxFragLookUpTolerance
        );
      }
      if (!frag) {
        // SN does not need to be accurate between renditions, but depending on the packaging it may be so.
        const targetSN = (fragPrevious.sn as number) + 1;
        if (
          targetSN >= levelDetails.startSN &&
          targetSN <= levelDetails.endSN
        ) {
          const fragNext = fragments[targetSN - levelDetails.startSN];
          // Ensure that we're staying within the continuity range, since PTS resets upon a new range
          if (fragPrevious.cc === fragNext.cc) {
            frag = fragNext;
            this.log(
              `Live playlist, switching playlist, load frag with next SN: ${
                frag!.sn
              }`
            );
          }
        }
        // It's important to stay within the continuity range if available; otherwise the fragments in the playlist
        // will have the wrong start times
        if (!frag) {
          frag = findFragWithCC(fragments, fragPrevious.cc);
          if (frag) {
            this.log(
              `Live playlist, switching playlist, load frag with same CC: ${frag.sn}`
            );
          }
        }
      }
    } else {
      // Find a new start fragment when fragPrevious is null
      const liveStart = this.hls.liveSyncPosition;
      if (liveStart !== null) {
        frag = this.getFragmentAtPosition(
          liveStart,
          this.bitrateTest ? levelDetails.fragmentEnd : levelDetails.edge,
          levelDetails
        );
      }
    }

    return frag;
  }

  /*
  This method finds the best matching fragment given the provided position.
   */
  protected getFragmentAtPosition(
    bufferEnd: number,
    end: number,
    levelDetails: LevelDetails
  ): Fragment | null {
    const { config, fragPrevious } = this;
    let { fragments, endSN } = levelDetails;
    const { fragmentHint } = levelDetails;
    const tolerance = config.maxFragLookUpTolerance;

    const loadingParts = !!(
      config.lowLatencyMode &&
      levelDetails.partList &&
      fragmentHint
    );
    if (loadingParts && fragmentHint && !this.bitrateTest) {
      // Include incomplete fragment with parts at end
      fragments = fragments.concat(fragmentHint);
      endSN = fragmentHint.sn as number;
    }

    let frag;
    if (bufferEnd < end) {
      const lookupTolerance = bufferEnd > end - tolerance ? 0 : tolerance;
      // Remove the tolerance if it would put the bufferEnd past the actual end of stream
      // Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE)
      frag = findFragmentByPTS(
        fragPrevious,
        fragments,
        bufferEnd,
        lookupTolerance
      );
    } else {
      // reach end of playlist
      frag = fragments[fragments.length - 1];
    }

    if (frag) {
      const curSNIdx = frag.sn - levelDetails.startSN;
      const sameLevel = fragPrevious && frag.level === fragPrevious.level;
      const nextFrag = fragments[curSNIdx + 1];
      const fragState = this.fragmentTracker.getState(frag);
      if (fragState === FragmentState.BACKTRACKED) {
        frag = null;
        let i = curSNIdx;
        while (
          fragments[i] &&
          this.fragmentTracker.getState(fragments[i]) ===
            FragmentState.BACKTRACKED
        ) {
          // When fragPrevious is null, backtrack to first the first fragment is not BACKTRACKED for loading
          // When fragPrevious is set, we want the first BACKTRACKED fragment for parsing and buffering
          if (!fragPrevious) {
            frag = fragments[--i];
          } else {
            frag = fragments[i--];
          }
        }
        if (!frag) {
          frag = nextFrag;
        }
      } else if (fragPrevious && frag.sn === fragPrevious.sn && !loadingParts) {
        // Force the next fragment to load if the previous one was already selected. This can occasionally happen with
        // non-uniform fragment durations
        if (sameLevel) {
          if (
            frag.sn < endSN &&
            this.fragmentTracker.getState(nextFrag) !== FragmentState.OK
          ) {
            this.log(
              `SN ${frag.sn} just loaded, load next one: ${nextFrag.sn}`
            );
            frag = nextFrag;
          } else {
            frag = null;
          }
        }
      }
    }
    return frag;
  }

  protected synchronizeToLiveEdge(levelDetails: LevelDetails) {
    const { config, media } = this;
    if (!media) {
      return;
    }
    const liveSyncPosition = this.hls.liveSyncPosition;
    const currentTime = media.currentTime;
    const start = levelDetails.fragments[0].start;
    const end = levelDetails.edge;
    const withinSlidingWindow =
      currentTime >= start - config.maxFragLookUpTolerance &&
      currentTime <= end;
    // Continue if we can seek forward to sync position or if current time is outside of sliding window
    if (
      liveSyncPosition !== null &&
      media.duration > liveSyncPosition &&
      (currentTime < liveSyncPosition || !withinSlidingWindow)
    ) {
      // Continue if buffer is starving or if current time is behind max latency
      const maxLatency =
        config.liveMaxLatencyDuration !== undefined
          ? config.liveMaxLatencyDuration
          : config.liveMaxLatencyDurationCount * levelDetails.targetduration;
      if (
        (!withinSlidingWindow && media.readyState < 4) ||
        currentTime < end - maxLatency
      ) {
        if (!this.loadedmetadata) {
          this.nextLoadPosition = liveSyncPosition;
        }
        // Only seek if ready and there is not a significant forward buffer available for playback
        if (media.readyState) {
          this.warn(
            `Playback: ${currentTime.toFixed(
              3
            )} is located too far from the end of live sliding playlist: ${end}, reset currentTime to : ${liveSyncPosition.toFixed(
              3
            )}`
          );
          media.currentTime = liveSyncPosition;
        }
      }
    }
  }

  protected alignPlaylists(
    details: LevelDetails,
    previousDetails?: LevelDetails
  ): number {
    const { levels, levelLastLoaded, fragPrevious } = this;
    const lastLevel: Level | null =
      levelLastLoaded !== null ? levels![levelLastLoaded] : null;

    // FIXME: If not for `shouldAlignOnDiscontinuities` requiring fragPrevious.cc,
    //  this could all go in level-helper mergeDetails()
    const length = details.fragments.length;
    if (!length) {
      this.warn(`No fragments in live playlist`);
      return 0;
    }
    const slidingStart = details.fragments[0].start;
    const firstLevelLoad = !previousDetails;
    const aligned = details.alignedSliding && Number.isFinite(slidingStart);
    if (firstLevelLoad || (!aligned && !slidingStart)) {
      alignStream(fragPrevious, lastLevel, details);
      const alignedSlidingStart = details.fragments[0].start;
      this.log(
        `Live playlist sliding: ${alignedSlidingStart.toFixed(2)} start-sn: ${
          previousDetails ? previousDetails.startSN : 'na'
        }->${details.startSN} prev-sn: ${
          fragPrevious ? fragPrevious.sn : 'na'
        } fragments: ${length}`
      );
      return alignedSlidingStart;
    }
    return slidingStart;
  }

  protected waitForCdnTuneIn(details: LevelDetails) {
    // Wait for Low-Latency CDN Tune-in to get an updated playlist
    const advancePartLimit = 3;
    return (
      details.live &&
      details.canBlockReload &&
      details.tuneInGoal >
        Math.max(details.partHoldBack, details.partTarget * advancePartLimit)
    );
  }

  protected setStartPosition(details: LevelDetails, sliding: number) {
    // compute start position if set to -1. use it straight away if value is defined
    let startPosition = this.startPosition;
    if (startPosition < sliding) {
      startPosition = -1;
    }
    if (startPosition === -1 || this.lastCurrentTime === -1) {
      // first, check if start time offset has been set in playlist, if yes, use this value
      const startTimeOffset = details.startTimeOffset!;
      if (Number.isFinite(startTimeOffset)) {
        startPosition = sliding + startTimeOffset;
        if (startTimeOffset < 0) {
          startPosition += details.totalduration;
        }
        startPosition = Math.min(
          Math.max(sliding, startPosition),
          sliding + details.totalduration
        );
        this.log(
          `Start time offset ${startTimeOffset} found in playlist, adjust startPosition to ${startPosition}`
        );
        this.startPosition = startPosition;
      } else if (details.live) {
        // Leave this.startPosition at -1, so that we can use `getInitialLiveFragment` logic when startPosition has
        // not been specified via the config or an as an argument to startLoad (#3736).
        startPosition = this.hls.liveSyncPosition || sliding;
      } else {
        this.startPosition = startPosition = 0;
      }
      this.lastCurrentTime = startPosition;
    }
    this.nextLoadPosition = startPosition;
  }

  protected getLoadPosition(): number {
    const { media } = this;
    // if we have not yet loaded any fragment, start loading from start position
    let pos = 0;
    if (this.loadedmetadata && media) {
      pos = media.currentTime;
    } else if (this.nextLoadPosition) {
      pos = this.nextLoadPosition;
    }

    return pos;
  }

  private handleFragLoadAborted(frag: Fragment, part: Part | undefined) {
    if (this.transmuxer && frag.sn !== 'initSegment' && frag.stats.aborted) {
      this.warn(
        `Fragment ${frag.sn}${part ? ' part' + part.index : ''} of level ${
          frag.level
        } was aborted`
      );
      this.resetFragmentLoading(frag);
    }
  }

  protected resetFragmentLoading(frag: Fragment) {
    if (!this.fragCurrent || !this.fragContextChanged(frag)) {
      this.state = State.IDLE;
    }
  }

  protected onFragmentOrKeyLoadError(
    filterType: PlaylistLevelType,
    data: ErrorData
  ) {
    if (data.fatal) {
      return;
    }
    const frag = data.frag;
    // Handle frag error related to caller's filterType
    if (!frag || frag.type !== filterType) {
      return;
    }
    const fragCurrent = this.fragCurrent;
    console.assert(
      fragCurrent &&
        frag.sn === fragCurrent.sn &&
        frag.level === fragCurrent.level &&
        frag.urlId === fragCurrent.urlId,
      'Frag load error must match current frag to retry'
    );
    const config = this.config;
    // keep retrying until the limit will be reached
    if (this.fragLoadError + 1 <= config.fragLoadingMaxRetry) {
      if (this.resetLiveStartWhenNotLoaded(frag.level)) {
        return;
      }
      // exponential backoff capped to config.fragLoadingMaxRetryTimeout
      const delay = Math.min(
        Math.pow(2, this.fragLoadError) * config.fragLoadingRetryDelay,
        config.fragLoadingMaxRetryTimeout
      );
      this.warn(
        `Fragment ${frag.sn} of ${filterType} ${frag.level} failed to load, retrying in ${delay}ms`
      );
      this.retryDate = self.performance.now() + delay;
      this.fragLoadError++;
      this.state = State.FRAG_LOADING_WAITING_RETRY;
    } else if (data.levelRetry) {
      if (filterType === PlaylistLevelType.AUDIO) {
        // Reset current fragment since audio track audio is essential and may not have a fail-over track
        this.fragCurrent = null;
      }
      // Fragment errors that result in a level switch or redundant fail-over
      // should reset the stream controller state to idle
      this.fragLoadError = 0;
      this.state = State.IDLE;
    } else {
      logger.error(
        `${data.details} reaches max retry, redispatch as fatal ...`
      );
      // switch error to fatal
      data.fatal = true;
      this.hls.stopLoad();
      this.state = State.ERROR;
    }
  }

  protected afterBufferFlushed(
    media: Bufferable,
    bufferType: SourceBufferName,
    playlistType: PlaylistLevelType
  ) {
    if (!media) {
      return;
    }
    // After successful buffer flushing, filter flushed fragments from bufferedFrags use mediaBuffered instead of media
    // (so that we will check against video.buffered ranges in case of alt audio track)
    const bufferedTimeRanges = BufferHelper.getBuffered(media);
    this.fragmentTracker.detectEvictedFragments(
      bufferType,
      bufferedTimeRanges,
      playlistType
    );
    if (this.state === State.ENDED) {
      this.resetLoadingState();
    }
  }

  protected resetLoadingState() {
    this.fragCurrent = null;
    this.fragPrevious = null;
    this.state = State.IDLE;
  }

  protected resetLiveStartWhenNotLoaded(level: number): boolean {
    // if loadedmetadata is not set, it means that we are emergency switch down on first frag
    // in that case, reset startFragRequested flag
    if (!this.loadedmetadata) {
      this.startFragRequested = false;
      const details = this.levels ? this.levels[level].details : null;
      if (details?.live) {
        // We can't afford to retry after a delay in a live scenario. Update the start position and return to IDLE.
        this.startPosition = -1;
        this.setStartPosition(details, 0);
        this.resetLoadingState();
        return true;
      }
      this.nextLoadPosition = this.startPosition;
    }
    return false;
  }

  private updateLevelTiming(
    frag: Fragment,
    part: Part | null,
    level: Level,
    partial: boolean
  ) {
    const details = level.details as LevelDetails;
    console.assert(!!details, 'level.details must be defined');
    const parsed = Object.keys(frag.elementaryStreams).reduce(
      (result, type) => {
        const info = frag.elementaryStreams[type];
        if (info) {
          const parsedDuration = info.endPTS - info.startPTS;
          if (parsedDuration <= 0) {
            // Destroy the transmuxer after it's next time offset failed to advance because duration was <= 0.
            // The new transmuxer will be configured with a time offset matching the next fragment start,
            // preventing the timeline from shifting.
            this.warn(
              `Could not parse fragment ${frag.sn} ${type} duration reliably (${parsedDuration}) resetting transmuxer to fallback to playlist timing`
            );
            this.resetTransmuxer();
            return result || false;
          }
          const drift = partial
            ? 0
            : updateFragPTSDTS(
                details,
                frag,
                info.startPTS,
                info.endPTS,
                info.startDTS,
                info.endDTS
              );
          this.hls.trigger(Events.LEVEL_PTS_UPDATED, {
            details,
            level,
            drift,
            type,
            frag,
            start: info.startPTS,
            end: info.endPTS,
          });
          return true;
        }
        return result;
      },
      false
    );
    if (parsed) {
      this.state = State.PARSED;
      this.hls.trigger(Events.FRAG_PARSED, { frag, part });
    } else {
      this.resetLoadingState();
    }
  }

  protected resetTransmuxer() {
    if (this.transmuxer) {
      this.transmuxer.destroy();
      this.transmuxer = null;
    }
  }

  set state(nextState) {
    const previousState = this._state;
    if (previousState !== nextState) {
      this._state = nextState;
      this.log(`${previousState}->${nextState}`);
    }
  }

  get state() {
    return this._state;
  }
}