UNPKG

rx-player

Version:
1,948 lines 66.7 kB
/**
 * Copyright 2015 CANAL+ Group
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * This file is used to abstract the notion of text, audio and video tracks
 * switching for an easier API management.
 */

import config from "../../config";
import type { IAdaptationChoice, IRepresentationsChoice } from "../../core/types";
import { MediaError } from "../../errors";
import log from "../../log";
import type {
  IAdaptationMetadata,
  IManifestMetadata,
  IPeriodMetadata,
} from "../../manifest";
import {
  getSupportedAdaptations,
  isRepresentationPlayable,
  toAudioTrack,
  toTextTrack,
  toVideoTrack,
} from "../../manifest";
import type {
  IAudioRepresentationsSwitchingMode,
  IAudioTrack,
  IAudioTrackSwitchingMode,
  ITrackUpdateEventPayload,
  IAvailableAudioTrack,
  IAvailableTextTrack,
  IAvailableVideoTrack,
  IBrokenRepresentationsLockContext,
  IPeriod,
  ITextTrack,
  IVideoRepresentationsSwitchingMode,
  IVideoTrack,
  IVideoTrackSwitchingMode,
  IPlayerError,
  ITrackType,
  INoPlayableTrackEventPayload,
} from "../../public_types";
import arrayFind from "../../utils/array_find";
import assert, { assertUnreachable } from "../../utils/assert";
import EventEmitter from "../../utils/event_emitter";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import objectAssign from "../../utils/object_assign";
import SharedReference from "../../utils/reference";
import TrackDispatcher from "./track_dispatcher";

/**
 * Class helping with the management of the audio, video and text tracks and
 * qualities.
 *
 * The `TracksStore` allows to choose a track and qualities for different types
 * of media through a simpler API.
 *
 * @class TracksStore
 */
export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
  /**
   * Store track selection information, per Period.
   * Sorted by Period's start time ascending
   */
  private _storedPeriodInfo: ITSPeriodObject[];

  /**
   * If `true`, the current `TracksStore` instance has been disposed. It
   * shouldn't perform side-effects anymore.
   */
  private _isDisposed: boolean;

  /**
   * Period information that was before in `_storedPeriodInfo` but has since
   * been removed is added to the `_cachedPeriodInfo` cache as a weak reference.
   *
   * This allows to still retrieve old track information for Periods which are
   * for example not in the Manifest anymore as long as the same Period's
   * reference is still kept.
   */
  private _cachedPeriodInfo: WeakMap<IPeriodMetadata, ITSPeriodObject>;

  /** Tells if trick mode has been enabled by the RxPlayer user */
  private _isTrickModeTrackEnabled: boolean;

  /**
   * In absence of another setting, this is the default "switching mode" for the
   * audio track.
   * See type documentation.
   */
  private _defaultAudioTrackSwitchingMode: IAudioTrackSwitchingMode;

  /** Specifies the behavior when audio or video tracks are not playable. */
  private onTracksNotPlayableForType: {
    audio: "error" | "continue";
    video: "error" | "continue";
    text: "error" | "continue";
  };

  constructor(args: {
    preferTrickModeTracks: boolean;
    defaultAudioTrackSwitchingMode: IAudioTrackSwitchingMode | undefined;
    onTracksNotPlayableForType: {
      audio: "error" | "continue";
      video: "error" | "continue";
      text: "error" | "continue";
    };
  }) {
    super();
    this._storedPeriodInfo = [];
    this._isDisposed = false;
    this._cachedPeriodInfo = new WeakMap();
    this._isTrickModeTrackEnabled = args.preferTrickModeTracks;
    this._defaultAudioTrackSwitchingMode =
      args.defaultAudioTrackSwitchingMode ??
      config.getCurrent().DEFAULT_AUDIO_TRACK_SWITCHING_MODE;
    this.onTracksNotPlayableForType = args.onTracksNotPlayableForType;
  }

  /**
   * Return Array of Period information, to allow an outside application to
   * modify the track of any Period.
   * @returns {Array.<Object>}
   */
  public getAvailablePeriods(): IPeriod[] {
    return this._storedPeriodInfo.reduce((acc: IPeriod[], p) => {
      if (p.isPeriodAdvertised) {
        acc.push(toExposedPeriod(p.period));
      }
      return acc;
    }, []);
  }

  /**
   * Callack that needs to be called as codec support is either first known or
   * updated on the Manifest.
   */
  public onManifestCodecSupportUpdate(): void {
    this._selectInitialTrackIfNeeded();
  }

  /**
   * Check that a period has a supported track for the given track type.
   * Trigger an error with `MANIFEST_INCOMPATIBLE_CODECS_ERROR` if no codecs are
   * supported and `onTracksNotPlayableForType[ttype]` is "error".
   *
   * @param period - The period to check
   * @param ttype - The track type.
   */
  private checkPeriodHasSupportedTrack(
    period: IPeriodMetadata,
    ttype: "video" | "audio",
  ): void {
    const adaptationsForType = period.adaptations[ttype];
    const audioVideoAdaptations = [
      ...(period.adaptations.audio ?? []),
      ...(period.adaptations.video ?? []),
    ];

    const periodHasAdaptationForType =
      adaptationsForType !== undefined && adaptationsForType.length > 0;
    if (!periodHasAdaptationForType) {
      // The content does not have audio/video media, e.g. it's an audio only content.
      // there is no track of this type to select.
      return;
    }

    const isTypeUnsupported = adaptationsForType.every(
      (adapt) => adapt.supportStatus.hasSupportedCodec === false,
    );
    const isAudioAndVideoUnsupported = audioVideoAdaptations.every(
      (adapt) => adapt.supportStatus.hasSupportedCodec === false,
    );

    if (isTypeUnsupported) {
      const err = new MediaError(
        "MANIFEST_INCOMPATIBLE_CODECS_ERROR",
        "No supported " + ttype + " adaptations",
        { tracks: undefined },
      );
      if (isAudioAndVideoUnsupported) {
        // Both video and audio are unsupported or the content is audio/video only.
        // The content cannot be played without this media type as there is no other media type.
        this.trigger("error", err);
        // The previous event trigger could have had side-effects, so we
        // re-check if we're still mostly in the same state
        if (this._isDisposed) {
          return; // Someone disposed the `TracksStore` on the previous side-effect
        }
        this.dispose();
        return;
      } else if (this.onTracksNotPlayableForType[ttype] === "continue") {
        // audio or video is not playable, but let's continue the playback without audio
        // or without video because of the option was set to "continue".
        this.trigger("warning", err);
      } else {
        this.trigger("error", err);
        // The previous event trigger could have had side-effects, so we
        // re-check if we're still mostly in the same state
        if (this._isDisposed) {
          return; // Someone disposed the `TracksStore` on the previous side-effect
        }
        this.dispose();
        return;
      }
    }
  }

  /**
   * Update the list of Periods handled by the TracksStore and make a
   * track choice decision for each of them.
   * @param {Object} manifest - The new Manifest object
   */
  public onManifestUpdate(manifest: IManifestMetadata) {
    const { periods } = manifest;

    // We assume that they are always sorted chronologically
    // In dev mode, perform a runtime check that this is the case
    if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) {
      for (let i = 1; i < periods.length; i++) {
        assert(periods[i - 1].start <= periods[i].start);
      }
    }

    /** Periods which have just been added. */
    const addedPeriods: ITSPeriodObject[] = [];

    for (const period of periods) {
      ["audio" as const, "video" as const].forEach((ttype) => {
        this.checkPeriodHasSupportedTrack(period, ttype);
      });
      if (this._isDisposed) {
        return;
      }
    }

    let newPListIdx = 0;
    for (let i = 0; i < this._storedPeriodInfo.length; i++) {
      const oldPeriod = this._storedPeriodInfo[i].period;
      const newPeriod = periods[newPListIdx];
      if (newPeriod === undefined) {
        // We reached the end of the new Periods, remove remaining old Periods
        for (let j = this._storedPeriodInfo.length - 1; j >= i; j--) {
          this._storedPeriodInfo[j].inManifest = false;
          if (isPeriodItemRemovable(this._storedPeriodInfo[j])) {
            this._removePeriodObject(j);
          }
        }
      } else if (oldPeriod === newPeriod) {
        newPListIdx++;

        this.resetSelectedTrackIfNotAvailableAnymore(
          this._storedPeriodInfo[i],
          newPeriod,
          "video",
        );
        this.resetSelectedTrackIfNotAvailableAnymore(
          this._storedPeriodInfo[i],
          newPeriod,
          "audio",
        );
        this.resetSelectedTrackIfNotAvailableAnymore(
          this._storedPeriodInfo[i],
          newPeriod,
          "text",
        );

        // (If not, what do?)
      } else if (oldPeriod.start <= newPeriod.start) {
        // This old Period does not exist anymore.
        this._storedPeriodInfo[i].inManifest = false;
        if (isPeriodItemRemovable(this._storedPeriodInfo[i])) {
          this._removePeriodObject(i);
          i--;
        }
      } else {
        const newPeriodInfo = generatePeriodInfo(newPeriod, true);
        // oldPeriod.start > newPeriod.start: insert newPeriod before
        this._storedPeriodInfo.splice(i, 0, newPeriodInfo);
        addedPeriods.push(newPeriodInfo);
        newPListIdx++;
        // Note: we don't increment `i` on purpose here, as we want to check the
        // same oldPeriod at the next loop iteration
      }
    }

    if (newPListIdx < periods.length) {
      // Add further new Period
      const periodsToAdd = periods
        .slice(newPListIdx)
        .map((p) => generatePeriodInfo(p, true));
      this._storedPeriodInfo.push(...periodsToAdd);
      addedPeriods.push(...periodsToAdd);
    }

    for (const storedPeriodInfo of this._storedPeriodInfo) {
      storedPeriodInfo.audio.dispatcher?.refresh();
      storedPeriodInfo.video.dispatcher?.refresh();
      storedPeriodInfo.text.dispatcher?.refresh();
    }
  }

  public onDecipherabilityUpdates() {
    for (const storedPeriodInfo of this._storedPeriodInfo) {
      storedPeriodInfo.audio.dispatcher?.refresh();
      storedPeriodInfo.video.dispatcher?.refresh();
      storedPeriodInfo.text.dispatcher?.refresh();
    }
  }

  /**
   * Reset the stored settings if the track is not available anymore in
   * the new manifest.
   * @param periodInfo
   * @param newPeriod
   * @param type
   * @returns
   */
  private resetSelectedTrackIfNotAvailableAnymore(
    periodInfo: ITSPeriodObject,
    newPeriod: IPeriodMetadata,
    type: ITrackType,
  ) {
    const wantedTrack = periodInfo[type].storedSettings;
    if (isNullOrUndefined(wantedTrack)) {
      return;
    }
    const supportedAdaptations = getSupportedAdaptations(newPeriod, type);
    const stillHere = supportedAdaptations.some(
      (a) => a.id === wantedTrack.adaptation.id,
    );

    if (stillHere) {
      // The track still exists, it don't need to be resetted.
      return;
    }

    log.warn(`Track`, `Chosen ${type} Adaptation not available anymore`);

    if (type === "video") {
      periodInfo.video.storedSettings = this.getDefaultStoredSettingsForAdaptation(
        supportedAdaptations,
        "video",
      );
    } else if (type === "audio") {
      periodInfo.audio.storedSettings = this.getDefaultStoredSettingsForAdaptation(
        supportedAdaptations,
        "audio",
      );
    } else if (type === "text") {
      periodInfo.text.storedSettings = this.getDefaultStoredSettingsForAdaptation(
        supportedAdaptations,
        "text",
      );
    }

    this.trigger("trackUpdate", {
      period: toExposedPeriod(newPeriod),
      trackType: type,
      reason: "missing",
    });

    // The previous event trigger could have had side-effects, so we
    // re-check if we're still mostly in the same state
    if (this._isDisposed) {
      return; // The current TracksStore is disposed, we can abort
    }

    const periodItem = getPeriodItem(this._storedPeriodInfo, periodInfo.period.id);
    if (
      periodItem !== undefined &&
      periodItem.isPeriodAdvertised &&
      periodItem[type].storedSettings === null
    ) {
      periodItem[type].dispatcher?.updateTrack(null);
    }
  }

  // Overloading signatures
  private getDefaultStoredSettingsForAdaptation(
    supportedAdaptations: IAdaptationMetadata[],
    type: "text",
  ): null;
  private getDefaultStoredSettingsForAdaptation(
    supportedAdaptations: IAdaptationMetadata[],
    type: "audio",
  ): IAudioStoredSettings | null;
  private getDefaultStoredSettingsForAdaptation(
    supportedAdaptations: IAdaptationMetadata[],
    type: "video",
  ): IVideoStoredSettings | null;
  private getDefaultStoredSettingsForAdaptation<B extends ITrackType>(
    supportedAdaptations: IAdaptationMetadata[],
    type: B,
  ): IVideoStoredSettings | IAudioStoredSettings | null {
    const { DEFAULT_VIDEO_TRACK_SWITCHING_MODE } = config.getCurrent();

    if (type === "text") {
      return null;
    }
    if (supportedAdaptations.length === 0) {
      return null;
    }
    switch (type) {
      case "text":
        return null;

      case "video": {
        const adaptationBase = supportedAdaptations[0];
        const adaptation = getRightVideoTrack(
          adaptationBase,
          this._isTrickModeTrackEnabled,
        );
        const lockedRepresentations = new SharedReference<IRepresentationsChoice | null>(
          null,
        );
        return {
          adaptationBase,
          adaptation,
          switchingMode: DEFAULT_VIDEO_TRACK_SWITCHING_MODE,
          lockedRepresentations,
        };
      }

      case "audio": {
        return {
          adaptation: supportedAdaptations[0],
          switchingMode: this._defaultAudioTrackSwitchingMode,
          lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
        };
      }

      default: {
        assertUnreachable(type);
      }
    }
  }

  /**
   * Add shared reference to choose Adaptation for new "audio", "video" or
   * "text" Period.
   *
   * Note that such reference has to be removed through `removeTrackReference`
   * so ressources can be freed.
   * @param {string} bufferType - The concerned buffer type
   * @param {Period} period - The concerned Period.
   * @param {Object} adaptationRef - A reference through which
   * the choice will be given.
   */
  public addTrackReference(
    bufferType: "audio" | "text" | "video",
    period: IPeriodMetadata,
    adaptationRef: SharedReference<IAdaptationChoice | null | undefined>,
  ): void {
    log.debug("Track", "Adding Track Reference", { bufferType, periodId: period.id });
    let periodObj = getPeriodItem(this._storedPeriodInfo, period.id);
    if (periodObj === undefined) {
      // The Period has not yet been added.
      periodObj = generatePeriodInfo(period, false);
      let found = false;
      for (let i = 0; i < this._storedPeriodInfo.length; i++) {
        if (this._storedPeriodInfo[i].period.start > period.start) {
          this._storedPeriodInfo.splice(i, 0, periodObj);
          found = true;
        }
      }
      if (!found) {
        this._storedPeriodInfo.push(periodObj);
      }
    }

    if (periodObj[bufferType].dispatcher !== null) {
      log.error(`Track`, `Subject already added for type and Period`, {
        bufferType,
        periodId: period.id,
      });
      periodObj[bufferType].dispatcher.dispose();
    }

    const dispatcher = new TrackDispatcher(adaptationRef);
    periodObj[bufferType].dispatcher = dispatcher;

    dispatcher.addEventListener("noPlayableRepresentation", () => {
      this.handleMissingOrUnplayableTrack(period, bufferType, false);
    });
    dispatcher.addEventListener("noPlayableLockedRepresentation", () => {
      // TODO check that it doesn't already lead to segment loading or MediaSource
      // reloading
      if (periodObj === undefined) {
        return;
      }
      this.unlockVideoRepresentations(periodObj);
      this.trigger("brokenRepresentationsLock", {
        period: { id: period.id, start: period.start, end: period.end },
        trackType: bufferType,
      });
    });

    this._selectInitialTrackIfNeeded();

    // Ensure `newAvailablePeriods` is sent
    if (this._shouldAdvertisePeriod(periodObj)) {
      periodObj.isPeriodAdvertised = true;
      this.trigger("newAvailablePeriods", [
        {
          id: period.id,
          start: period.start,
          end: period.end,
        },
      ]);

      if (this._isDisposed) {
        return;
      }
    }

    // Ensure the initial track is set for each type now)
    const trackTypes: ITrackType[] = ["audio", "video", "text"];
    for (const ttype of trackTypes) {
      const trackObj = periodObj[ttype];
      if (
        periodObj.isPeriodAdvertised &&
        trackObj.dispatcher !== null &&
        !trackObj.dispatcher.hasSetTrack() &&
        trackObj.storedSettings !== undefined
      ) {
        trackObj.dispatcher.updateTrack(trackObj.storedSettings);
      }
      if (this._isDisposed) {
        return;
      }
    }
  }

  /**
   * Throws an error if neither audio nor video tracks are selected for the given period.
   *
   * This indicates that the application or user has not selected any tracks for playback,
   * which is considered an invalid state.
   *
   * @param {object} period - The period to check for selected tracks.
   * @returns {void}
   * @throws {erorr} If no audio or video tracks are set for the period.
   */
  private throwIfTracksAreNotSetForPeriod(period: IPeriodMetadata): void {
    const periodItem = getPeriodItem(this._storedPeriodInfo, period.id);
    if (periodItem !== undefined) {
      const hasNoTrackAtAll = ["audio" as const, "video" as const].every(
        (ttype) => periodItem[ttype].storedSettings === null,
      );
      if (hasNoTrackAtAll) {
        const err = new MediaError(
          "NO_AUDIO_VIDEO_TRACKS",
          "No audio and no video tracks are set.",
        );
        this.trigger("error", err);
        this.dispose();
      }
    }
  }

  /**
   * Handles the case when no playable representations are available, or when no initial track has been set,
   * for a given period and track type (e.g., `'video'` or `'audio'`).
   *
   * It attempts to fall back to another available track.
   *
   * If no fallback tracks are available, the function may either:
   * - Throw an error
   * - Continue playback by disabling the affected media type (audio/video)
   *
   * The behavior depends on how the `onVideoTracksNotPlayable` and `onAudioTracksNotPlayable` options are configured.
   *
   * @param {Object} period - The period object containing the adaptations.
   * @param {string} trackType - The type of media track (e.g., `'video'` or `'audio'`) that became unplayable.
   *@param {boolean} isInitialSelection - Indicates if this occurs during initial track selection.
   * @returns {void}
   */
  private handleMissingOrUnplayableTrack(
    period: IPeriodMetadata,
    trackType: ITrackType,
    isInitialSelection: boolean,
  ): void {
    const { fallbackTrack, noSourceMedia } = getFallbackTrack(period, trackType);
    const typeInfo = getPeriodItem(this._storedPeriodInfo, period.id)?.[trackType];
    if (typeInfo === undefined) {
      log.warn("Track", "Could not find period", { periodId: period.id });
      return;
    }
    const initialStoredSettings = typeInfo.storedSettings;
    const hasStoredSettingsChanged = (): boolean => {
      return typeInfo.storedSettings !== initialStoredSettings;
    };

    if (fallbackTrack !== null) {
      let switchingMode: "direct" | "seamless" | "reload";
      if (trackType === "audio") {
        switchingMode = this._defaultAudioTrackSwitchingMode;
      } else if (trackType === "text") {
        switchingMode = "direct";
      } else {
        switchingMode = "reload";
      }

      const storedSettings = {
        adaptation: fallbackTrack,
        switchingMode,
        lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
      };
      typeInfo.storedSettings = storedSettings;

      if (!isInitialSelection) {
        // "trackUpdate" events are not sent for the initial track.
        // See documentation: #Player_Events.md, section ###trackUpdate
        this.trigger("trackUpdate", {
          period: toExposedPeriod(period),
          trackType,
          reason: "no-playable-representation",
        });
      }
      // The previous event trigger could have had side-effects, so we
      // re-check if we're still mostly in the same state
      if (this._isDisposed) {
        return; // Someone disposed the `TracksStore` on the previous side-effect
      }

      // Check again that no track change occurred in the meantime
      if (typeInfo.storedSettings === storedSettings) {
        typeInfo.dispatcher?.updateTrack(storedSettings);
      }
    } else if (fallbackTrack === null && !noSourceMedia) {
      this.trigger("noPlayableTrack", {
        trackType,
        period: { id: period.id, start: period.start, end: period.end },
      });
      // The previous event trigger could have had side-effects, so we
      // re-check if we're still mostly in the same state
      if (this._isDisposed) {
        return; // Someone disposed the `TracksStore` on the previous side-effect
      }

      const fallbackBehavior = this.onTracksNotPlayableForType[trackType];
      if (hasStoredSettingsChanged()) {
        // The previous "noPlayableTrack" event might have caused changes,
        // so we re-check to see if the selected track has been updated.
        // If it has, we exit early because the API consumer likely adjusted the settings,
        // and throwing an error now would be out of sync with their changes.
      } else if (fallbackBehavior === "continue") {
        log.warn("Track", `No playable ${trackType}, continuing without ${trackType}`);
        typeInfo.storedSettings = null;
        if (!isInitialSelection) {
          // "trackUpdate" events are not sent for the initial track.
          // See documentation: #Player_Events.md, section ###trackUpdate
          this.trigger("trackUpdate", {
            period: toExposedPeriod(period),
            trackType,
            reason: "no-playable-representation",
          });
        }
        if (typeInfo.storedSettings !== null || this._isDisposed) {
          // The previous "trackUpdate" event might have caused changes,
          // so we re-check to see if the selected track has been updated.
          // If it has, we exit early because the API consumer likely adjusted the settings,
          // and throwing an error now would be out of sync with their changes.
        } else {
          typeInfo.dispatcher?.updateTrack(null);
        }
      } else if (fallbackBehavior === "error") {
        const noRepErr = new MediaError(
          "NO_PLAYABLE_REPRESENTATION",
          `No ${trackType} Representation can be played`,
          { tracks: undefined },
        );
        this.trigger("error", noRepErr);
      }
    } else if (fallbackTrack === null && noSourceMedia) {
      log.debug(
        `Track`,
        `The period does not have adaptation for ${trackType} there is no track to choose`,
      );
      typeInfo.storedSettings = null;
      if (!isInitialSelection) {
        // "trackUpdate" events are not sent for the initial track.
        // See documentation: #Player_Events.md, section ###trackUpdate
        this.trigger("trackUpdate", {
          period: toExposedPeriod(period),
          trackType,
          reason: "no-playable-representation",
        });
      }
      if (typeInfo.storedSettings !== null || this._isDisposed) {
        // The previous "trackUpdate" event might have caused changes,
        // so we re-check to see if the selected track has been updated.
        // If it has, we exit early because the API consumer likely adjusted the settings,
        // and throwing an error now would be out of sync with their changes.
      } else {
        typeInfo.dispatcher?.updateTrack(null);
      }
    }

    // The previous event trigger could have had side-effects, so we
    // re-check if we're still mostly in the same state
    if (this._isDisposed) {
      return; // Someone disposed the `TracksStore` on the previous side-effect
    }

    if (trackType !== "text") {
      // Allow missing text tracks; only enforce for audio/video
      this.throwIfTracksAreNotSetForPeriod(period);
    }
  }

  /**
   * Remove shared reference to choose an "audio", "video" or "text" Adaptation
   * for a Period.
   * @param {string} bufferType - The concerned buffer type
   * @param {string} periodId - The concerned Period's `id`.
   */
  public removeTrackReference(
    bufferType: "audio" | "text" | "video",
    periodId: string,
  ): void {
    log.debug("Track", "Removing Track Reference", { bufferType, periodId });
    let periodIndex;
    for (let i = 0; i < this._storedPeriodInfo.length; i++) {
      const periodI = this._storedPeriodInfo[i];
      if (periodI.period.id === periodId) {
        periodIndex = i;
        break;
      }
    }
    if (periodIndex === undefined) {
      log.warn(`Track`, `type not found for period`, { bufferType, periodId });
      return;
    }

    const periodObj = this._storedPeriodInfo[periodIndex];
    const choiceItem = periodObj[bufferType];
    if (choiceItem?.dispatcher === null) {
      log.warn(`Track`, `TrackDispatcher already removed for type and Period`, {
        bufferType,
        periodId,
      });
      return;
    }

    choiceItem.dispatcher.dispose();
    choiceItem.dispatcher = null;
    if (isPeriodItemRemovable(periodObj)) {
      this._removePeriodObject(periodIndex);
    }
  }

  /**
   * Allows to recuperate a "Period Object" - used in get/set methods of the
   * `TracksStore` - by giving the Period itself.
   *
   * This method should be preferred when possible over `getPeriodObjectFromId`
   * because it is able to fallback on an internal cache in case the
   * corresponding Period is not stored anymore.
   * This for example could happen when a Period has been removed from the
   * Manifest yet may still be needed (e.g. because its linked segments might
   * still live in the buffers).
   *
   * Note however that this cache-retrieval logic is based on a Map whose key
   * is the Period's JavaScript reference. As such, the cache won't be used if
   * `Period` corresponds to a copy of the original `Period` object.
   *
   * @param {Object} period
   * @returns {Object}
   */
  public getPeriodObjectFromPeriod(period: IPeriodMetadata): ITSPeriodObject | undefined {
    const periodObj = getPeriodItem(this._storedPeriodInfo, period.id);
    if (periodObj === undefined && period !== undefined) {
      return this._cachedPeriodInfo.get(period);
    }
    return periodObj;
  }

  /**
   * Allows to recuperate a "Period Object" - used in get/set methods of the
   * `TracksStore` - by giving the Period's id.
   *
   * Note that unlike `getPeriodObjectFromPeriod` this method is only going to look
   * into currently stored Period and as such old Periods not in the Manifest
   * anymore might not be retrievable.
   * If you want to retrieve Period objects linked to such Period, you might
   * prefer to use `getPeriodObjectFromPeriod` (which necessitates the original
   * Period object).
   *
   * @param {string} periodId - The concerned Period's id
   * @returns {Object}
   */
  public getPeriodObjectFromId(periodId: string): ITSPeriodObject | undefined {
    return getPeriodItem(this._storedPeriodInfo, periodId);
  }

  public disableVideoTrickModeTracks(): void {
    if (!this._isTrickModeTrackEnabled) {
      return;
    }
    this._isTrickModeTrackEnabled = false;
    this._resetVideoTrackChoices("trickmode-disabled");
  }

  public enableVideoTrickModeTracks(): void {
    if (this._isTrickModeTrackEnabled) {
      return;
    }
    this._isTrickModeTrackEnabled = true;
    this._resetVideoTrackChoices("trickmode-enabled");
  }

  /**
   * Reset the TracksStore's Period objects:
   *   - All Period which are not in the manifest currently will be removed.
   *   - All References used to communicate the wanted track will be removed.
   *
   * You might want to call this API when restarting playback.
   */
  public resetPeriodObjects(): void {
    log.debug("Track", "Resetting Period Objects");
    for (let i = this._storedPeriodInfo.length - 1; i >= 0; i--) {
      const storedObj = this._storedPeriodInfo[i];
      storedObj.audio.dispatcher?.dispose();
      storedObj.audio.dispatcher = null;
      storedObj.video.dispatcher?.dispose();
      storedObj.video.dispatcher = null;
      storedObj.text.dispatcher?.dispose();
      storedObj.text.dispatcher = null;
      if (!storedObj.inManifest) {
        this._removePeriodObject(i);
      }
    }
  }

  /**
   * @returns {boolean}
   */
  public isTrickModeEnabled(): boolean {
    return this._isTrickModeTrackEnabled;
  }

  /**
   * Set audio track based on the ID of its Adaptation for a given added Period.
   * @param {Object} params
   * @param {Object} params.periodRef - The concerned Period's object.
   * @param {string} params.trackId - adaptation id of the wanted track.
   * @param {string} params.switchingMode - Behavior when replacing the track by
   * another.
   * @param {Object|null} params.lockedRepresentations - Audio Representations
   * that should be locked after switching to that track.
   * `null` if no Audio Representation should be locked.
   * @param {number} params.relativeResumingPosition
   */
  public setAudioTrack(payload: {
    periodRef: ITSPeriodObject;
    trackId: string;
    switchingMode: IAudioTrackSwitchingMode | undefined;
    lockedRepresentations: string[] | null;
    relativeResumingPosition: number | undefined;
  }): void {
    const {
      periodRef,
      trackId,
      switchingMode,
      lockedRepresentations,
      relativeResumingPosition,
    } = payload;
    return this._setAudioOrTextTrack({
      bufferType: "audio",
      periodRef,
      trackId,
      switchingMode: switchingMode ?? this._defaultAudioTrackSwitchingMode,
      lockedRepresentations,
      relativeResumingPosition,
    });
  }

  /**
   * Set text track based on the ID of its Adaptation for a given added Period.
   * @param {Object} periodObj - The concerned Period's object.
   * @param {string} wantedId - adaptation id of the wanted track.
   */
  public setTextTrack(periodObj: ITSPeriodObject, wantedId: string): void {
    return this._setAudioOrTextTrack({
      bufferType: "text",
      periodRef: periodObj,
      trackId: wantedId,
      switchingMode: "direct",
      lockedRepresentations: null,
      relativeResumingPosition: undefined,
    });
  }

  /**
   * Set audio track based on the ID of its Adaptation for a given added Period.
   * @param {Object} params
   * @param {string} params.bufferType
   * @param {Object} params.periodRef - The concerned Period's object.
   * @param {string} params.trackId - adaptation id of the wanted track.
   * @param {string} params.switchingMode - Behavior when replacing the track by
   * another.
   * @param {Array.<string>|null} params.lockedRepresentations - Audio
   * Representations that should be locked after switchingMode to that track.
   * `null` if no Audio Representation should be locked.
   * @param {number|undefined} params.relativeResumingPosition
   */
  private _setAudioOrTextTrack({
    bufferType,
    periodRef,
    trackId,
    switchingMode,
    lockedRepresentations,
    relativeResumingPosition,
  }: {
    bufferType: "audio" | "text";
    periodRef: ITSPeriodObject;
    trackId: string;
    switchingMode: IAudioTrackSwitchingMode;
    lockedRepresentations: string[] | null;
    relativeResumingPosition: number | undefined;
  }): void {
    if (!periodRef.isPeriodAdvertised) {
      throw new Error("Wanted Period not yet advertised.");
    }
    const period = periodRef.period;
    const wantedAdaptation = arrayFind(
      period.adaptations[bufferType] ?? [],
      ({ id, supportStatus }) =>
        supportStatus.hasSupportedCodec !== false &&
        supportStatus.isDecipherable !== false &&
        id === trackId,
    );

    if (wantedAdaptation === undefined) {
      throw new Error(`Wanted ${bufferType} track not found.`);
    }

    const typeInfo = periodRef[bufferType];
    let lockedRepresentationsRef: SharedReference<IRepresentationsChoice | null>;
    if (lockedRepresentations === null) {
      lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>(null);
    } else {
      const representationsToLock = this._getRepresentationsToLock(
        wantedAdaptation,
        lockedRepresentations,
      );
      const repSwitchingMode =
        bufferType === "audio"
          ? this._defaultAudioTrackSwitchingMode
          : ("direct" as const);
      lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>({
        representationIds: representationsToLock,
        switchingMode: repSwitchingMode,
      });
    }

    const storedSettings = {
      adaptation: wantedAdaptation,
      switchingMode,
      lockedRepresentations: lockedRepresentationsRef,
      relativeResumingPosition,
    };
    typeInfo.storedSettings = storedSettings;
    this.trigger("trackUpdate", {
      period: toExposedPeriod(period),
      trackType: bufferType,
      reason: "manual",
    });

    // The previous event trigger could have had side-effects, so we
    // re-check if we're still mostly in the same state
    if (this._isDisposed) {
      return; // Someone disposed the `TracksStore` on the previous side-effect
    }
    const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
    if (
      newPeriodItem !== undefined &&
      newPeriodItem[bufferType].storedSettings === storedSettings
    ) {
      newPeriodItem[bufferType].dispatcher?.updateTrack(storedSettings);
    }
  }

  /**
   * Set video track based on the ID of its Adaptation for a given added Period.
   * @param {Object} params
   * @param {Object} params.periodRef - The concerned Period's object.
   * @param {string} params.trackId - adaptation id of the wanted track.
   * @param {string} params.switchingMode - Behavior when replacing the track by
   * another.
   * @param {Array.<string>|null} params.lockedRepresentations - Video
   * Representations that should be locked after switching to that track.
   * `null` if no Video Representation should be locked.
   * @param {number|undefined} params.relativeResumingPosition
   */
  public setVideoTrack(payload: {
    periodRef: ITSPeriodObject;
    trackId: string;
    switchingMode: IVideoTrackSwitchingMode | undefined;
    lockedRepresentations: string[] | null;
    relativeResumingPosition: number | undefined;
  }): void {
    const {
      periodRef,
      trackId,
      switchingMode,
      lockedRepresentations,
      relativeResumingPosition,
    } = payload;
    if (!periodRef.isPeriodAdvertised) {
      throw new Error("Wanted Period not yet advertised.");
    }
    const period = periodRef.period;
    const wantedAdaptation = arrayFind(
      period.adaptations.video ?? [],
      ({ id, supportStatus }) =>
        supportStatus.isDecipherable !== false &&
        supportStatus.hasSupportedCodec !== false &&
        id === trackId,
    );

    if (wantedAdaptation === undefined) {
      throw new Error("Wanted video track not found.");
    }

    const { DEFAULT_VIDEO_TRACK_SWITCHING_MODE } = config.getCurrent();
    const typeInfo = periodRef.video;
    const newAdaptation = getRightVideoTrack(
      wantedAdaptation,
      this._isTrickModeTrackEnabled,
    );

    let lockedRepresentationsRef;
    if (lockedRepresentations === null) {
      lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>(null);
    } else {
      const representationsToLock = this._getRepresentationsToLock(
        wantedAdaptation,
        lockedRepresentations,
      );
      const repSwitchingMode = DEFAULT_VIDEO_TRACK_SWITCHING_MODE;
      lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>({
        representationIds: representationsToLock,
        switchingMode: repSwitchingMode,
      });
    }

    const storedSettings = {
      adaptationBase: wantedAdaptation,
      switchingMode: switchingMode ?? DEFAULT_VIDEO_TRACK_SWITCHING_MODE,
      adaptation: newAdaptation,
      relativeResumingPosition,
      lockedRepresentations: lockedRepresentationsRef,
    };
    typeInfo.storedSettings = storedSettings;
    this.trigger("trackUpdate", {
      period: toExposedPeriod(period),
      trackType: "video",
      reason: "manual",
    });

    // The previous event trigger could have had side-effects, so we
    // re-check if we're still mostly in the same state
    if (this._isDisposed) {
      return; // Someone disposed the `TracksStore` on the previous side-effect
    }
    const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
    if (
      newPeriodItem !== undefined &&
      newPeriodItem.video.storedSettings === storedSettings
    ) {
      newPeriodItem.video.dispatcher?.updateTrack(storedSettings);
    }
  }

  /**
   * Disable the current text track for a given period.
   *
   * @param {Object} periodObj - The concerned Period's object
   * @param {string} bufferType - The type of track to disable.
   * @throws Error - Throws if the period given has not been added
   */
  public disableTrack(
    periodObj: ITSPeriodObject,
    bufferType: "audio" | "video" | "text",
  ): void {
    if (!periodObj.isPeriodAdvertised) {
      throw new Error("Wanted Period not yet advertised.");
    }
    const trackInfo = periodObj[bufferType];
    if (trackInfo.storedSettings === null) {
      return;
    }

    if (bufferType !== "text") {
      // Potentially unneeded, but let's be clean
      periodObj[bufferType].storedSettings?.lockedRepresentations.finish();
    }

    trackInfo.storedSettings = null;
    this.trigger("trackUpdate", {
      period: toExposedPeriod(periodObj.period),
      trackType: bufferType,
      reason: "manual",
    });

    // The previous event trigger could have had side-effects, so we
    // re-check if we're still mostly in the same state
    if (this._isDisposed) {
      return; // Someone disposed the `TracksStore` on the previous side-effect
    }
    const newPeriodItem = getPeriodItem(this._storedPeriodInfo, periodObj.period.id);
    if (
      newPeriodItem !== undefined &&
      newPeriodItem[bufferType].storedSettings === null
    ) {
      newPeriodItem[bufferType].dispatcher?.updateTrack(null);
    }

    if (newPeriodItem !== undefined) {
      const hasNoTrackAtAll = ["audio" as const, "video" as const].every(
        (ttype) => newPeriodItem[ttype].storedSettings === null,
      );
      if (hasNoTrackAtAll) {
        const err = new MediaError(
          "NO_AUDIO_VIDEO_TRACKS",
          "No audio and no video tracks are set.",
        );
        this.trigger("error", err);
        this.dispose();
        return;
      }
    }
  }

  /**
   * Returns an object describing the chosen audio track for the given audio
   * Period.
   *
   * Returns `null` is the the current audio track is disabled or not
   * set yet.a pas bcp de marge de manoeuvre j'ai l'impression
   *
   * Returns `undefined` if the given Period's id is not currently found in the
   * `TracksStore`. The cause being most probably that the corresponding
   * Period is not available anymore.
   * If you're in that case and if still have the corresponding JavaScript
   * reference to the wanted Period, you can call `getOldAudioTrack` with it. It
   * will try retrieving the choice it made from its cache.
   * @param {Object} periodObj - The concerned Period's object
   * @returns {Object|null|undefined} - The audio track chosen for this Period.
   * `null` if audio tracks were disabled and `undefined` if the Period is not
   * known.
   */
  public getChosenAudioTrack(
    periodObj: ITSPeriodObject,
    filterPlayableRepresentations: boolean,
  ): IAudioTrack | null {
    return isNullOrUndefined(periodObj.audio.storedSettings)
      ? null
      : toAudioTrack(
          periodObj.audio.storedSettings.adaptation,
          filterPlayableRepresentations,
        );
  }

  /**
   * Returns an object describing the chosen text track for the given text
   * Period.
   *
   * Returns null is the the current text track is disabled or not
   * set yet.
   *
   * @param {Object} periodObj - The concerned Period's object
   * @returns {Object|null} - The text track chosen for this Period
   */
  public getChosenTextTrack(periodObj: ITSPeriodObject): ITextTrack | null {
    return isNullOrUndefined(periodObj.text.storedSettings)
      ? null
      : toTextTrack(periodObj.text.storedSettings.adaptation);
  }

  /**
   * Returns an object describing the chosen video track for the given video
   * Period.
   *
   * Returns null is the the current video track is disabled or not
   * set yet.
   *
   * @param {Object} periodObj - The concerned Period's object
   * @returns {Object|null} - The video track chosen for this Period
   */
  public getChosenVideoTrack(
    periodObj: ITSPeriodObject,
    filterPlayableRepresentations: boolean,
  ): IVideoTrack | null {
    if (isNullOrUndefined(periodObj.video.storedSettings)) {
      return null;
    }

    return toVideoTrack(
      periodObj.video.storedSettings.adaptation,
      filterPlayableRepresentations,
    );
  }

  /**
   * Returns all available audio tracks for a given Period, as an array of
   * objects.
   *
   * Returns `undefined` if the given Period's id is not known.
   *
   * @param {Object} periodObj - The concerned Period's object
   * @param {boolean} filterPlayableRepresentations - If `true`, only
   * representations considered to be "playable" will be included in the
   * returned response.
   * If `false`, the response should contain all linked representations.
   * @returns {Array.<Object>}
   */
  public getAvailableAudioTracks(
    periodObj: ITSPeriodObject,
    filterPlayableRepresentations: boolean,
  ): IAvailableAudioTrack[] | undefined {
    const storedSettings = periodObj.audio.storedSettings;
    const currentId = !isNullOrUndefined(storedSettings)
      ? storedSettings.adaptation.id
      : null;
    const adaptations = getSupportedAdaptations(periodObj.period, "audio");
    return adaptations.map((adaptation: IAdaptationMetadata) => {
      const active = currentId === null ? false : currentId === adaptation.id;
      return objectAssign(toAudioTrack(adaptation, filterPlayableRepresentations), {
        active,
      });
    });
  }

  /**
   * Returns all available text tracks for a given Period, as an array of
   * objects.
   *
   * Returns `undefined` if the given Period's id is not known.
   *
   * @param {Object} periodObj - The concerned Period's object
   * @returns {Array.<Object>}
   */
  public getAvailableTextTracks(
    periodObj: ITSPeriodObject,
  ): IAvailableTextTrack[] | undefined {
    const storedSettings = periodObj.text.storedSettings;
    const currentId = !isNullOrUndefined(storedSettings)
      ? storedSettings.adaptation.id
      : null;

    const adaptations = getSupportedAdaptations(periodObj.period, "text");
    return adaptations.map((adaptation) => {
      const active = currentId === null ? false : currentId === adaptation.id;
      return objectAssign(toTextTrack(adaptation), { active });
    });
  }

  /**
   * Returns all available video tracks for a given Period, as an array of
   * objects.
   *
   * Returns `undefined` if the given Period's id is not known.
   *
   * @param {Object} periodObj - The concerned Period's object
   * @param {boolean} filterPlayableRepresentations - If `true`, only
   * representations considered to be "playable" will be included in the
   * returned response.
   * If `false`, the response should contain all linked representations.
   * @returns {Array.<Object>}
   */
  public getAvailableVideoTracks(
    periodObj: ITSPeriodObject,
    filterPlayableRepresentations: boolean,
  ): IAvailableVideoTrack[] | undefined {
    const storedSettings = periodObj.video.storedSettings;
    const currentId = isNullOrUndefined(storedSettings)
      ? undefined
      : storedSettings.adaptation.id;

    const adaptations = getSupportedAdaptations(periodObj.period, "video");
    return adaptations.map((adaptation) => {
      const active = currentId === null ? false : currentId === adaptation.id;
      const track = toVideoTrack(adaptation, filterPlayableRepresentations);
      const trickModeTracks =
        track.trickModeTracks !== undefined
          ? track.trickModeTracks.map((trickModeAdaptation) => {
              const isActive =
                currentId === null ? false : currentId === trickModeAdaptation.id;
              return objectAssign(trickModeAdaptation, { active: isActive });
            })
          : [];
      const availableTrack = objectAssign(track, { active });
      if (trickModeTracks !== undefined) {
        availableTrack.trickModeTracks = trickModeTracks;
      }
      return availableTrack;
    });
  }

  public getLockedAudioRepresentations(periodObj: ITSPeriodObject): string[] | null {
    const { storedSettings } = periodObj.audio;
    if (isNullOrUndefined(storedSettings)) {
      return null;
    }
    const lastLockedSettings = storedSettings.lockedRepresentations.getValue();
    return lastLockedSettings === null ? null : lastLockedSettings.representationIds;
  }

  public getLockedVideoRepresentations(periodObj: ITSPeriodObject): string[] | null {
    const { storedSettings } = periodObj.video;
    if (isNullOrUndefined(storedSettings)) {
      return null;
    }
    const lastLockedSettings = storedSettings.lockedRepresentations.getValue();
    return lastLockedSettings === null ? null : lastLockedSettings.representationIds;
  }

  public lockAudioRepresentations(
    periodObj: ITSPeriodObject,
    lockSettings: IAudioRepresentationsLockSettings,
  ): void {
    const { storedSettings } = periodObj.audio;
    if (isNullOrUndefined(storedSettings)) {
      return;
    }
    const { DEFAULT_AUDIO_REPRESENTATIONS_SWITCHING_MODE } = config.getCurrent();
    const filtered = this._getRepresentationsToLock(
      storedSettings.adaptation,
      lockSettings.representations,
    );

    const switchingMode =
      lockSettings.switchingMode ?? DEFAULT_AUDIO_REPRESENTATIONS_SWITCHING_MODE;
    storedSettings.lockedRepresentations.setValue({
      representationIds: filtered,
      switchingMode,
    });
  }

  public lockVideoRepresentations(
    periodObj: ITSPeriodObject,
    lockSettings: IVideoRepresentationsLockSettings,
  ): void {
    const { storedSettings } = periodObj.video;
    if (isNullOrUndefined(storedSettings)) {
      return;
    }

    const { DEFAULT_VIDEO_REPRESENTATIONS_SWITCHING_MODE } = config.getCurrent();
    const filtered = this._getRepresentationsToLock(
      storedSettings.adaptation,
      lockSettings.representations,
    );
    const switchingMode =
      lockSettings.switchingMode ?? DEFAULT_VIDEO_REPRESENTATIONS_SWITCHING_MODE;
    storedSettings.lockedRepresentations.setValue({
      representationIds: filtered,
      switchingMode,
    });
  }

  public unlockAudioRepresentations(periodObj: ITSPeriodObject): void {
    const { storedSettings } = periodObj.audio;
    if (
      isNullOrUndefined(storedSettings) ||
      storedSettings.lockedRepresentations.getValue() === null
    ) {
      return;
    }
    storedSettings.lockedRepresentations.setValue(null);
  }

  public unlockVideoRepresentations(periodObj: ITSPeriodObject): void {
    const { storedSettings } = periodObj.video;
    if (
      isNullOrUndefined(storedSettings) ||
      storedSettings.lockedRepresentations.getValue() === null
    ) {
      return;
    }
    storedSettings.lockedRepresentations.setValue(null);
  }

  public dispose(): void {
    this._isDisposed = true;
    while (true) {
      const lastPeriod = this._storedPeriodInfo.pop();
      if (lastPeriod === undefined) {
        return;
      }
      lastPeriod.isRemoved = true;
    }
  }

  private _resetVideoTrackChoices(reason: "trickmode-enabled" | "trickmode-disabled") {
    for (let i = 0; i < this._storedPeriodInfo.length; i++) {
      const periodObj = this._storedPeriodInfo[i];
      if (!isNullOrUndefined(periodObj.video.storedSettings)) {
        const chosenBaseTrack = periodObj.video.storedSettings.adaptationBase;
        if (chosenBaseTrack !== null) {
          const chosenTrack = getRightVideoTrack(
            chosenBaseTrack,
            this._isTrickModeTrackEnabled,
          );
          periodObj.video.storedSettings.adaptationBase = chosenBaseTrack;
          periodObj.video.storedSettings.adaptation = chosenTrack;
        }
      }
    }

    // Clone the current Period list to not be influenced if Periods are removed
    // or added while the loop is running.
    const sliced = this._storedPeriodInfo.slice();
    for (let i = 0; i < sliced.length; i++) {
      const period = sliced[i].period;
      const videoItem = sliced[i].video;
      const storedSettings = videoItem.storedSettings;
      if (storedSettings !== undefined) {
        this.trigger("trackUpdate", {
          period: toExposedPeriod(period),
          trackType: "video",
          reason,
        });

        // The previous event trigger could have had side-effects, so we
        // re-check if we're still mostly in the same state
        if (this._isDisposed) {
          return; // Someone disposed the `TracksStore` on the previous side-effect
        }
        const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
        if (
          newPeriodItem !== undefined &&
          newPeriodItem.isPeriodAdvertised &&
          newPeriodItem.video.storedSettings === storedSettings
        ) {
          newPeriodItem.video.dispatcher?.updateTrack(storedSettings);
        }
      }
    }
  }

  private _removePeriodObject(index: number) {
    assert(index < this._storedPeriodInfo.length);
    const oldPeriodItem = this._storedPeriodInfo[index];
    this._storedPeriodInfo[index].isRemoved = true;
    this._storedPeriodInfo.splice(index, 1);
    this._cachedPeriodInfo.set(oldPeriodItem.period, oldPeriodItem);
  }

  private _getRepresentationsToLock(
    adaptation: IAdaptationMetadata,
    representationIds: string[],
  ): string[] {
    const filtered = representationIds.reduce((acc: string[], repId) => {
      const foundRep = arrayFind(adaptation.representations, (r) => {
        return r.id === repId;
      });
      if (foundRep === undefined) {
        log.warn("Track", "Wanted locked Representation not found.");
      } else {
        acc.push(foundRep.id);
      }
      return acc;
    }, []);

    if (filtered.length === 0) {
      throw new Error(
        "Cannot lock Representations: " + "None of the given Representation id are found",
      );
    }

    return filtered;
  }

  /**
   * Check or re-check all Periods for which both an initial track can be chosen
   * and for which the `newAvailablePeriods` event can be triggered.
   */
  private _selectInitialTrackIfNeeded(): void {
    const { DEFAULT_VIDEO_TRACK_SWITCHING_MODE } = config.getCurrent();

    const periodsToAdvertise: IPeriod[] = [];
    const toDispatchTrack: ITSPeriodObject[] = [];
    for (const trackStorePeriod of this._storedPeriodInfo) {
      const { period } = trackStorePeriod;
      if (
        trackStorePeriod.audio.storedSettings !== undefined &&
        trackStorePeriod.video.storedSettings !== undefined &&
        trackStorePeriod.text.storedSettings !== undefined
      ) {
        // already processed, continue
        continue;
      }
      const adaptations: IAdaptationMetadata[] = [
        ...(period.adaptations.audio ?? []),
        ...(period.adaptations.video ?? []),
      ];
      const hasCodecWithUndefinedSupport = adaptations.every(
        (a) => a.supportStatus.hasCodecWithUndefinedSupport,
      );
      if (adaptations.length > 0 && hasCodecWithUndefinedSupport) {
        // Not all codecs for that Period are known yet.
        // Await until this is the case.
        continue;
      }

      const audioAdaptation: IAdaptationMetadata | undefined = getSupportedAdaptations(
        period,
        "audio",
      )[0];
      if (audioAdaptation === undefined) {
        trackStorePeriod.audio.storedSettings = null;
        this.handleMissingOrUnplayableTrack(period, "audio", true);
        if (this._isDisposed) {
          return;
        }
      } else {
        trackStorePeriod.audio.storedSettings = {
          adaptation: audioAdaptation,
          switchingMode: this._defaultAudioTrackSwitchingMode,
          lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
        };
      }

      const baseVideoAdaptation: IAdaptationMetadata | undefined =
        getSupportedAdaptations(period, "video")[0];
      if (baseVideoAdaptation === undefined) {
        trackStorePeriod.video.storedSettings = null;
        this.handleMissingOrUnplayableTrack(period, "video", true);
        if (this._isDisposed) {
          return;
        }
      } else {
        const videoAdaptation = getRightVideoTrack(
          baseVideoAdaptation,
          this._isTrickModeTrackEnabled,
        );
        trackStorePeriod.video.storedSettings = {
          adaptation: videoAdaptation,
          adaptationBase: baseVideoAdaptation,
          switchingMode: DEFAULT_VIDEO_TRACK_SWITCHING_MODE,
          lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
        };
      }

      let textAdaptation: IAdaptationMetadata | null = null;
      const forcedSubtitles = (period.adaptations.text ?? []).filter(
        (ad) => ad.isForcedSubtitles === true,
      );
      if (forcedSubtitles.length > 0) {
        if (audioAdaptation !== null && audioAdaptation !== undefined) {
          const sameLanguage = arrayFind(
            forcedSubtitles,
            (f) => f.normalizedLanguage === audioAdaptation.normalizedLanguage,
          );
          if (sameLanguage !== undefined) {
            textAdaptation = sameLanguage;
          }
        }
        if (textAdaptation === null) {
          textAdaptation =
            arrayFind(forcedSubtitles, (f) => f.normalizedLanguage === undefined) ?? null;
        }
      }
      trackStorePeriod.text.storedSettings =
        textAdaptation === null
          ? null
          : {
              adaptation: textAdaptation,
              switchingMode: "direct" as const,
              lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(
                null,
              ),
            };

      toDispatchTrack.push(trackStorePeriod);
      if (this._shouldAdvertisePeriod(trackStorePeriod)) {
        trackStorePeriod.isPeriodAdvertised = true;
        periodsToAdvertise.push({ id: period.id, start: period.start, end: period.end });
      }
    }

    if (periodsToAdvertise.length > 0) {
      this.trigger("newAvailablePeriods", periodsToAdvertise);
      if (this._isDisposed) {
        return;
      }
    }

    for (const trackStorePeriod of toDispatchTrack) {
      if (!trackStorePeriod.isPeriodAdvertised) {
        continue;
      }

      const bufferTypes: ITrackType[] = ["audio", "video", "text"];
      for (const bufferType of bufferTypes) {
        const trackInfo = trackStorePeriod[bufferType];
        if (
          trackInfo.dispatcher !== null &&
          trackInfo.storedSettings !== undefined &&
          !trackInfo.dispatcher.hasSetTrack()
        ) {
          trackInfo.dispatcher.updateTrack(trackInfo.storedSettings);
          if (this._isDisposed) {
            return;
          }
        }
      }
    }
  }

  /**
   * Returns `true` once a Period can be advertised through a `newAvailablePeriods`
   * event, after which track can begin to be set and updated.
   * @param {Object} periodObj
   * @returns {boolean}
   */
  private _shouldAdvertisePeriod(periodObj: ITSPeriodObject): boolean {
    return (
      !periodObj.isPeriodAdvertised &&
      periodObj.text.dispatcher !== null &&
      periodObj.video.dispatcher !== null &&
      periodObj.audio.dispatcher !== null
    );
  }
}

/**
 * Returns element in the given `periods` Array that corresponds to the
 * `period` given.
 * Returns `undefined` if that `period` is not found.
 * @param {Object} periods
 * @param {string} periodId
 * @returns {Object|undefined}
 */
function getPeriodItem(
  periods: ITSPeriodObject[],
  periodId: string,
): ITSPeriodObject | undefined {
  for (let i = 0; i < periods.length; i++) {
    const periodI = periods[i];
    if (periodI.period.id === periodId) {
      return periodI;
    }
  }
}

/**
 * A `ITSPeriodObject` should only be removed once all References linked to it
 * do not exist anymore, to keep the possibility of making track choices.
 * @param {Object} periodObj
 * @returns {boolean}
 */
function isPeriodItemRemovable(periodObj: ITSPeriodObject): boolean {
  return (
    !periodObj.inManifest &&
    periodObj.text?.dispatcher === null &&
    periodObj.audio?.dispatcher === null &&
    periodObj.video?.dispatcher === null
  );
}

function getRightVideoTrack(
  adaptation: IAdaptationMetadata,
  isTrickModeEnabled: boolean,
): IAdaptationMetadata {
  if (isTrickModeEnabled && adaptation.trickModeTracks?.[0] !== undefined) {
    return adaptation.trickModeTracks[0];
  }
  return adaptation;
}

/**
 * Generate an `ITSPeriodObject` object for the given Period, selecting the
 * default track for each type.
 * @param {Object} period
 * @param {boolean} inManifest
 * @returns {object}
 */
function generatePeriodInfo(
  period: IPeriodMetadata,
  inManifest: boolean,
): ITSPeriodObject {
  return {
    period,
    inManifest,
    isPeriodAdvertised: false,
    isRemoved: false,
    audio: { storedSettings: undefined, dispatcher: null },
    video: { storedSettings: undefined, dispatcher: null },
    text: { storedSettings: undefined, dispatcher: null },
  };
}

function toExposedPeriod(p: IPeriodMetadata): IPeriod {
  return { start: p.start, end: p.end, id: p.id };
}

/**
 * Retrieves a fallback track for the given period and track type.
 *
 * This function is used when the current track becomes unavailable,
 * and a fallback track must be selected based on the period and media type.
 *
 * @param {Object} period - The period object that contains information about the available tracks.
 * @param {string} trackType - The type of media track to fallback to (e.g., "audio", "video", "text").
 * @returns {object|null} A fallback track matching the type, or `null` if none is available.
 */
function getFallbackTrack(
  period: IPeriodMetadata,
  trackType: ITrackType,
): {
  fallbackTrack: IAdaptationMetadata | null;
  noSourceMedia: boolean;
} {
  const periodHasAdaptationForType =
    period.adaptations[trackType] !== undefined &&
    period.adaptations[trackType].length > 0;
  if (!periodHasAdaptationForType) {
    return {
      fallbackTrack: null,
      noSourceMedia: true,
    };
  }
  const firstPlayableAdaptation = findFirstPlayableAdaptation(period, trackType);
  return {
    fallbackTrack: firstPlayableAdaptation ?? null,
    noSourceMedia: false,
  };
}

function findFirstPlayableAdaptation(
  period: IPeriodMetadata,
  type: "audio" | "text" | "video",
): IAdaptationMetadata | undefined {
  const firstPlayableAdaptation = arrayFind(
    period.adaptations[type] ?? [],
    (adaptation) => {
      if (
        adaptation.supportStatus.hasSupportedCodec === false ||
        adaptation.supportStatus.isDecipherable === false
      ) {
        return false;
      }
      const playableRepresentations = adaptation.representations.filter(
        (r) => isRepresentationPlayable(r) === true,
      );
      return playableRepresentations.length > 0;
    },
  );
  return firstPlayableAdaptation;
}

/** Every information stored for a single Period. */
export interface ITSPeriodObject {
  /** The Period in question. */
  period: IPeriodMetadata;
  /**
   * If `true`, this Period was present at the last `updatePeriodList` call,
   * meaning it's probably still in the Manifest.
   *
   * If `false`, this Period was not. In that case it is probably just here
   * because some audio/video/text buffer still contains data of the given type.
   */
  inManifest: boolean;
  /**
   * Set to `true` once a `newAvailablePeriods` event has been sent for this
   * particular Period.
   *
   * Once this event has been sent, we can begin to select a audio, video and
   * text track for that Period (but **NOT** before).
   */
  isPeriodAdvertised: boolean;
  /**
   * Information on the selected audio track and Representations for this Period.
   */
  audio: IAudioPeriodInfo;
  /**
   * Information on the selected text track and Representations for this Period.
   */
  text: ITextPeriodInfo;
  /**
   * Information on the selected video track and Representations for this Period.
   */
  video: IVideoPeriodInfo;
  /**
   * If `true`, this object was since cleaned-up.
   */
  isRemoved: boolean;
}

/**
 * Internal representation of audio track preferences for a given `Period` of
 * the Manifest.
 */
interface IAudioPeriodInfo {
  /**
   * Information on the last audio track settings wanted by the user.
   * `null` if no audio track is wanted.
   * `undefined` if not set yet.
   */
  storedSettings: IAudioStoredSettings | null | undefined;
  /**
   * Tracks are internally emitted through RxJS's `Subject`s.
   * A `TrackDispatcher` allows to facilitate and centralize the management of
   * that Subject so that the right wanted track and qualities are emitted
   * through it.
   *
   * `null` if no `Subject` has been linked for this `Period` and buffer type
   * for now.
   */
  dispatcher: TrackDispatcher | null;
}

interface IAudioStoredSettings {
  /** Contains the last `Adaptation` wanted by the user. */
  adaptation: IAdaptationMetadata;
  /** "Switching mode" in which the track switch should happen. */
  switchingMode: IAudioTrackSwitchingMode;
  /**
   * Contains the last locked `Representation`s for this `Adaptation` wanted
   * by the user.
   * `null` if no Representation is locked.
   */
  lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
}

/**
 * Internal representation of text track preferences for a given `Period` of
 * the Manifest.
 */
export interface ITextPeriodInfo {
  /**
   * Information on the last text track settings wanted.
   * `null` if no text track is wanted.
   * `undefined` if not set yet.
   */
  storedSettings:
    | {
        /** Contains the last `Adaptation` wanted by the user. */
        adaptation: IAdaptationMetadata;
        /** "Switching mode" in which the track switch should happen. */
        switchingMode: "direct";
        /**
         * Contains the last locked `Representation`s for this `Adaptation` wanted
         * by the user.
         * `null` if no Representation is locked.
         */
        lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
      }
    | null
    | undefined;
  /**
   * Tracks are internally emitted through RxJS's `Subject`s.
   * A `TrackDispatcher` allows to facilitate and centralize the management of
   * that Subject so that the right wanted track and qualities are emitted
   * through it.
   *
   * `null` if no `Subject` has been linked for this `Period` and buffer type
   * for now.
   */
  dispatcher: TrackDispatcher | null;
}

/**
 * Internal representation of video track preferences for a given `Period` of
 * the Manifest.
 */
export interface IVideoPeriodInfo {
  /**
   * Information on the `id` of the last video track settings wanted.
   * `null` if no video track is wanted.
   * `undefined` if not set yet.
   */
  storedSettings: IVideoStoredSettings | null | undefined;
  /**
   * Tracks are internally emitted through RxJS's `Subject`s.
   * A `TrackDispatcher` allows to facilitate and centralize the management of
   * that Subject so that the right wanted track and qualities are emitted
   * through it.
   *
   * `null` if no `Subject` has been linked for this `Period` and buffer type
   * for now.
   */
  dispatcher: TrackDispatcher | null;
}

interface IVideoStoredSettings {
  /**
   * The wanted Adaptation itself (may be different from `adaptationBase` when
   * a trickmode track is chosen, in which case `adaptationBase` is the
   * Adaptation the trickmode track is linked to and `adaptation` is the
   * trickmode track).
   */
  adaptation: IAdaptationMetadata;
  /** "Switching mode" in which the track switch should happen. */
  switchingMode: IVideoTrackSwitchingMode;
  /**
   * The "base" Adaptation for `storedSettings` (if a trickmode track was
   * chosen, this is the Adaptation the trickmode track is linked to, and not
   * the trickmode track itself).
   */
  adaptationBase: IAdaptationMetadata;
  /**
   * Contains the last locked `Representation`s for this `Adaptation` wanted
   * by the user.
   * `null` if no Representation is locked.
   */
  lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
}

/** Events emitted by the TracksStore. */
interface ITracksStoreEvents {
  newAvailablePeriods: IPeriod[];
  brokenRepresentationsLock: IBrokenRepresentationsLockContext;
  trackUpdate: ITrackUpdateEventPayload;
  error: unknown;
  warning: IPlayerError;
  noPlayableTrack: INoPlayableTrackEventPayload;
}

export interface IAudioRepresentationsLockSettings {
  representations: string[];
  switchingMode?: IAudioRepresentationsSwitchingMode | undefined;
}

export interface IVideoRepresentationsLockSettings {
  representations: string[];
  switchingMode?: IVideoRepresentationsSwitchingMode | undefined;
}