/**
 * Copyright 2015 CANAL+ Group
 *
 * Licensed under the Apache License, Version 2.0 (the "License");publicapi
 * 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 defines the public API for the RxPlayer.
 * It also starts the different sub-parts of the player on various API calls.
 */

import type { IMediaElement } from "../../compat/browser_compatibility_types";
import canRelyOnVideoVisibilityAndSize from "../../compat/can_rely_on_video_visibility_and_size";
import type { IPictureInPictureEvent } from "../../compat/event_listeners";
import {
  getPictureOnPictureStateRef,
  getVideoVisibilityRef,
  getElementResolutionRef,
  getScreenResolutionRef,
} from "../../compat/event_listeners";
import getStartDate from "../../compat/get_start_date";
import hasMseInWorker from "../../compat/has_mse_in_worker";
import hasWorkerApi from "../../compat/has_worker_api";
import config from "../../config";
import type { ISegmentSinkMetrics } from "../../core/segment_sinks/segment_sinks_store";
import type {
  IAdaptationChoice,
  IInbandEvent,
  IABRThrottlers,
  IBufferType,
  ICoreMessage,
} from "../../core/types";
import { CoreMessageType } from "../../core/types";
import type { IDefaultConfig } from "../../default_config";
import type { IErrorCode, IErrorType } from "../../errors";
import { ErrorCodes, ErrorTypes, formatError, MediaError } from "../../errors";
import WorkerInitializationError from "../../errors/worker_initialization_error";
import type { IFeature } from "../../features";
import features, { addFeatures } from "../../features";
import log from "../../log";
import type {
  IDecipherabilityStatusChangedElement,
  IAdaptationMetadata,
  IManifestMetadata,
  IPeriodMetadata,
  IRepresentationMetadata,
  IPeriodsUpdateResult,
  IManifest,
} from "../../manifest";
import {
  getLivePosition,
  getMaximumSafePosition,
  getMinimumSafePosition,
  ManifestMetadataFormat,
  getPeriodForTime,
  toVideoRepresentation,
  toAudioRepresentation,
} from "../../manifest";
import type { IPlaybackObservation } from "../../playback_observer";
import MediaElementPlaybackObserver from "../../playback_observer/media_element_playback_observer";
import type {
  IAudioRepresentation,
  IAudioRepresentationsSwitchingMode,
  IAudioTrack,
  IAudioTrackSetting,
  IAudioTrackSwitchingMode,
  IAvailableAudioTrack,
  IAvailableTextTrack,
  IAvailableVideoTrack,
  IBrokenRepresentationsLockContext,
  IConstructorOptions,
  IKeySystemConfigurationOutput,
  IKeySystemOption,
  ILoadVideoOptions,
  ILockedAudioRepresentationsSettings,
  ILockedVideoRepresentationsSettings,
  ITrackUpdateEventPayload,
  IRepresentationListUpdateContext,
  IPeriod,
  IPeriodChangeEvent,
  IPlayerError,
  IPlayerState,
  IPositionUpdate,
  IStreamEvent,
  ITextTrack,
  IVideoRepresentation,
  ITextTrackSetting,
  IVideoRepresentationsSwitchingMode,
  IVideoTrack,
  IVideoTrackSetting,
  IVideoTrackSwitchingMode,
  ITrackType,
  IModeInformation,
  IWorkerSettings,
  IThumbnailTrackInfo,
  IThumbnailRenderingOptions,
  INoPlayableTrackEventPayload,
} from "../../public_types";
import type { IThumbnailResponse } from "../../transports";
import arrayFind from "../../utils/array_find";
import arrayIncludes from "../../utils/array_includes";
import assert, { assertUnreachable } from "../../utils/assert";
import type { IEventPayload, IListener } from "../../utils/event_emitter";
import EventEmitter from "../../utils/event_emitter";
import globalScope from "../../utils/global_scope";
import idGenerator from "../../utils/id_generator";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import type Logger from "../../utils/logger";
import getMonotonicTimeStamp from "../../utils/monotonic_timestamp";
import objectAssign from "../../utils/object_assign";
import { getLeftSizeOfBufferedTimeRange } from "../../utils/ranges";
import type { IReadOnlySharedReference } from "../../utils/reference";
import SharedReference, { createMappedReference } from "../../utils/reference";
import type { CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller from "../../utils/task_canceller";
import {
  clearOnStop,
  disposeDecryptionResources,
  getKeySystemConfiguration,
} from "../decrypt";
import type { ContentInitializer } from "../init";
import renderThumbnail from "../render_thumbnail";
import type { IMediaElementTracksStore, ITSPeriodObject } from "../tracks_store";
import TracksStore from "../tracks_store";
import { MainThreadMessageType } from "../types";
import { canHandleTextTracks, canHandleVideoTracks } from "../utils/media_capabilities";
import type { IParsedLoadVideoOptions, IParsedStartAtOption } from "./option_utils";
import {
  checkReloadOptions,
  parseConstructorOptions,
  parseLoadVideoOptions,
} from "./option_utils";
import {
  constructPlayerStateReference,
  emitPlayPauseEvents,
  emitSeekEvents,
  isLoadedState,
  PLAYER_STATES,
} from "./utils";

/* eslint-disable @typescript-eslint/naming-convention */

// Enable debug mode as soon as `RX_PLAYER_DEBUG_MODE__` is set to `true`:

const globals: typeof globalScope & {
  __RX_PLAYER_DEBUG_MODE__?: boolean;
} = globalScope;

let isDebugModeEnabled: boolean =
  typeof globals.__RX_PLAYER_DEBUG_MODE__ === "boolean" &&
  globals.__RX_PLAYER_DEBUG_MODE__;

try {
  Object.defineProperty(globals, "__RX_PLAYER_DEBUG_MODE__", {
    get(): boolean {
      return isDebugModeEnabled;
    },
    set(val: boolean) {
      isDebugModeEnabled = val;
      if (val) {
        Player.LogLevel = "DEBUG";
        Player.LogFormat = "full";
      }
    },
  });
} catch (_err) {
  // Ignore, maybe we're in some jsdom thing, maybe the current target does not
  // authorize setting globals that way etc.
}

if (isDebugModeEnabled) {
  log.setLevel("DEBUG", "full");
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
} else if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) {
  log.setLevel(__LOGGER_LEVEL__.CURRENT_LEVEL, "standard");
}

const generateContentId = idGenerator();

/**
 * @class Player
 * @extends EventEmitter
 */
class Player extends EventEmitter<IPublicAPIEvent> {
  /** Current version of the RxPlayer.  */
  public static version: string;

  /** Current version of the RxPlayer.  */
  public readonly version: string;

  /**
   * Store all video elements currently in use by an RxPlayer instance.
   * This is used to check that a video element is not shared between multiple instances.
   * Use of a WeakSet ensure the object is garbage collected if it's not used anymore.
   */
  private static _priv_currentlyUsedVideoElements = new WeakSet<IMediaElement>();

  /**
   * Media element attached to the RxPlayer.
   * Set to `null` when the RxPlayer is disposed.
   */
  public videoElement: IMediaElement | null; // null on dispose

  /** Logger the RxPlayer uses.  */
  public readonly log: Logger;

  /**
   * Current state of the RxPlayer.
   * Please use `getPlayerState()` instead.
   */
  public state: IPlayerState;

  /**
   * Emit when the the RxPlayer is not needed anymore and thus all resources
   * used for its normal functionment can be freed.
   * The player will be unusable after that.
   */
  private readonly _destroyCanceller: TaskCanceller;

  /**
   * Contains `true` when the previous content is cleaning-up, `false` when it's
   * done.
   * A new content cannot be launched until it stores `false`.
   */
  private readonly _priv_contentLock: SharedReference<boolean>;

  /**
   * The speed that should be applied to playback.
   * Used instead of videoElement.playbackRate to allow more flexibility.
   */
  private readonly _priv_speed: SharedReference<number>;

  /** Store buffer-related options used needed when initializing a content. */
  private readonly _priv_bufferOptions: {
    /** Last wanted buffer goal. */
    wantedBufferAhead: SharedReference<number>;
    /** Maximum kept buffer ahead in the current position, in seconds. */
    maxBufferAhead: SharedReference<number>;
    /** Maximum kept buffer behind in the current position, in seconds. */
    maxBufferBehind: SharedReference<number>;
    /** Maximum size of video buffer , in kiloBytes */
    maxVideoBufferSize: SharedReference<number>;
  };

  /** Information on the current bitrate settings. */
  private readonly _priv_bitrateInfos: {
    /**
     * Store last bitrates for each media type for the adaptive logic.
     * Store the initial wanted bitrates at first.
     */
    lastBitrates: { audio?: number; video?: number; text?: number };
  };

  private _priv_workerData: {
    worker: Worker;
    messageListeners: Map<string, Array<(payload: unknown) => void>>;
  } | null;

  /**
   * Current fatal error which STOPPED the player.
   * `null` if no fatal error was received for the current or last content.
   */
  private _priv_currentError: Error | null;

  /**
   * Information about the current content being played.
   * `null` when no content is currently loading or loaded.
   */
  private _priv_contentInfos: IPublicApiContentInfos | null;

  /** If `true` trickMode video tracks will be chosen if available. */
  private _priv_preferTrickModeTracks: boolean;

  /** Refer to last picture in picture event received. */
  private _priv_pictureInPictureRef: IReadOnlySharedReference<IPictureInPictureEvent>;

  /** Store wanted configuration for the `videoResolutionLimit` option. */
  private readonly _priv_videoResolutionLimit: "videoElement" | "screen" | "none";

  /** Store wanted configuration for the `throttleVideoBitrateWhenHidden` option. */
  private readonly _priv_throttleVideoBitrateWhenHidden: boolean;

  /**
   * Store last state of various values sent as events, to avoid re-triggering
   * them multiple times in a row.
   *
   * All those events are linked to the content being played and can be cleaned
   * on stop.
   */
  private _priv_contentEventsMemory: {
    [P in keyof IPublicAPIEvent]?: IPublicAPIEvent[P];
  };

  /**
   * Information that can be relied on once `reload` is called.
   * It should refer to the last content being played.
   */
  private _priv_reloadingMetadata: {
    /**
     * `loadVideo` options communicated for the last content that will be re-used
     * on reload.
     */
    options?: IParsedLoadVideoOptions;
    /**
     * Manifest loaded for the last content that should be used once `reload`
     * is called.
     */
    manifest?: IManifest;
    /**
     * If `true`, the player should be paused after reloading.
     * If `false`, the player should be playing after reloading.
     * If `undefined`, `reload` should depend on other criteria (such as the
     * `autoPlay` option, to know whether the content should play or not after
     * reloading.
     */
    reloadInPause?: boolean;
    /**
     * If set this is the position that should be seeked to by default after
     * reloading.
     */
    reloadPosition?: number;
  };

  /**
   * Store last value of autoPlay, from the last load or reload.
   */
  private _priv_lastAutoPlay: boolean;

  /** All possible Error types emitted by the RxPlayer. */
  static get ErrorTypes(): Record<IErrorType, IErrorType> {
    return ErrorTypes;
  }

  /** All possible Error codes emitted by the RxPlayer. */
  static get ErrorCodes(): Record<IErrorCode, IErrorCode> {
    return ErrorCodes;
  }

  /**
   * Current log level.
   * Update current log level.
   * Should be either (by verbosity ascending):
   *   - "NONE"
   *   - "ERROR"
   *   - "WARNING"
   *   - "INFO"
   *   - "DEBUG"
   * Any other value will be translated to "NONE".
   */
  static get LogLevel(): string {
    return log.getLevel();
  }
  static set LogLevel(logLevel: string) {
    log.setLevel(logLevel, log.getFormat());
  }

  /**
   * Current log format.
   * Should be either (by verbosity ascending):
   *   - "standard": Regular log messages.
   *   - "full": More verbose format, including a timestamp and a namespace.
   * Any other value will be translated to "standard".
   */
  static get LogFormat(): string {
    return log.getFormat();
  }
  static set LogFormat(format: string) {
    log.setLevel(log.getLevel(), format);
  }

  /**
   * Add feature(s) to the RxPlayer.
   * @param {Array.<Object>} featureList - Features wanted.
   */
  static addFeatures(featureList: IFeature[]): void {
    addFeatures(featureList);
  }

  /**
   * Register the video element to the set of elements currently in use.
   * @param videoElement the video element to register.
   * @throws Error - Throws if the element is already used by another player instance.
   */
  private static _priv_registerVideoElement(videoElement: IMediaElement) {
    if (Player._priv_currentlyUsedVideoElements.has(videoElement)) {
      const errorMessage =
        "The video element is already attached to another RxPlayer instance." +
        "\nMake sure to dispose the previous instance with player.dispose() before creating" +
        " a new player instance attaching that video element.";
      // eslint-disable-next-line no-console
      console.warn(errorMessage);
      /*
       * TODO: for next major version 5.0: this need to throw an error instead of just logging
       * this was not done for minor version as it could be considerated a breaking change.
       *
       * throw new Error(errorMessage);
       */
    }
    Player._priv_currentlyUsedVideoElements.add(videoElement);
  }

  /**
   * Deregister the video element of the set of elements currently in use.
   * @param videoElement the video element to deregister.
   */
  static _priv_deregisterVideoElement(videoElement: IMediaElement) {
    if (Player._priv_currentlyUsedVideoElements.has(videoElement)) {
      Player._priv_currentlyUsedVideoElements.delete(videoElement);
    }
  }

  /**
   * @constructor
   * @param {Object} options
   */
  constructor(options: IConstructorOptions = {}) {
    super();
    const {
      baseBandwidth,
      videoResolutionLimit,
      maxBufferAhead,
      maxBufferBehind,
      throttleVideoBitrateWhenHidden,
      videoElement,
      wantedBufferAhead,
      maxVideoBufferSize,
    } = parseConstructorOptions(options);
    // Workaround to support Firefox autoplay on FF 42.
    // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1194624
    videoElement.preload = "auto";

    this.version = /* PLAYER_VERSION */ "4.5.0";
    this.log = log;
    this.state = "STOPPED";
    this.videoElement = videoElement;
    Player._priv_registerVideoElement(this.videoElement);
    const destroyCanceller = new TaskCanceller("API");
    this._destroyCanceller = destroyCanceller;

    this._priv_pictureInPictureRef = getPictureOnPictureStateRef(
      videoElement,
      destroyCanceller.signal,
    );
    this._priv_speed = new SharedReference(
      videoElement.playbackRate,
      this._destroyCanceller.signal,
    );
    this._priv_preferTrickModeTracks = false;
    this._priv_contentLock = new SharedReference<boolean>(
      false,
      this._destroyCanceller.signal,
    );

    this._priv_bufferOptions = {
      wantedBufferAhead: new SharedReference(
        wantedBufferAhead,
        this._destroyCanceller.signal,
      ),
      maxBufferAhead: new SharedReference(maxBufferAhead, this._destroyCanceller.signal),
      maxBufferBehind: new SharedReference(
        maxBufferBehind,
        this._destroyCanceller.signal,
      ),
      maxVideoBufferSize: new SharedReference(
        maxVideoBufferSize,
        this._destroyCanceller.signal,
      ),
    };

    this._priv_bitrateInfos = {
      lastBitrates: { audio: baseBandwidth, video: baseBandwidth },
    };

    this._priv_throttleVideoBitrateWhenHidden = throttleVideoBitrateWhenHidden;
    this._priv_videoResolutionLimit = videoResolutionLimit;

    this._priv_currentError = null;
    this._priv_contentInfos = null;

    this._priv_contentEventsMemory = {};

    this._priv_reloadingMetadata = {};

    this._priv_lastAutoPlay = false;

    this._priv_workerData = null;

    const onVolumeChange = () => {
      this.trigger("volumeChange", {
        volume: videoElement.volume,
        muted: videoElement.muted,
      });
    };

    videoElement.addEventListener("volumechange", onVolumeChange);
    destroyCanceller.signal.register(() => {
      videoElement.removeEventListener("volumechange", onVolumeChange);
    });
  }

  /**
   * TODO returns promise?
   * @param {Object} workerSettings
   */
  public attachWorker(workerSettings: IWorkerSettings): Promise<void> {
    return new Promise((res, rej) => {
      if (!hasWorkerApi()) {
        log.warn("API", "Cannot rely on a WebWorker: Worker API unavailable");
        return rej(
          new WorkerInitializationError("INCOMPATIBLE_ERROR", "Worker unavailable"),
        );
      }

      // check if the user already attach worker before
      // terminate the previous worker to release the resources
      if (this._priv_workerData !== null) {
        if (this.state !== "STOPPED") {
          log.warn(
            "API",
            "Cannot attach a new worker while a content is playing, please stop the player first.",
          );
          return rej(
            new WorkerInitializationError(
              "SETUP_ERROR",
              "Cannot attach a new worker while a content is playing",
            ),
          );
        } else {
          this._priv_workerData.worker.terminate();
          this._priv_workerData.messageListeners.clear();
          this._priv_workerData = null;
        }
      }

      let workerData: typeof Player.prototype._priv_workerData;
      if (typeof workerSettings.workerUrl === "string") {
        workerData = {
          worker: new Worker(workerSettings.workerUrl),
          messageListeners: new Map(),
        };
      } else if ("postMessage" in workerSettings.workerUrl) {
        workerData = {
          worker: workerSettings.workerUrl,
          messageListeners: new Map(),
        };
      } else {
        const blobUrl = URL.createObjectURL(workerSettings.workerUrl);
        workerData = {
          worker: new Worker(blobUrl),
          messageListeners: new Map(),
        };
        URL.revokeObjectURL(blobUrl);
      }
      this._priv_workerData = workerData;

      this._priv_workerData.worker.onerror = (evt: ErrorEvent) => {
        if (this._priv_workerData !== null) {
          this._priv_workerData.worker.terminate();
          this._priv_workerData.messageListeners.clear();
          this._priv_workerData = null;
        }
        log.error(
          "API",
          "Unexpected worker error",
          evt.error instanceof Error ? evt.error : undefined,
        );
        rej(
          new WorkerInitializationError(
            "UNKNOWN_ERROR",
            'Unexpected Worker "error" event',
          ),
        );
      };
      const handleInitMessages = (msg: MessageEvent) => {
        const msgData = msg.data as unknown as ICoreMessage;
        if (msgData.type === CoreMessageType.InitError) {
          log.warn("API", "Processing InitError worker message: detaching worker");
          if (this._priv_workerData !== null) {
            this._priv_workerData.worker.removeEventListener(
              "message",
              handleInitMessages,
            );
            this._priv_workerData.worker.terminate();
            this._priv_workerData.messageListeners.clear();
            this._priv_workerData = null;
          }
          rej(
            new WorkerInitializationError(
              "SETUP_ERROR",
              "Worker parser initialization failed: " + msgData.value.errorMessage,
            ),
          );
        } else if (msgData.type === CoreMessageType.InitSuccess) {
          log.info("API", "InitSuccess received from worker.");
          if (this._priv_workerData !== null) {
            this._priv_workerData.worker.removeEventListener(
              "message",
              handleInitMessages,
            );
          }
          res();
        }
      };
      const onAppDefinedMessage = (evt: MessageEvent<ICoreMessage>) => {
        if (evt.data.type !== CoreMessageType.AppDefined) {
          return;
        }
        const { name, payload } = evt.data.value;
        const listeners = workerData.messageListeners.get(name);
        if (listeners === undefined) {
          return;
        }
        for (const listener of listeners.slice()) {
          try {
            listener(payload);
          } catch (err) {
            log.error(
              "API",
              "A message listener failed with an error:",
              err instanceof Error ? err : "Unknown Error",
            );
          }
        }
      };
      this._priv_workerData.worker.addEventListener("message", handleInitMessages);
      this._priv_workerData.worker.addEventListener("message", onAppDefinedMessage);

      log.debug("M-->C", "Sending message", { name: MainThreadMessageType.Init });
      this._priv_workerData.worker.postMessage({
        type: MainThreadMessageType.Init,
        value: {
          dashWasmUrl: workerSettings.dashWasmUrl,
          logLevel: log.getLevel(),
          logFormat: log.getFormat(),
          sendBackLogs: isDebugModeEnabled,
          date: Date.now(),
          timestamp: getMonotonicTimeStamp(),
          hasVideo: canHandleVideoTracks(this.videoElement),
        },
      });
      log.addEventListener(
        "onLogLevelChange",
        (logInfo) => {
          if (this._priv_workerData === null) {
            return;
          }
          log.debug("M-->C", "Sending message", {
            name: MainThreadMessageType.LogLevelUpdate,
          });
          this._priv_workerData.worker.postMessage({
            type: MainThreadMessageType.LogLevelUpdate,
            value: {
              logLevel: logInfo.level,
              logFormat: logInfo.format,
              sendBackLogs: isDebugModeEnabled,
            },
          });
        },
        this._destroyCanceller.signal,
      );

      const sendConfigUpdates = (updates: Partial<IDefaultConfig>) => {
        if (this._priv_workerData === null) {
          return;
        }
        log.debug("M-->C", "Sending message:", {
          name: MainThreadMessageType.ConfigUpdate,
        });
        this._priv_workerData.worker.postMessage({
          type: MainThreadMessageType.ConfigUpdate,
          value: updates,
        });
      };
      if (config.updated) {
        sendConfigUpdates(config.getCurrent());
      }
      config.addEventListener("update", sendConfigUpdates, this._destroyCanceller.signal);
    });
  }

  /**
   * Construct Object allowing to exchange message with the worker associated to
   * this RxPlayer instance.
   * @returns {Object|null} Object allowing communication with the Worker
   * associated to an `RxPlayer`'s instance.
   * `null` if no worker is attached currently.
   */
  public getWorkerInterface(): IWorkerInterface | null {
    if (this._priv_workerData === null) {
      return null;
    }
    const workerData = this._priv_workerData;
    /** @see IWorkerInterface */
    return {
      sendMessage(messageName: string, payload: unknown) {
        workerData.worker.postMessage({
          type: MainThreadMessageType.AppDefined,
          value: {
            name: messageName,
            payload,
          },
        });
      },
      addMessageListener(messageName: string, callback: (x: unknown) => void) {
        const prev = workerData.messageListeners.get(messageName);
        if (prev === undefined) {
          workerData.messageListeners.set(messageName, [callback]);
        } else {
          workerData.messageListeners.set(messageName, [...prev, callback]);
        }
      },
      removeMessageListener(messageName: string, callback: (x: unknown) => void) {
        const prev = workerData.messageListeners.get(messageName);
        if (prev === undefined) {
          return;
        }
        while (true) {
          const indexOf = prev.indexOf(callback);
          if (indexOf < 0) {
            return;
          }
          prev.splice(indexOf, 1);
          if (prev.length === 0) {
            workerData.messageListeners.delete(messageName);
            return;
          }
        }
      },
    };
  }

  /**
   * Returns information on which "mode" the RxPlayer is running for the current
   * content (e.g. main logic running in a WebWorker or not, are we in
   * directfile mode...).
   *
   * Returns `null` if no content is loaded.
   * @returns {Object|null}
   */
  public getCurrentModeInformation(): IModeInformation | null {
    if (this._priv_contentInfos === null) {
      return null;
    }
    return {
      isDirectFile: this._priv_contentInfos.isDirectFile,
      useWorker: this._priv_contentInfos.useWorker,
    };
  }

  /**
   * Register a new callback for a player event event.
   *
   * @param {string} evt - The event to register a callback to
   * @param {Function} fn - The callback to call as that event is triggered.
   * The callback will take as argument the eventual payload of the event
   * (single argument).
   */
  addEventListener<TEventName extends keyof IPublicAPIEvent>(
    evt: TEventName,
    fn: IListener<IPublicAPIEvent, TEventName>,
  ): void {
    // The EventEmitter's `addEventListener` method takes an optional third
    // argument that we do not want to expose in the public API.
    // We thus overwrite that function to remove any possible usage of that
    // third argument.
    return super.addEventListener(evt, fn);
  }

  /**
   * Stop the playback for the current content.
   */
  stop(): void {
    if (this._priv_contentInfos !== null) {
      this._priv_contentInfos.currentContentCanceller.cancel("API stop");
    }
    this._priv_cleanUpCurrentContentState();
    if (this.state !== PLAYER_STATES.STOPPED) {
      this._priv_setPlayerState(PLAYER_STATES.STOPPED);
    }
  }

  /**
   * Free the resources used by the player.
   * /!\ The player cannot be "used" anymore after this method has been called.
   */
  dispose(): void {
    // free resources linked to the loaded content
    this.stop();

    if (this.videoElement !== null) {
      Player._priv_deregisterVideoElement(this.videoElement);
      // free resources used for decryption management
      disposeDecryptionResources(this.videoElement).catch((err: unknown) => {
        const message = err instanceof Error ? err.message : "Unknown error";
        log.error("API", "Could not dispose decryption resources: " + message);
      });
    }

    // free resources linked to the Player instance
    this._destroyCanceller.cancel("API destroy");

    this._priv_reloadingMetadata = {};

    // un-attach video element
    this.videoElement = null;

    if (this._priv_workerData !== null) {
      this._priv_workerData.worker.terminate();
      this._priv_workerData.messageListeners.clear();
      this._priv_workerData = null;
    }
  }

  /**
   * Load a new video.
   * @param {Object} opts
   */
  loadVideo(opts: ILoadVideoOptions): void {
    const options = parseLoadVideoOptions(opts);
    log.info("API", "Calling loadvideo", {
      url: options.url,
      transport: options.transport,
    });
    this._priv_reloadingMetadata = { options };
    this._priv_initializeContentPlayback(options);
    this._priv_lastAutoPlay = options.autoPlay;
  }

  /**
   * Reload the last loaded content.
   * @param {Object} reloadOpts
   */
  reload(reloadOpts?: {
    reloadAt?: { position?: number; relative?: number };
    keySystems?: IKeySystemOption[];
    autoPlay?: boolean;
  }): void {
    const { options, manifest, reloadPosition, reloadInPause } =
      this._priv_reloadingMetadata;
    if (options === undefined) {
      throw new Error("API: Can't reload without having previously loaded a content.");
    }
    checkReloadOptions(reloadOpts);
    let startAt: IParsedStartAtOption | undefined;
    if (reloadOpts?.reloadAt?.position !== undefined) {
      startAt = { position: reloadOpts.reloadAt.position };
    } else if (reloadOpts?.reloadAt?.relative !== undefined) {
      if (reloadPosition === undefined) {
        log.warn(
          "API",
          "reload API > reloadAt.relative option given but we don't know the previous content's position",
        );
      } else {
        startAt = { position: reloadOpts.reloadAt.relative + reloadPosition };
      }
    } else if (reloadPosition !== undefined) {
      startAt = { position: reloadPosition };
    }

    let autoPlay: boolean | undefined;
    if (reloadOpts?.autoPlay !== undefined) {
      autoPlay = reloadOpts.autoPlay;
    } else if (reloadInPause !== undefined) {
      autoPlay = !reloadInPause;
    }

    let keySystems: IKeySystemOption[] | undefined;
    if (reloadOpts?.keySystems !== undefined) {
      keySystems = reloadOpts.keySystems;
    } else if (this._priv_reloadingMetadata.options?.keySystems !== undefined) {
      keySystems = this._priv_reloadingMetadata.options.keySystems;
    }

    const newOptions = { ...options, initialManifest: manifest };
    if (startAt !== undefined) {
      newOptions.startAt = startAt;
    }
    if (autoPlay !== undefined) {
      newOptions.autoPlay = autoPlay;
    }
    if (keySystems !== undefined) {
      newOptions.keySystems = keySystems;
    }
    this._priv_initializeContentPlayback(newOptions);
  }

  public createDebugElement(element: HTMLElement): {
    dispose(): void;
  } {
    if (features.createDebugElement === null) {
      throw new Error("Feature `DEBUG_ELEMENT` not added to the RxPlayer");
    }
    const canceller = new TaskCanceller("API debug element");
    features.createDebugElement(element, this, canceller.signal);
    return {
      dispose() {
        canceller.cancel("API debug dispose");
      },
    };
  }

  /**
   * Returns an array decribing the various thumbnail tracks that can be
   * encountered at the wanted time or Period.
   * @param {Object} arg
   * @param {number|undefined} [arg.time] - The position to check for thumbnail
   * tracks, in seconds.
   * @param {string|undefined} [arg.periodId] - The Period to check for
   * thumbnail tracks.
   * If not set and if `arg.time` is also not set, the current Period will be
   * considered.
   * @returns {Array.<Object>}
   */
  public getAvailableThumbnailTracks({
    time,
    periodId,
  }: {
    time?: number | undefined;
    periodId?: string | undefined;
  } = {}): IThumbnailTrackInfo[] {
    if (this._priv_contentInfos === null || this._priv_contentInfos.manifest === null) {
      return [];
    }
    const { manifest } = this._priv_contentInfos;
    let period;
    if (time !== undefined) {
      period = getPeriodForTime(this._priv_contentInfos.manifest, time);
      if (period === undefined || period.thumbnailTracks.length === 0) {
        return [];
      }
    } else if (periodId !== undefined) {
      period = arrayFind(manifest.periods, (p) => p.id === periodId);
      if (period === undefined) {
        log.error("API", "getAvailableThumbnailTracks: periodId not found", { periodId });
        return [];
      }
    } else {
      const { currentPeriod } = this._priv_contentInfos;
      if (currentPeriod === null) {
        return [];
      }
      period = currentPeriod;
    }
    return period.thumbnailTracks.map((t) => {
      return {
        id: t.id,
        width: Math.floor(t.width / t.horizontalTiles),
        height: Math.floor(t.height / t.verticalTiles),
        mimeType: t.mimeType,
      };
    });
  }

  /**
   * Render inside the given `container` the thumbnail corresponding to the
   * given time.
   *
   * If no thumbnail is available at that time or if the RxPlayer does not succeed
   * to load or render it, reject the corresponding Promise and remove the
   * potential previous thumbnail from the container.
   *
   * If a new `renderThumbnail` call is made with the same `container` before it
   * had time to finish, the Promise is also rejected but the previous thumbnail
   * potentially found in the container is untouched.
   *
   * @param {Object|undefined} options
   * @returns {Promise}
   */
  public async renderThumbnail(options: IThumbnailRenderingOptions): Promise<void> {
    if (isNullOrUndefined(options.time)) {
      throw new Error(
        "You have to provide a `time` property to `renderThumbnail`, indicating the wanted thumbnail time in seconds.",
      );
    }
    if (isNullOrUndefined(options.container)) {
      throw new Error(
        "You have to provide a `container` property to `renderThumbnail`, specifying the HTML Element in which the thumbnail should be inserted.",
      );
    }
    return renderThumbnail(this._priv_contentInfos, options);
  }

  /**
   * From given options, initialize content playback.
   * @param {Object} options
   */
  private _priv_initializeContentPlayback(options: IParsedLoadVideoOptions): void {
    const {
      autoPlay,
      cmcd,
      defaultAudioTrackSwitchingMode,
      enableFastSwitching,
      initialManifest,
      keySystems,
      lowLatencyMode,
      minimumManifestUpdateInterval,
      requestConfig,
      onCodecSwitch,
      startAt,
      transport,
      checkMediaSegmentIntegrity,
      checkManifestIntegrity,
      manifestLoader,
      referenceDateTime,
      segmentLoader,
      serverSyncInfos,
      experimentalOptions,
      __priv_manifestUpdateUrl,
      __priv_patchLastSegmentInSidx,
      url,
      onAudioTracksNotPlayable,
      onVideoTracksNotPlayable,
    } = options;

    // Perform multiple checks on the given options
    if (this.videoElement === null) {
      throw new Error("the attached video element is disposed");
    }

    const isDirectFile = transport === "directfile";

    /** Emit to stop the current content. */
    const currentContentCanceller = new TaskCanceller("API current content");

    const videoElement = this.videoElement;

    let initializer: ContentInitializer;

    let useWorker = false;
    let mediaElementTracksStore: IMediaElementTracksStore | null = null;
    if (!isDirectFile) {
      /** Interface used to load and refresh the Manifest. */
      const manifestRequestSettings = {
        lowLatencyMode,
        maxRetry: requestConfig.manifest?.maxRetry,
        requestTimeout: requestConfig.manifest?.timeout,
        connectionTimeout: requestConfig.manifest?.connectionTimeout,
        minimumManifestUpdateInterval,
        initialManifest,
      };

      const relyOnVideoVisibilityAndSize = canRelyOnVideoVisibilityAndSize();
      const throttlers: IABRThrottlers = {
        throttleBitrate: {},
        limitResolution: {},
      };

      if (this._priv_throttleVideoBitrateWhenHidden) {
        if (!relyOnVideoVisibilityAndSize) {
          log.warn(
            "API",
            "Can't apply throttleVideoBitrateWhenHidden because " +
              "browser can't be trusted for visibility.",
          );
        } else {
          throttlers.throttleBitrate = {
            video: createMappedReference(
              getVideoVisibilityRef(
                this._priv_pictureInPictureRef,
                currentContentCanceller.signal,
              ),
              (isActive) => (isActive ? Infinity : 0),
              currentContentCanceller.signal,
            ),
          };
        }
      }
      if (this._priv_videoResolutionLimit === "videoElement") {
        if (!relyOnVideoVisibilityAndSize) {
          log.warn(
            "API",
            "Can't apply videoResolutionLimit because browser can't be " +
              "trusted for video size.",
          );
        } else {
          throttlers.limitResolution = {
            video: getElementResolutionRef(
              videoElement,
              this._priv_pictureInPictureRef,
              currentContentCanceller.signal,
            ),
          };
        }
      } else if (this._priv_videoResolutionLimit === "screen") {
        throttlers.limitResolution = {
          video: getScreenResolutionRef(currentContentCanceller.signal),
        };
      }

      /** Options used by the adaptive logic. */
      const adaptiveOptions = {
        initialBitrates: this._priv_bitrateInfos.lastBitrates,
        lowLatencyMode,
        throttlers,
      };

      /** Options used by the TextTrack SegmentSink. */
      const textTrackOptions =
        options.textTrackMode === "native"
          ? { textTrackMode: "native" as const }
          : {
              textTrackMode: "html" as const,
              textTrackElement: options.textTrackElement,
            };

      const bufferOptions = objectAssign(
        { enableFastSwitching, onCodecSwitch },
        this._priv_bufferOptions,
      );

      const segmentRequestOptions = {
        lowLatencyMode,
        maxRetry: requestConfig.segment?.maxRetry,
        requestTimeout: requestConfig.segment?.timeout,
        connectionTimeout: requestConfig.segment?.connectionTimeout,
      };

      const transportOptions = {
        lowLatencyMode,
        checkMediaSegmentIntegrity,
        checkManifestIntegrity,
        referenceDateTime,
        serverSyncInfos,
        manifestLoader,
        segmentLoader,
        representationFilter: options.representationFilter,
        __priv_manifestUpdateUrl,
        __priv_patchLastSegmentInSidx,
      };

      if (!this._priv_shouldLoadMultithread(options)) {
        if (features.monothread === null) {
          throw new Error(
            "Cannot load video, neither in a WebWorker nor with the " +
              "`MEDIA_SOURCE_MAIN` feature",
          );
        }

        // Small checks, log if some option seems wrong
        if (
          typeof transportOptions.representationFilter?.workerId === "string" &&
          (isNullOrUndefined(transportOptions.representationFilter.fn) ||
            isNullOrUndefined(transportOptions.representationFilter.eval))
        ) {
          log.warn(
            "API",
            "You only set a `workerId` for a `representationFilter` in monothreaded mode. Ignoring it...",
          );
        }
        if (
          typeof transportOptions.manifestLoader?.workerId === "string" &&
          isNullOrUndefined(transportOptions.manifestLoader.fn)
        ) {
          log.warn(
            "API",
            "You only set a `workerId` for a `manifestLoader` in monothreaded mode. Ignoring it...",
          );
        }
        if (
          typeof transportOptions.segmentLoader?.workerId === "string" &&
          isNullOrUndefined(transportOptions.segmentLoader.fn)
        ) {
          log.warn(
            "API",
            "You only set a `workerId` for a `segmentLoader` in monothreaded mode. Ignoring it...",
          );
        }

        log.info("API", "Initializing MediaSource mode in the main thread");
        const coreInterface = new features.monothread.coreInterface();
        const coreInterfaceCallbacks = coreInterface.getCallbacks();
        features.monothread.initializeCoreEntry(
          coreInterfaceCallbacks.setCoreMessageReceiver,
          coreInterfaceCallbacks.sendCoreMessage,
          {
            // No need for core-side plugins in monothread mode
            representationFilters: new Map(),
            segmentLoaders: new Map(),
            manifestLoaders: new Map(),
          },
        );
        coreInterface.sendMessage({
          type: MainThreadMessageType.Init,
          value: {
            dashWasmUrl: undefined,
            hasVideo: canHandleVideoTracks(this.videoElement),
            logLevel: log.getLevel(),
            logFormat: log.getFormat(),
            sendBackLogs: false,
            date: Date.now(),
            timestamp: getMonotonicTimeStamp(),
          },
        });
        initializer = new features.monothread.init({
          coreInterface,
          adaptiveOptions,
          autoPlay,
          bufferOptions,
          cmcd,
          enableRepresentationAvoidance:
            experimentalOptions.enableRepresentationAvoidance,
          keySystems,
          lowLatencyMode,
          transport,
          transportOptions,
          manifestRequestSettings,
          segmentRequestOptions,
          speed: this._priv_speed,
          startAt,
          textTrackOptions,
          url,
          useMseInWorker: false,
        });
      } else {
        if (features.multithread === null) {
          throw new Error(
            "Cannot load video in multithread mode: `MULTI_THREAD` " +
              "feature not imported.",
          );
        } else if (this._priv_workerData === null) {
          throw new Error(
            "Cannot load video in multithread mode: `attachWorker` " +
              "method not called.",
          );
        }

        // Small checks, log if some option seems wrong
        if (
          transportOptions.representationFilter !== undefined &&
          !isNullOrUndefined(transportOptions.representationFilter.fn)
        ) {
          transportOptions.representationFilter.fn = undefined;
          if (
            isNullOrUndefined(transportOptions.representationFilter.workerId) &&
            isNullOrUndefined(transportOptions.representationFilter.eval)
          ) {
            log.warn(
              "API",
              "You set a `representationFilter` function without a `workerId` while loading in multithread mode. That function cannot run in the worker, so it will be ignored.",
            );
          }
        }
        if (
          transportOptions.manifestLoader !== undefined &&
          !isNullOrUndefined(transportOptions.manifestLoader.fn)
        ) {
          transportOptions.manifestLoader.fn = undefined;
          if (isNullOrUndefined(transportOptions.manifestLoader.workerId)) {
            log.warn(
              "API",
              "You set a `manifestLoader` function without a `workerId` while loading in multithread mode. That function cannot run in the worker, so it will be ignored.",
            );
          }
        }
        if (
          transportOptions.segmentLoader !== undefined &&
          !isNullOrUndefined(transportOptions.segmentLoader.fn)
        ) {
          transportOptions.segmentLoader.fn = undefined;
          if (isNullOrUndefined(transportOptions.segmentLoader.workerId)) {
            log.warn(
              "API",
              "You set a `segmentLoader` function without a `workerId` while loading in multithread mode. That function cannot run in the worker, so it will be ignored.",
            );
          }
        }

        // `initialManifest` is not supported in multithread mode.
        manifestRequestSettings.initialManifest = undefined;
        useWorker = true;
        log.info("API", "Initializing MediaSource mode in a WebWorker");
        initializer = new features.multithread.init({
          coreInterface: new features.multithread.coreInterface(
            this._priv_workerData.worker,
          ),
          adaptiveOptions,
          autoPlay,
          bufferOptions,
          cmcd,
          enableRepresentationAvoidance:
            experimentalOptions.enableRepresentationAvoidance,
          keySystems,
          lowLatencyMode,
          transport,
          transportOptions,
          manifestRequestSettings,
          segmentRequestOptions,
          speed: this._priv_speed,
          startAt,
          textTrackOptions,
          url,
          useMseInWorker: hasMseInWorker,
        });
      }
    } else {
      if (features.directfile === null) {
        this.stop();
        this._priv_currentError = null;
        throw new Error("DirectFile feature not activated in your build.");
      } else if (isNullOrUndefined(url)) {
        throw new Error("No URL for a DirectFile content");
      }

      log.info("API", "Initializing DirectFile mode in the main thread");
      mediaElementTracksStore = this._priv_initializeMediaElementTracksStore(
        currentContentCanceller.signal,
      );
      if (currentContentCanceller.isUsed()) {
        return;
      }
      initializer = new features.directfile.initDirectFile({
        autoPlay,
        keySystems,
        speed: this._priv_speed,
        startAt,
        url,
      });
    }

    /** Global "playback observer" which will emit playback conditions */
    const playbackObserver = new MediaElementPlaybackObserver({
      withMediaSource: !isDirectFile,
      lowLatencyMode,
    });

    /*
     * We want to block seeking operations until we know the media element is
     * ready for it.
     */
    playbackObserver.blockSeeking();

    currentContentCanceller.signal.register((err) => {
      playbackObserver.stop(err.reason);
    });

    /** Future `this._priv_contentInfos` related to this content. */
    const contentInfos: IPublicApiContentInfos = {
      contentId: generateContentId(),
      originalUrl: url,
      playbackObserver,
      currentContentCanceller,
      defaultAudioTrackSwitchingMode,
      initializer,
      isDirectFile,
      manifest: null,
      currentPeriod: null,
      activeAdaptations: null,
      activeRepresentations: null,
      tracksStore: null,
      mediaElementTracksStore,
      handledTrackTypes: {
        audio: true,
        video: canHandleVideoTracks(this.videoElement),
        text: canHandleTextTracks(options),
      },
      useWorker,
      segmentSinkMetricsCallback: null,
      fetchThumbnailDataCallback: null,
      thumbnailRequestsInfo: {
        pendingRequests: new WeakMap(),
        lastResponse: null,
      },
      onAudioTracksNotPlayable,
      onVideoTracksNotPlayable,
    };

    // Bind events
    initializer.addEventListener("error", (error) => {
      this._priv_onFatalError(error, contentInfos);
    });
    initializer.addEventListener("warning", (error) => {
      const formattedError = formatError(error, {
        defaultCode: "NONE",
        defaultReason: "An unknown error happened.",
      });
      log.warn("API", "Sending warning:", formattedError);
      this.trigger("warning", formattedError);
    });
    initializer.addEventListener("reloadingMediaSource", (payload) => {
      if (contentInfos.tracksStore !== null) {
        contentInfos.tracksStore.resetPeriodObjects();
      }
      if (this._priv_contentInfos !== null) {
        this._priv_contentInfos.segmentSinkMetricsCallback = null;
      }
      this._priv_lastAutoPlay = payload.autoPlay;
    });
    initializer.addEventListener("inbandEvents", (inbandEvents) =>
      this.trigger("inbandEvents", inbandEvents),
    );
    initializer.addEventListener("streamEvent", (streamEvent) =>
      this.trigger("streamEvent", streamEvent),
    );
    initializer.addEventListener("streamEventSkip", (streamEventSkip) =>
      this.trigger("streamEventSkip", streamEventSkip),
    );
    initializer.addEventListener("activePeriodChanged", (periodInfo) =>
      this._priv_onActivePeriodChanged(contentInfos, periodInfo),
    );
    initializer.addEventListener("periodStreamReady", (periodReadyInfo) =>
      this._priv_onPeriodStreamReady(contentInfos, periodReadyInfo),
    );
    initializer.addEventListener("periodStreamCleared", (periodClearedInfo) =>
      this._priv_onPeriodStreamCleared(contentInfos, periodClearedInfo),
    );
    initializer.addEventListener("representationChange", (representationInfo) =>
      this._priv_onRepresentationChange(contentInfos, representationInfo),
    );
    initializer.addEventListener("adaptationChange", (adaptationInfo) =>
      this._priv_onAdaptationChange(contentInfos, adaptationInfo),
    );
    initializer.addEventListener("bitrateEstimateChange", (bitrateEstimateInfo) =>
      this._priv_onBitrateEstimateChange(bitrateEstimateInfo),
    );
    initializer.addEventListener("manifestReady", (manifest) =>
      this._priv_onManifestReady(contentInfos, manifest),
    );
    initializer.addEventListener("manifestUpdate", (updates) =>
      this._priv_onManifestUpdate(contentInfos, updates),
    );

    initializer.addEventListener("codecSupportUpdate", () =>
      this._priv_onCodecSupportUpdate(contentInfos),
    );
    initializer.addEventListener("decipherabilityUpdate", (updates) =>
      this._priv_onDecipherabilityUpdate(contentInfos, updates),
    );
    initializer.addEventListener("loaded", (evt) => {
      if (this._priv_contentInfos !== null) {
        this._priv_contentInfos.segmentSinkMetricsCallback = evt.getSegmentSinkMetrics;
        this._priv_contentInfos.fetchThumbnailDataCallback = evt.getThumbnailData;
      }
    });

    // Now, that most events are linked, prepare the next content.
    initializer.prepare();

    // Now that the content is prepared, stop previous content and reset state
    // This is done after content preparation as `stop` could technically have
    // a long and synchronous blocking time.
    // Note that this call is done **synchronously** after all events linking.
    // This is **VERY** important so:
    //   - the `STOPPED` state is switched to synchronously after loading a new
    //     content.
    //   - we can avoid involontarily catching events linked to the previous
    //     content.
    this.stop();

    playbackObserver.attachMediaElement(videoElement);

    // Update the RxPlayer's state at the right events
    const playerStateRef = constructPlayerStateReference(
      initializer,
      videoElement,
      playbackObserver,
      isDirectFile,
      currentContentCanceller.signal,
    );
    currentContentCanceller.signal.register((err) => {
      initializer.dispose(err.reason);
    });

    /**
     * Function updating `this._priv_reloadingMetadata` in function of the
     * current state and playback conditions.
     * To call when either might change.
     * @param {string} state - The player state we're about to switch to.
     */
    const updateReloadingMetadata = (state: IPlayerState) => {
      switch (state) {
        case "STOPPED":
        case "RELOADING":
        case "LOADING":
          break; // keep previous metadata
        case "ENDED":
          this._priv_reloadingMetadata.reloadInPause = true;
          this._priv_reloadingMetadata.reloadPosition = playbackObserver
            .getReference()
            .getValue()
            .position.getPolled();
          break;
        default: {
          const o = playbackObserver.getReference().getValue();
          this._priv_reloadingMetadata.reloadInPause = o.paused;
          this._priv_reloadingMetadata.reloadPosition = o.position.getWanted();
          break;
        }
      }
    };

    /**
     * `TaskCanceller` allowing to stop emitting `"play"` and `"pause"`
     * events.
     * `null` when such events are not emitted currently.
     */
    let playPauseEventsCanceller: TaskCanceller | null = null;

    /**
     * Callback emitting `"play"` and `"pause`" events once the content is
     * loaded, starting from the state indicated in argument.
     * @param {boolean} willAutoPlay - If `false`, we're currently paused.
     */
    const triggerPlayPauseEventsWhenReady = (willAutoPlay: boolean) => {
      if (playPauseEventsCanceller !== null) {
        playPauseEventsCanceller.cancel("reset"); // cancel previous logic
        playPauseEventsCanceller = null;
      }
      playerStateRef.onUpdate(
        (val, stopListeningToStateUpdates) => {
          if (!isLoadedState(val)) {
            return; // content not loaded yet: no event
          }
          stopListeningToStateUpdates();
          if (playPauseEventsCanceller !== null) {
            playPauseEventsCanceller.cancel("reset");
          }
          playPauseEventsCanceller = new TaskCanceller("API play/pause events");
          playPauseEventsCanceller.linkToSignal(currentContentCanceller.signal);
          if (willAutoPlay !== !videoElement.paused) {
            // paused status is not at the expected value on load: emit event
            if (videoElement.paused) {
              this.trigger("pause", null);
            } else {
              this.trigger("play", null);
            }
          }
          emitPlayPauseEvents(
            videoElement,
            () => this.trigger("play", null),
            () => this.trigger("pause", null),
            currentContentCanceller.signal,
          );
        },
        {
          emitCurrentValue: false,
          clearSignal: currentContentCanceller.signal,
        },
      );
    };

    triggerPlayPauseEventsWhenReady(autoPlay);
    initializer.addEventListener("reloadingMediaSource", (payload) => {
      triggerPlayPauseEventsWhenReady(payload.autoPlay);
    });

    this._priv_currentError = null;
    this._priv_contentInfos = contentInfos;

    /**
     * `TaskCanceller` allowing to stop emitting `"seeking"` and `"seeked"`
     * events.
     * `null` when such events are not emitted currently.
     */
    let seekEventsCanceller: TaskCanceller | null = null;

    // React to player state change
    playerStateRef.onUpdate(
      (newState: IPlayerState) => {
        updateReloadingMetadata(newState);
        this._priv_setPlayerState(newState);

        if (currentContentCanceller.isUsed()) {
          return;
        }

        if (seekEventsCanceller !== null) {
          if (!isLoadedState(this.state)) {
            seekEventsCanceller.cancel("Player State Update");
            seekEventsCanceller = null;
          }
        } else if (isLoadedState(this.state)) {
          seekEventsCanceller = new TaskCanceller("API seek events");
          seekEventsCanceller.linkToSignal(currentContentCanceller.signal);
          emitSeekEvents(
            playbackObserver,
            () => this.trigger("seeking", null),
            () => this.trigger("seeked", null),
            seekEventsCanceller.signal,
          );
        }
      },
      { emitCurrentValue: true, clearSignal: currentContentCanceller.signal },
    );

    // React to playback conditions change
    playbackObserver.listen(
      (observation) => {
        updateReloadingMetadata(this.state);
        this._priv_triggerPositionUpdate(contentInfos, observation);
      },
      { clearSignal: currentContentCanceller.signal },
    );

    currentContentCanceller.signal.register(() => {
      initializer.removeEventListener();
    });

    // initialize the content only when the lock is inactive
    this._priv_contentLock.onUpdate(
      (isLocked, stopListeningToLock) => {
        if (!isLocked) {
          stopListeningToLock();

          // start playback!
          initializer.start(videoElement, playbackObserver);
        }
      },
      { emitCurrentValue: true, clearSignal: currentContentCanceller.signal },
    );
  }

  /**
   * Returns fatal error if one for the current content.
   * null otherwise.
   * @returns {Object|null} - The current Error (`null` when no error).
   */
  getError(): Error | null {
    return this._priv_currentError;
  }

  /**
   * Returns the media DOM element used by the player.
   * You should not its HTML5 API directly and use the player's method instead,
   * to ensure a well-behaved player.
   * @returns {HTMLMediaElement|null} - The HTMLMediaElement used (`null` when
   * disposed)
   */
  // eslint-disable-next-line @typescript-eslint/no-restricted-types
  getVideoElement(): HTMLMediaElement | null {
    // eslint-disable-next-line @typescript-eslint/no-restricted-types
    return this.videoElement as HTMLMediaElement;
  }

  /**
   * Returns the player's current state.
   * @returns {string} - The current Player's state
   */
  getPlayerState(): string {
    return this.state;
  }

  /**
   * Returns true if a content is loaded.
   * @returns {Boolean} - `true` if a content is loaded, `false` otherwise.
   */
  isContentLoaded(): boolean {
    return !arrayIncludes(["LOADING", "RELOADING", "STOPPED"], this.state);
  }

  /**
   * Returns true if the player is buffering.
   * @returns {Boolean} - `true` if the player is buffering, `false` otherwise.
   */
  isBuffering(): boolean {
    return arrayIncludes(["BUFFERING", "SEEKING", "LOADING", "RELOADING"], this.state);
  }

  /**
   * Returns the play/pause status of the player :
   *   - when `LOADING` or `RELOADING`, returns the scheduled play/pause condition
   *     for when loading is over,
   *   - in other states, returns the `<video>` element .paused value,
   *   - if the player is disposed, returns `true`.
   * @returns {Boolean} - `true` if the player is paused or will be after loading,
   * `false` otherwise.
   */
  isPaused(): boolean {
    if (this.videoElement !== null) {
      if (arrayIncludes(["LOADING", "RELOADING"], this.state)) {
        return !this._priv_lastAutoPlay;
      } else {
        return this.videoElement.paused;
      }
    }
    return true;
  }

  /**
   * Returns true if both:
   *   - a content is loaded
   *   - the content loaded is a live content
   * @returns {Boolean} - `true` if we're playing a live content, `false` otherwise.
   */
  isLive(): boolean {
    if (this._priv_contentInfos === null) {
      return false;
    }
    const { isDirectFile, manifest } = this._priv_contentInfos;
    if (isDirectFile || manifest === null) {
      return false;
    }
    return manifest.isLive;
  }

  /**
   * Returns `true` if trickmode playback is active (usually through the usage
   * of the `setPlaybackRate` method), which means that the RxPlayer selects
   * "trickmode" video tracks in priority.
   * @returns {Boolean}
   */
  areTrickModeTracksEnabled(): boolean {
    return this._priv_preferTrickModeTracks;
  }

  /**
   * Returns the URL(s) of the currently considered Manifest, or of the content for
   * directfile content.
   * @returns {Array.<string>|undefined} - Current URL. `undefined` if not known
   * or no URL yet.
   */
  getContentUrls(): string[] | undefined {
    if (this._priv_contentInfos === null) {
      return undefined;
    }
    const { isDirectFile, manifest, originalUrl } = this._priv_contentInfos;
    if (isDirectFile) {
      return originalUrl === undefined ? undefined : [originalUrl];
    }
    if (manifest !== null) {
      return manifest.uris;
    }
    return undefined;
  }

  /**
   * Update URL of the content currently being played (e.g. DASH's MPD).
   * @param {Array.<string>|undefined} urls - URLs to reach that content /
   * Manifest from the most prioritized URL to the least prioritized URL.
   * @param {Object|undefined} [params]
   * @param {boolean} params.refresh - If `true` the resource in question
   * (e.g. DASH's MPD) will be refreshed immediately.
   */
  public updateContentUrls(
    urls: string[] | undefined,
    params?: { refresh?: boolean } | undefined,
  ): void {
    if (this._priv_contentInfos === null) {
      throw new Error("No content loaded");
    }
    const refreshNow = params?.refresh === true;
    this._priv_contentInfos.initializer.updateContentUrls(urls, refreshNow);
  }

  /**
   * Returns the video duration, in seconds.
   * NaN if no video is playing.
   * @returns {Number}
   */
  getMediaDuration(): number {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    return this.videoElement.duration;
  }

  /**
   * Returns in seconds the difference between:
   *   - the end of the current contiguous loaded range.
   *   - the current time
   * @returns {Number}
   */
  getCurrentBufferGap(): number {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    const videoElement = this.videoElement;
    const bufferGap = getLeftSizeOfBufferedTimeRange(
      videoElement.buffered,
      videoElement.currentTime,
    );
    if (bufferGap === Infinity) {
      return 0;
    }
    return bufferGap;
  }

  /**
   * Get the current position, in s, in wall-clock time.
   * That is:
   *   - for live content, get a timestamp, in s, of the current played content.
   *   - for static content, returns the position from beginning in s.
   *
   * If you do not know if you want to use this method or getPosition:
   *   - If what you want is to display the current time to the user, use this
   *     one.
   *   - If what you want is to interact with the player's API or perform other
   *     actions (like statistics) with the real player data, use getPosition.
   *
   * @returns {Number}
   */
  getWallClockTime(): number {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    if (this._priv_contentInfos === null) {
      return this.videoElement.currentTime;
    }
    const wallClockOffset = this.getWallClockOffset();
    const currentTime = this.videoElement.currentTime;
    return currentTime + wallClockOffset;
  }

  /**
   * Get value allowing to convert the media position, as returned by
   * `getPosition` to a "wall-clock time", as returned by `getWallClockTime`, by
   * additioning the first two:
   * ```js
   * player.getPosition() + player.getWallClockOffset() === player.getWallClockTime();
   * ```
   *
   * As most of the RxPlayer API relies on the media position as returned by
   * `getPosition`, you may want to use this method to simplify conversion if
   * what you have right now is a wall-clock time. For example:
   *
   * ```js
   * const wallClockTime = player.getWallClockTime();
   *
   * // This one is expressed as a media position
   * const maximumPosition = player.getMaximumPosition();
   *
   * // convert wall-clock time to a media position
   * const currentPosition = wallClockTime - player.getWallClockOffset();
   *
   * console.log(
   *   "delay from maximum position, in seconds",
   *   maximumPosition - currentPosition
   * );
   * ```
   *
   * Note that there's nothing guaranteeing that this offset won't evolve over
   * time for a given content. You should call this method every time you need
   * to perform the conversion.
   * @returns {number}
   */
  getWallClockOffset(): number {
    if (this.videoElement === null) {
      return 0;
    }
    if (this._priv_contentInfos === null) {
      return 0;
    }

    const { isDirectFile, manifest } = this._priv_contentInfos;
    if (isDirectFile) {
      const startDate = getStartDate(this.videoElement);
      return startDate ?? 0;
    }
    if (manifest !== null) {
      return manifest.availabilityStartTime ?? 0;
    }
    return 0;
  }

  /**
   * Get the current position, in seconds, of the video element.
   *
   * If you do not know if you want to use this method or getWallClockTime:
   *   - If what you want is to display the current time to the user, use
   *     getWallClockTime.
   *   - If what you want is to interact with the player's API or perform other
   *     actions (like statistics) with the real player data, use this one.
   *
   * @returns {Number}
   */
  getPosition(): number {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    return this.videoElement.currentTime;
  }

  /**
   * Returns the last stored content position, in seconds.
   *
   * @returns {number|undefined}
   */
  getLastStoredContentPosition(): number | undefined {
    return this._priv_reloadingMetadata.reloadPosition;
  }

  /**
   * Returns the current playback rate at which the video plays.
   * @returns {Number}
   */
  getPlaybackRate(): number {
    return this._priv_speed.getValue();
  }

  /**
   * Update the playback rate of the video.
   *
   * This method's effect is persisted from content to content, and can be
   * called even when no content is playing (it will still have an effect for
   * the next contents).
   *
   * If you want to reverse effects provoked by `setPlaybackRate` before playing
   * another content, you will have to call `setPlaybackRate` first with the
   * default settings you want to set.
   *
   * As an example, to reset the speed to "normal" (x1) speed and to disable
   * trickMode video tracks (which may have been enabled by a previous
   * `setPlaybackRate` call), you can call:
   * ```js
   * player.setPlaybackRate(1, { preferTrickModeTracks: false });
   * ```
   *
   * --
   *
   * This method can be used to switch to or exit from "trickMode" video tracks,
   * which are tracks specifically defined to mimic the visual aspect of a VCR's
   * fast forward/rewind feature, by only displaying a few video frames during
   * playback.
   *
   * This behavior is configurable through the second argument, by adding a
   * property named `preferTrickModeTracks` to that object.
   *
   * You can set that value to `true` to switch to trickMode video tracks when
   * available, and set it to `false` when you want to disable that logic.
   * Note that like any configuration given to `setPlaybackRate`, this setting
   * is persisted through all future contents played by the player.
   *
   * If you want to stop enabling trickMode tracks, you will have to call
   * `setPlaybackRate` again with `preferTrickModeTracks` set to `false`.
   *
   * You can know at any moment whether this behavior is enabled by calling
   * the `areTrickModeTracksEnabled` method. This will only means that the
   * RxPlayer will select in priority trickmode video tracks, not that the
   * currently chosen video tracks is a trickmode track (for example, some
   * contents may have no trickmode tracks available).
   *
   * If you want to know about the latter instead, you can call `getVideoTrack`
   * and/or listen to `videoTrackChange` events. The track returned may have an
   * `isTrickModeTrack` property set to `true`, indicating that it is a
   * trickmode track.
   *
   * Note that switching to or getting out of a trickmode video track may
   * lead to the player being a brief instant in a `"RELOADING"` state (notified
   * through `playerStateChange` events and the `getLoadedContentState` method).
   * When in that state, a black screen may be displayed and multiple RxPlayer
   * APIs will not be usable.
   *
   * @param {Number} rate
   * @param {Object} opts
   */
  setPlaybackRate(rate: number, opts?: { preferTrickModeTracks?: boolean }): void {
    if (rate !== this._priv_speed.getValue()) {
      this._priv_speed.setValue(rate);
    }

    const preferTrickModeTracks = opts?.preferTrickModeTracks;
    if (typeof preferTrickModeTracks !== "boolean") {
      return;
    }
    this._priv_preferTrickModeTracks = preferTrickModeTracks;
    const tracksStore = this._priv_contentInfos?.tracksStore;
    if (!isNullOrUndefined(tracksStore)) {
      if (preferTrickModeTracks && !tracksStore.isTrickModeEnabled()) {
        tracksStore.enableVideoTrickModeTracks();
      } else if (!preferTrickModeTracks && tracksStore.isTrickModeEnabled()) {
        tracksStore.disableVideoTrickModeTracks();
      }
    }
  }

  /**
   * Returns video Representation currently considered for the current Period.
   *
   * Returns `null` if no video track is playing for the current Period.
   *
   * Returns `undefined` either when are not currently playing any Period or
   * when we don't know which Representation is playing.
   * @returns {Object|null|undefined}
   */
  getVideoRepresentation(): IVideoRepresentation | null | undefined {
    const representations = this.__priv_getCurrentRepresentations();
    if (representations === null) {
      return undefined;
    }
    return isNullOrUndefined(representations.video)
      ? representations.video
      : toVideoRepresentation(representations.video);
  }

  /**
   * Returns audio Representation currently considered for the current Period.
   *
   * Returns `null` if no audio track is playing for the current Period.
   *
   * Returns `undefined` either when are not currently playing any Period or
   * when we don't know which Representation is playing.
   * @returns {Object|null|undefined}
   */
  getAudioRepresentation(): IAudioRepresentation | null | undefined {
    const representations = this.__priv_getCurrentRepresentations();
    if (representations === null) {
      return undefined;
    }
    return isNullOrUndefined(representations.audio)
      ? representations.video
      : toAudioRepresentation(representations.audio);
  }

  /**
   * Play/Resume the current video.
   * @returns {Promise}
   */
  play(): Promise<void> {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }

    const playPromise = this.videoElement.play();
    if (isNullOrUndefined(playPromise) || typeof playPromise.catch !== "function") {
      return Promise.resolve();
    }
    return playPromise.catch((error: Error) => {
      if (error.name === "NotAllowedError") {
        const warning = new MediaError("MEDIA_ERR_PLAY_NOT_ALLOWED", error.toString());
        this.trigger("warning", warning);
      }
      throw error;
    });
  }

  /**
   * Pause the current video.
   */
  pause(): void {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    this.videoElement.pause();
  }

  /**
   * Seek to a given absolute position.
   * @param {Number|Object} time
   * @returns {Number} - The time the player has seek to
   */
  seekTo(
    time:
      | number
      | { relative: number }
      | { position: number }
      | { wallClockTime: number },
  ): number {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    if (this._priv_contentInfos === null) {
      throw new Error("player: no content loaded");
    }

    const { isDirectFile, manifest } = this._priv_contentInfos;
    let positionWanted: number | undefined;

    if (typeof time === "number") {
      positionWanted = time;
    } else if (typeof time === "object") {
      const timeObj: {
        time?: number;
        relative?: number;
        position?: number;
        wallClockTime?: number;
      } = time;
      const currentTs = this.videoElement.currentTime;
      if (!isNullOrUndefined(timeObj.relative)) {
        positionWanted = currentTs + timeObj.relative;
      } else if (!isNullOrUndefined(timeObj.position)) {
        positionWanted = timeObj.position;
      } else if (!isNullOrUndefined(timeObj.wallClockTime)) {
        if (manifest !== null) {
          positionWanted = timeObj.wallClockTime - (manifest.availabilityStartTime ?? 0);
        } else if (!isDirectFile) {
          throw new Error(
            "Cannot seek: wallClockTime asked but Manifest not yet loaded.",
          );
        } else if (this.videoElement !== null) {
          const startDate = getStartDate(this.videoElement);
          if (startDate !== undefined) {
            positionWanted = timeObj.wallClockTime - startDate;
          }
        }
        if (positionWanted === undefined) {
          positionWanted = timeObj.wallClockTime;
        }
      } else {
        throw new Error(
          "invalid time object. You must set one of the " +
            'following properties: "relative", "position" or ' +
            '"wallClockTime"',
        );
      }
    }

    if (positionWanted === undefined) {
      throw new Error("invalid time given");
    }
    log.info("API", "API seekTo", { positionWanted });
    this._priv_contentInfos.playbackObserver.setCurrentTime(positionWanted, false);
    return positionWanted;
  }

  /**
   * Returns the current player's audio volume on the media element.
   * From 0 (no audio) to 1 (maximum volume).
   * @returns {Number}
   */
  getVolume(): number {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    return this.videoElement.volume;
  }

  /**
   * Set the player's audio volume. From 0 (no volume) to 1 (maximum volume).
   * @param {Number} volume
   */
  setVolume(volume: number): void {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }

    const videoElement = this.videoElement;
    if (volume !== videoElement.volume) {
      videoElement.volume = volume;
    }
  }

  /**
   * Returns `true` if audio is currently muted.
   * @returns {Boolean}
   */
  isMute(): boolean {
    return this.videoElement?.muted === true;
  }

  /**
   * Mute audio.
   */
  mute(): void {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    if (!this.videoElement.muted) {
      this.videoElement.muted = true;
    }
  }

  /**
   * Unmute audio.
   */
  unMute(): void {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    if (this.videoElement.muted) {
      this.videoElement.muted = false;
    }
  }

  /**
   * Set the max buffer size for the buffer behind the current position.
   * Every buffer data before will be removed.
   * @param {Number} depthInSeconds
   */
  setMaxBufferBehind(depthInSeconds: number): void {
    this._priv_bufferOptions.maxBufferBehind.setValue(depthInSeconds);
  }

  /**
   * Set the max buffer size for the buffer behind the current position.
   * Every buffer data before will be removed.
   * @param {Number} depthInSeconds
   */
  setMaxBufferAhead(depthInSeconds: number): void {
    this._priv_bufferOptions.maxBufferAhead.setValue(depthInSeconds);
  }

  /**
   * Set the max buffer size for the buffer ahead of the current position.
   * The player will stop downloading chunks when this size is reached.
   * @param {Number} sizeInSeconds
   */
  setWantedBufferAhead(sizeInSeconds: number): void {
    this._priv_bufferOptions.wantedBufferAhead.setValue(sizeInSeconds);
  }

  /**
   * Set the max buffer size the buffer should take in memory
   * The player . will stop downloading chunks when this size is reached.
   * @param {Number} sizeInKBytes
   */
  setMaxVideoBufferSize(sizeInKBytes: number): void {
    this._priv_bufferOptions.maxVideoBufferSize.setValue(sizeInKBytes);
  }

  /**
   * Returns the max buffer size for the buffer behind the current position.
   * @returns {Number}
   */
  getMaxBufferBehind(): number {
    return this._priv_bufferOptions.maxBufferBehind.getValue();
  }

  /**
   * Returns the max buffer size for the buffer behind the current position.
   * @returns {Number}
   */
  getMaxBufferAhead(): number {
    return this._priv_bufferOptions.maxBufferAhead.getValue();
  }

  /**
   * Returns the max buffer size for the buffer ahead of the current position.
   * @returns {Number}
   */
  getWantedBufferAhead(): number {
    return this._priv_bufferOptions.wantedBufferAhead.getValue();
  }

  /**
   * Returns the max buffer memory size for the buffer in kilobytes
   * @returns {Number}
   */
  getMaxVideoBufferSize(): number {
    return this._priv_bufferOptions.maxVideoBufferSize.getValue();
  }

  getCurrentPeriod(): IPeriod | null {
    const currentPeriod = this._priv_contentInfos?.currentPeriod;
    if (isNullOrUndefined(currentPeriod)) {
      return null;
    }
    return {
      id: currentPeriod.id,
      start: currentPeriod.start,
      end: currentPeriod.end,
    };
  }

  /**
   * Returns both the name of the key system (e.g. `"com.widevine.alpha"`) and
   * the `MediaKeySystemConfiguration` currently associated to the
   * HTMLMediaElement linked to the RxPlayer.
   *
   * Returns `null` if no such capabilities is associated or if unknown.
   * @returns {Object|null}
   */
  getKeySystemConfiguration(): IKeySystemConfigurationOutput | null {
    if (this.videoElement === null) {
      throw new Error("Disposed player");
    }
    const values = getKeySystemConfiguration(this.videoElement);
    if (values === null) {
      return null;
    }
    return { keySystem: values[0], configuration: values[1] };
  }

  /**
   * Returns the list of available Periods for which the current audio, video or
   * text track can now be changed.
   * @returns {Array.<Object>}
   */
  getAvailablePeriods(): IPeriod[] {
    if (this._priv_contentInfos === null) {
      return [];
    }
    const { isDirectFile, tracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      return [];
    }
    if (tracksStore === null) {
      return [];
    }
    return tracksStore.getAvailablePeriods().slice();
  }

  /**
   * Returns every available audio tracks for a given Period - or the current
   * one if no `periodId` is given.
   * @param {string|Object|undefined} [arg]
   * @returns {Array.<Object>}
   */
  getAvailableAudioTracks(
    arg?:
      | string
      | undefined
      | {
          periodId: string;
          filterPlayableRepresentations: boolean;
        },
  ): IAvailableAudioTrack[] {
    if (this._priv_contentInfos === null) {
      return [];
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      return mediaElementTracksStore?.getAvailableAudioTracks() ?? [];
    }

    let periodId: string | undefined;
    let filterPlayableRepresentations = true;
    if (typeof arg === "string") {
      periodId = arg;
    } else {
      periodId = arg?.periodId;
      filterPlayableRepresentations = arg?.filterPlayableRepresentations ?? true;
    }
    return this._priv_callTracksStoreGetterSetter(
      periodId,
      [],
      (tcm, periodRef) =>
        tcm.getAvailableAudioTracks(periodRef, filterPlayableRepresentations) ?? [],
    );
  }

  /**
   * Returns every available text tracks for a given Period - or the current
   * one if no `periodId` is given.
   * @param {string|undefined} [periodId]
   * @returns {Array.<Object>}
   */
  getAvailableTextTracks(periodId?: string | undefined): IAvailableTextTrack[] {
    if (this._priv_contentInfos === null) {
      return [];
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      return mediaElementTracksStore?.getAvailableTextTracks() ?? [];
    }
    return this._priv_callTracksStoreGetterSetter(
      periodId,
      [],
      (tcm, periodRef) => tcm.getAvailableTextTracks(periodRef) ?? [],
    );
  }

  /**
   * Returns every available video tracks for the current Period.
   * @param {string|Object|undefined} [arg]
   * @returns {Array.<Object>}
   */
  getAvailableVideoTracks(
    arg?:
      | string
      | undefined
      | {
          periodId: string;
          filterPlayableRepresentations: boolean;
        },
  ): IAvailableVideoTrack[] {
    if (this._priv_contentInfos === null) {
      return [];
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      return mediaElementTracksStore?.getAvailableVideoTracks() ?? [];
    }

    let periodId: string | undefined;
    let filterPlayableRepresentations = true;
    if (typeof arg === "string") {
      periodId = arg;
    } else {
      periodId = arg?.periodId;
      filterPlayableRepresentations = arg?.filterPlayableRepresentations ?? true;
    }
    return this._priv_callTracksStoreGetterSetter(
      periodId,
      [],
      (tcm, periodRef) =>
        tcm.getAvailableVideoTracks(periodRef, filterPlayableRepresentations) ?? [],
    );
  }

  /**
   * Returns currently chosen audio language for the current Period.
   * @param {string|Object|undefined} [arg]
   * @returns {Object|null|undefined}
   */
  getAudioTrack(
    arg?:
      | string
      | undefined
      | {
          periodId: string;
          filterPlayableRepresentations: boolean;
        },
  ): IAudioTrack | null | undefined {
    if (this._priv_contentInfos === null) {
      return undefined;
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      if (mediaElementTracksStore === null) {
        return undefined;
      }
      return mediaElementTracksStore.getChosenAudioTrack();
    }

    let periodId: string | undefined;
    let filterPlayableRepresentations = true;
    if (typeof arg === "string") {
      periodId = arg;
    } else {
      periodId = arg?.periodId;
      filterPlayableRepresentations = arg?.filterPlayableRepresentations ?? true;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.getChosenAudioTrack(periodRef, filterPlayableRepresentations),
    );
  }

  /**
   * Returns currently chosen subtitle for the current Period.
   * @param {string|undefined} [periodId]
   * @returns {Object|null|undefined}
   */
  getTextTrack(periodId?: string | undefined): ITextTrack | null | undefined {
    if (this._priv_contentInfos === null) {
      return undefined;
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      if (mediaElementTracksStore === null) {
        return undefined;
      }
      return mediaElementTracksStore.getChosenTextTrack();
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.getChosenTextTrack(periodRef),
    );
  }

  /**
   * Returns currently chosen video track for the current Period.
   * @param {string|Object|undefined} [arg]
   * @returns {Object|null|undefined}
   */
  getVideoTrack(
    arg?:
      | string
      | undefined
      | {
          periodId: string;
          filterPlayableRepresentations: boolean;
        },
  ): IVideoTrack | null | undefined {
    if (this._priv_contentInfos === null) {
      return undefined;
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      if (mediaElementTracksStore === null) {
        return undefined;
      }
      return mediaElementTracksStore.getChosenVideoTrack();
    }

    let periodId: string | undefined;
    let filterPlayableRepresentations = true;
    if (typeof arg === "string") {
      periodId = arg;
    } else {
      periodId = arg?.periodId;
      filterPlayableRepresentations = arg?.filterPlayableRepresentations ?? true;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.getChosenVideoTrack(periodRef, filterPlayableRepresentations),
    );
  }

  /**
   * Update the audio language for the current Period.
   * @param {string | object} arg
   * @throws Error - the current content has no TracksStore.
   * @throws Error - the given id is linked to no audio track.
   */
  setAudioTrack(arg: string | IAudioTrackSetting): void {
    if (this._priv_contentInfos === null) {
      throw new Error("No content loaded");
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      try {
        const audioId = typeof arg === "string" ? arg : arg.trackId;
        mediaElementTracksStore?.setAudioTrackById(audioId);
        return;
      } catch (_e) {
        throw new Error("player: unknown audio track");
      }
    }

    let periodId: string | undefined;
    let trackId: string;
    let switchingMode: IAudioTrackSwitchingMode | undefined;
    let lockedRepresentations: string[] | null = null;
    let relativeResumingPosition: number | undefined;
    if (typeof arg === "string") {
      trackId = arg;
    } else {
      trackId = arg.trackId;
      periodId = arg.periodId;
      switchingMode = arg.switchingMode;
      lockedRepresentations = arg.lockedRepresentations ?? null;
      relativeResumingPosition = arg.relativeResumingPosition;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.setAudioTrack({
        periodRef,
        trackId,
        switchingMode,
        lockedRepresentations,
        relativeResumingPosition,
      }),
    );
  }

  /**
   * Update the text language for the current Period.
   * @param {string | Object} arg
   * @throws Error - the current content has no TracksStore.
   * @throws Error - the given id is linked to no text track.
   */
  setTextTrack(arg: string | ITextTrackSetting): void {
    if (this._priv_contentInfos === null) {
      throw new Error("No content loaded");
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      try {
        const textId = typeof arg === "string" ? arg : arg.trackId;
        mediaElementTracksStore?.setTextTrackById(textId);
        return;
      } catch (_e) {
        throw new Error("player: unknown text track");
      }
    }

    let periodId: string | undefined;
    let trackId: string;
    if (typeof arg === "string") {
      trackId = arg;
    } else {
      trackId = arg.trackId;
      periodId = arg.periodId;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.setTextTrack(periodRef, trackId),
    );
  }

  /**
   * Disable subtitles for the current content.
   * @param {string|undefined} [periodId]
   */
  disableTextTrack(periodId?: string | undefined): void {
    if (this._priv_contentInfos === null) {
      return;
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      mediaElementTracksStore?.disableTextTrack();
      return;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.disableTrack(periodRef, "text"),
    );
  }

  /**
   * Update the video track for the current Period.
   * @param {string | Object} arg
   * @throws Error - the current content has no TracksStore.
   * @throws Error - the given id is linked to no video track.
   */
  setVideoTrack(arg: string | IVideoTrackSetting): void {
    if (this._priv_contentInfos === null) {
      throw new Error("No content loaded");
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile) {
      try {
        const videoId = typeof arg === "string" ? arg : arg.trackId;
        mediaElementTracksStore?.setVideoTrackById(videoId);
        return;
      } catch (_e) {
        throw new Error("player: unknown video track");
      }
    }

    let periodId: string | undefined;
    let trackId: string;
    let switchingMode: IVideoTrackSwitchingMode | undefined;
    let lockedRepresentations: string[] | null = null;
    let relativeResumingPosition: number | undefined;
    if (typeof arg === "string") {
      trackId = arg;
    } else {
      trackId = arg.trackId;
      periodId = arg.periodId;
      switchingMode = arg.switchingMode;
      lockedRepresentations = arg.lockedRepresentations ?? null;
      relativeResumingPosition = arg.relativeResumingPosition;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.setVideoTrack({
        periodRef,
        trackId,
        switchingMode,
        lockedRepresentations,
        relativeResumingPosition,
      }),
    );
  }

  /**
   * Disable video track for the current content.
   * @param {string|undefined} [periodId]
   */
  disableVideoTrack(periodId?: string | undefined): void {
    if (this._priv_contentInfos === null) {
      return;
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile && mediaElementTracksStore !== null) {
      return mediaElementTracksStore.disableVideoTrack();
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.disableTrack(periodRef, "video"),
    );
  }

  lockVideoRepresentations(arg: string[] | ILockedVideoRepresentationsSettings): void {
    if (this._priv_contentInfos === null) {
      throw new Error("No content loaded");
    }
    const { isDirectFile } = this._priv_contentInfos;
    if (isDirectFile) {
      throw new Error("Cannot lock video Representations in directfile mode.");
    }

    let repsId: string[];
    let periodId: string | undefined;
    let switchingMode: IVideoRepresentationsSwitchingMode | undefined;
    if (Array.isArray(arg)) {
      repsId = arg;
      periodId = undefined;
    } else {
      repsId = arg.representations;
      periodId = arg.periodId;
      switchingMode = arg.switchingMode;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.lockVideoRepresentations(periodRef, {
        representations: repsId,
        switchingMode,
      }),
    );
  }

  /**
   * Disable audio track for the current content.
   * @param {string|undefined} [periodId]
   */
  disableAudioTrack(periodId?: string | undefined): void {
    if (this._priv_contentInfos === null) {
      return;
    }
    const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos;
    if (isDirectFile && mediaElementTracksStore !== null) {
      return mediaElementTracksStore.disableAudioTrack();
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.disableTrack(periodRef, "audio"),
    );
  }

  lockAudioRepresentations(arg: string[] | ILockedAudioRepresentationsSettings): void {
    if (this._priv_contentInfos === null) {
      throw new Error("No content loaded");
    }
    const { isDirectFile } = this._priv_contentInfos;
    if (isDirectFile) {
      throw new Error("Cannot lock audio Representations in directfile mode.");
    }

    let repsId: string[];
    let periodId: string | undefined;
    let switchingMode: IAudioRepresentationsSwitchingMode | undefined;
    if (Array.isArray(arg)) {
      repsId = arg;
      periodId = undefined;
    } else {
      repsId = arg.representations;
      periodId = arg.periodId;
      switchingMode = arg.switchingMode;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.lockAudioRepresentations(periodRef, {
        representations: repsId,
        switchingMode,
      }),
    );
  }

  getLockedVideoRepresentations(periodId?: string | undefined): string[] | null {
    if (this._priv_contentInfos === null) {
      return null;
    }
    const { isDirectFile } = this._priv_contentInfos;
    if (isDirectFile) {
      return null;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, null, (tcm, periodRef) =>
      tcm.getLockedVideoRepresentations(periodRef),
    );
  }

  getLockedAudioRepresentations(periodId?: string | undefined): string[] | null {
    if (this._priv_contentInfos === null) {
      return null;
    }
    const { isDirectFile } = this._priv_contentInfos;
    if (isDirectFile) {
      return null;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, null, (tcm, periodRef) =>
      tcm.getLockedAudioRepresentations(periodRef),
    );
  }

  unlockVideoRepresentations(periodId?: string | undefined): void {
    if (this._priv_contentInfos === null) {
      return;
    }
    const { isDirectFile } = this._priv_contentInfos;
    if (isDirectFile) {
      return;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.unlockVideoRepresentations(periodRef),
    );
  }

  unlockAudioRepresentations(periodId?: string | undefined): void {
    if (this._priv_contentInfos === null) {
      return;
    }
    const { isDirectFile } = this._priv_contentInfos;
    if (isDirectFile) {
      return;
    }
    return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) =>
      tcm.unlockAudioRepresentations(periodRef),
    );
  }

  /**
   * Get minimum seek-able position.
   * @returns {number}
   */
  getMinimumPosition(): number | null {
    if (this._priv_contentInfos === null) {
      return null;
    }

    if (this._priv_contentInfos.isDirectFile) {
      if (this.videoElement === null) {
        log.error("API", "getMinimumPosition() called on a disposed player");
        return 0;
      }
      if (this.videoElement.seekable.length > 0) {
        return this.videoElement.seekable.start(0);
      }
      return 0;
    }

    const { manifest } = this._priv_contentInfos;
    if (manifest !== null) {
      return getMinimumSafePosition(manifest);
    }
    return null;
  }

  /**
   * Returns the current position for live contents.
   *
   * Returns `null` if no content is loaded or if the current loaded content is
   * not considered as a live content.
   * Returns `undefined` if that live position is currently unknown.
   * @returns {number}
   */
  getLivePosition(): number | undefined | null {
    if (this._priv_contentInfos === null) {
      return null;
    }

    const { isDirectFile, manifest } = this._priv_contentInfos;
    if (isDirectFile) {
      return undefined;
    }
    if (manifest?.isLive !== true) {
      return null;
    }
    return getLivePosition(manifest);
  }

  /**
   * Get maximum seek-able position.
   * @returns {number}
   */
  getMaximumPosition(): number | null {
    if (this._priv_contentInfos === null) {
      return null;
    }

    const { isDirectFile, manifest } = this._priv_contentInfos;

    if (isDirectFile) {
      if (this.videoElement === null) {
        log.error("API", "getMaximumPosition() called on a disposed player");
        return null;
      }

      if (this.videoElement.seekable.length > 0) {
        return this.videoElement.seekable.end(this.videoElement.seekable.length - 1);
      }
      // if for some reason seekable has no entry, fallback on duration
      return this.videoElement.duration;
    }

    if (manifest !== null) {
      if (!manifest.isDynamic && this.videoElement !== null) {
        return this.videoElement.duration;
      }
      return getMaximumSafePosition(manifest);
    }
    return null;
  }

  // ---- Undocumented Private methods. ----
  //
  // Those methods are just here either to allow some tools relying on the
  // RxPlayer instance to work or to improve the RxPlayer's demo.
  //
  // They should not be used by any external code.

  /**
   * Used for the display of segmentSink metrics for the debug element
   * @param fn
   * @param cancellationSignal
   * @returns
   */
  async __priv_getSegmentSinkMetrics(): Promise<undefined | ISegmentSinkMetrics> {
    return this._priv_contentInfos?.segmentSinkMetricsCallback?.();
  }

  /**
   * /!\ For tools use only! Do not touch!
   *
   * Returns manifest/playlist object.
   * null if the player is STOPPED.
   * @returns {Manifest|null} - The current Manifest (`null` when not known).
   */
  // TODO remove the need for that public method
  __priv_getManifest(): IManifestMetadata | null {
    if (this._priv_contentInfos === null) {
      return null;
    }
    return this._priv_contentInfos.manifest;
  }

  // TODO remove the need for that public method
  __priv_getCurrentAdaptation(): Partial<
    Record<IBufferType, IAdaptationMetadata | null>
  > | null {
    if (this._priv_contentInfos === null) {
      return null;
    }
    const { currentPeriod, activeAdaptations } = this._priv_contentInfos;
    if (
      currentPeriod === null ||
      activeAdaptations === null ||
      isNullOrUndefined(activeAdaptations[currentPeriod.id])
    ) {
      return null;
    }
    return activeAdaptations[currentPeriod.id];
  }

  // TODO remove the need for that public method
  __priv_getCurrentRepresentations(): Partial<
    Record<IBufferType, IRepresentationMetadata | null>
  > | null {
    if (this._priv_contentInfos === null) {
      return null;
    }
    const { currentPeriod, activeRepresentations } = this._priv_contentInfos;
    if (
      currentPeriod === null ||
      activeRepresentations === null ||
      isNullOrUndefined(activeRepresentations[currentPeriod.id])
    ) {
      return null;
    }
    return activeRepresentations[currentPeriod.id];
  }

  // ---- Private methods ----

  /**
   * Reset all state properties relative to a playing content.
   */
  private _priv_cleanUpCurrentContentState(): void {
    log.debug("API", "Locking `contentLock` to clean-up the current content.");

    // lock playback of new contents while cleaning up is pending
    this._priv_contentLock.setValue(true);

    this._priv_contentInfos?.tracksStore?.dispose();
    this._priv_contentInfos?.mediaElementTracksStore?.dispose();
    this._priv_contentInfos = null;

    this._priv_contentEventsMemory = {};

    // DRM-related clean-up
    const freeUpContentLock = () => {
      if (this.videoElement !== null) {
        // If not disposed
        log.debug("API", "Unlocking `contentLock`. Next content can begin.");
        this._priv_contentLock.setValue(false);
      }
    };

    if (!isNullOrUndefined(this.videoElement)) {
      clearOnStop(this.videoElement).then(
        () => {
          log.debug("API", "DRM session cleaned-up with success!");
          freeUpContentLock();
        },
        (err: unknown) => {
          log.error(
            "API",
            "An error arised when trying to clean-up the DRM session:" +
              (err instanceof Error ? err.toString() : "Unknown Error"),
          );
          freeUpContentLock();
        },
      );
    } else {
      freeUpContentLock();
    }
  }

  /**
   * Triggered when the Manifest has been loaded for the current content.
   * Initialize various private properties and emit initial event.
   * @param {Object} contentInfos
   * @param {Object} manifest
   */
  private _priv_onManifestReady(
    contentInfos: IPublicApiContentInfos,
    manifest: IManifest | IManifestMetadata,
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }
    contentInfos.manifest = manifest;

    if (manifest.manifestFormat === ManifestMetadataFormat.Class) {
      this._priv_reloadingMetadata.manifest = manifest as IManifest;
    }

    const tracksStore = new TracksStore({
      preferTrickModeTracks: this._priv_preferTrickModeTracks,
      defaultAudioTrackSwitchingMode: contentInfos.defaultAudioTrackSwitchingMode,
      handledTrackTypes: contentInfos.handledTrackTypes,
      onTracksNotPlayableForType: {
        audio: contentInfos.onAudioTracksNotPlayable,
        video: contentInfos.onVideoTracksNotPlayable,
        text: "continue",
      },
    });
    contentInfos.tracksStore = tracksStore;
    tracksStore.addEventListener("newAvailablePeriods", (p) => {
      this.trigger("newAvailablePeriods", p);
    });
    tracksStore.addEventListener("brokenRepresentationsLock", (e) => {
      this.trigger("brokenRepresentationsLock", e);
    });
    tracksStore.addEventListener("trackUpdate", (e) => {
      this.trigger("trackUpdate", e);

      const currentPeriod = this._priv_contentInfos?.currentPeriod ?? undefined;
      if (
        e.reason === "no-playable-representation" &&
        e.period.id === currentPeriod?.id
      ) {
        this._priv_onAvailableTracksMayHaveChanged(e.trackType);
      }
    });
    tracksStore.addEventListener("warning", (err) => {
      this.trigger("warning", err);
    });
    tracksStore.addEventListener("error", (err) => {
      this._priv_onFatalError(err, contentInfos);
    });

    tracksStore.addEventListener("noPlayableTrack", (noPlayableTrackEvent) => {
      this.trigger("noPlayableTrack", noPlayableTrackEvent);
    });

    tracksStore.onManifestUpdate(manifest);
  }

  /**
   * Triggered when the Manifest has been updated for the current content.
   * Initialize various private properties and emit initial event.
   * @param {Object} contentInfos
   * @param {Object} updates
   */
  private _priv_onManifestUpdate(
    contentInfos: IPublicApiContentInfos,
    updates: IPeriodsUpdateResult,
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }
    if (this._priv_contentInfos === null || this._priv_contentInfos.manifest === null) {
      return;
    }

    // Update the tracks chosen if it changed
    if (!isNullOrUndefined(contentInfos?.tracksStore)) {
      contentInfos.tracksStore.onManifestUpdate(this._priv_contentInfos.manifest);
    }
    const currentPeriod = this._priv_contentInfos?.currentPeriod ?? undefined;
    const currTracksStore = this._priv_contentInfos?.tracksStore;
    if (currentPeriod === undefined || isNullOrUndefined(currTracksStore)) {
      return;
    }
    for (const update of updates.updatedPeriods) {
      if (update.period.id === currentPeriod.id) {
        if (
          update.result.addedAdaptations.length > 0 ||
          update.result.removedAdaptations.length > 0
        ) {
          // We might have new (or less) tracks, send events just to be sure
          const periodRef = currTracksStore.getPeriodObjectFromPeriod(currentPeriod);
          if (periodRef === undefined) {
            return;
          }
          this._priv_onAvailableTracksMayHaveChanged("audio");
          this._priv_onAvailableTracksMayHaveChanged("text");
          this._priv_onAvailableTracksMayHaveChanged("video");
        }
      }
    }
  }

  /**
   * Triggered each times the support for a codec has changed in the manifest.
   * When triggered, the track store may need to consider selecting a new track.
   *
   * @param {Object} contentInfos
   */
  private _priv_onCodecSupportUpdate(contentInfos: IPublicApiContentInfos) {
    const tStore = contentInfos?.tracksStore;
    if (isNullOrUndefined(tStore)) {
      return;
    }
    tStore.onManifestCodecSupportUpdate();
  }

  private _priv_onDecipherabilityUpdate(
    contentInfos: IPublicApiContentInfos,
    elts: IDecipherabilityStatusChangedElement[],
  ): void {
    if (contentInfos === null || contentInfos.manifest === null) {
      return;
    }
    if (!isNullOrUndefined(contentInfos?.tracksStore)) {
      contentInfos.tracksStore.onDecipherabilityUpdates();
    }

    /**
     * Array of tuples only including once the Period/Track combination, and
     * only when it concerns the currently-selected track.
     */
    const periodsAndTrackTypes = elts.reduce(
      (acc: Array<[IPeriodMetadata, ITrackType]>, elt) => {
        const isFound =
          arrayFind(
            acc,
            (x) => x[0].id === elt.period.id && x[1] === elt.adaptation.type,
          ) !== undefined;

        if (!isFound) {
          // Only consider the currently-selected tracks.
          // NOTE: Maybe there's room for optimizations? Unclear.
          const tStore = contentInfos.tracksStore;
          if (tStore === null) {
            return acc;
          }
          let isCurrent = false;
          const periodRef = tStore.getPeriodObjectFromPeriod(elt.period);
          if (periodRef === undefined) {
            return acc;
          }
          switch (elt.adaptation.type) {
            case "audio":
              isCurrent =
                tStore.getChosenAudioTrack(periodRef, false)?.id === elt.adaptation.id;
              break;
            case "video":
              isCurrent =
                tStore.getChosenVideoTrack(periodRef, false)?.id === elt.adaptation.id;
              break;
            case "text":
              isCurrent = tStore.getChosenTextTrack(periodRef)?.id === elt.adaptation.id;
              break;
          }
          if (isCurrent) {
            acc.push([elt.period, elt.adaptation.type]);
          }
        }
        return acc;
      },
      [],
    );
    for (const [period, trackType] of periodsAndTrackTypes) {
      this._priv_triggerEventIfNotStopped(
        "representationListUpdate",
        {
          period: { start: period.start, end: period.end, id: period.id },
          trackType,
          reason: "decipherability-update",
        },
        contentInfos.currentContentCanceller.signal,
      );
    }
  }

  /**
   * Triggered each times the current Period Changed.
   * Store and emit initial state for the Period.
   *
   * @param {Object} contentInfos
   * @param {Object} periodInfo
   */
  private _priv_onActivePeriodChanged(
    contentInfos: IPublicApiContentInfos,
    { period }: { period: IPeriodMetadata },
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }
    contentInfos.currentPeriod = period;

    const cancelSignal = contentInfos.currentContentCanceller.signal;
    if (this._priv_contentEventsMemory.periodChange !== period) {
      this._priv_contentEventsMemory.periodChange = period;
      this._priv_triggerEventIfNotStopped(
        "periodChange",
        { start: period.start, end: period.end, id: period.id },
        cancelSignal,
      );
    }

    this._priv_triggerEventIfNotStopped(
      "availableAudioTracksChange",
      this.getAvailableAudioTracks(),
      cancelSignal,
    );
    this._priv_triggerEventIfNotStopped(
      "availableTextTracksChange",
      this.getAvailableTextTracks(),
      cancelSignal,
    );
    this._priv_triggerEventIfNotStopped(
      "availableVideoTracksChange",
      this.getAvailableVideoTracks(),
      cancelSignal,
    );

    const tracksStore = this._priv_contentInfos?.tracksStore;

    // Emit initial events for the Period
    if (!isNullOrUndefined(tracksStore)) {
      const periodRef = tracksStore.getPeriodObjectFromPeriod(period);
      if (periodRef !== undefined) {
        const audioTrack = tracksStore.getChosenAudioTrack(periodRef, true);
        this._priv_triggerEventIfNotStopped("audioTrackChange", audioTrack, cancelSignal);
        const textTrack = tracksStore.getChosenTextTrack(periodRef);
        this._priv_triggerEventIfNotStopped("textTrackChange", textTrack, cancelSignal);
        const videoTrack = tracksStore.getChosenVideoTrack(periodRef, true);
        this._priv_triggerEventIfNotStopped("videoTrackChange", videoTrack, cancelSignal);
      }
    } else {
      this._priv_triggerEventIfNotStopped("audioTrackChange", null, cancelSignal);
      this._priv_triggerEventIfNotStopped("textTrackChange", null, cancelSignal);
      this._priv_triggerEventIfNotStopped("videoTrackChange", null, cancelSignal);
    }

    const audioRepresentation = this.__priv_getCurrentRepresentations()?.audio ?? null;
    this._priv_triggerEventIfNotStopped(
      "audioRepresentationChange",
      isNullOrUndefined(audioRepresentation)
        ? audioRepresentation
        : toAudioRepresentation(audioRepresentation),
      cancelSignal,
    );
    const videoRepresentation = this.__priv_getCurrentRepresentations()?.video ?? null;
    this._priv_triggerEventIfNotStopped(
      "videoRepresentationChange",
      isNullOrUndefined(videoRepresentation)
        ? videoRepresentation
        : toVideoRepresentation(videoRepresentation),
      cancelSignal,
    );
  }

  /**
   * Triggered each times a new "PeriodStream" is ready.
   * Choose the right Adaptation for the Period and emit it.
   * @param {Object} contentInfos
   * @param {Object} value
   */
  private _priv_onPeriodStreamReady(
    contentInfos: IPublicApiContentInfos,
    value: {
      type: IBufferType;
      period: IPeriodMetadata;
      adaptationRef: SharedReference<IAdaptationChoice | null | undefined>;
    },
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }
    const { type, period, adaptationRef } = value;
    const tracksStore = contentInfos.tracksStore;

    switch (type) {
      case "video":
      case "audio":
      case "text":
        if (isNullOrUndefined(tracksStore)) {
          log.error("API", `TracksStore not instanciated for a new ${type} period`);
          adaptationRef.setValue(null);
        } else {
          tracksStore.addTrackReference(type, period, adaptationRef);
        }
        break;
      default:
        assertUnreachable(type);
    }
  }

  /**
   * Triggered each times we "remove" a PeriodStream.
   * @param {Object} contentInfos
   * @param {Object} value
   */
  private _priv_onPeriodStreamCleared(
    contentInfos: IPublicApiContentInfos,
    value: { type: IBufferType; periodId: string },
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }
    const { type, periodId } = value;
    const tracksStore = contentInfos.tracksStore;

    // Clean-up track choices from TracksStore
    switch (type) {
      case "audio":
      case "text":
      case "video":
        if (!isNullOrUndefined(tracksStore)) {
          tracksStore.removeTrackReference(type, periodId);
        }
        break;
    }

    // Clean-up stored Representation and Adaptation information
    const { activeAdaptations, activeRepresentations } = contentInfos;
    if (
      !isNullOrUndefined(activeAdaptations) &&
      !isNullOrUndefined(activeAdaptations[periodId])
    ) {
      const activePeriodAdaptations = activeAdaptations[periodId];
      delete activePeriodAdaptations[type];
      if (Object.keys(activePeriodAdaptations).length === 0) {
        delete activeAdaptations[periodId];
      }
    }

    if (
      !isNullOrUndefined(activeRepresentations) &&
      !isNullOrUndefined(activeRepresentations[periodId])
    ) {
      const activePeriodRepresentations = activeRepresentations[periodId];
      delete activePeriodRepresentations[type];
      if (Object.keys(activePeriodRepresentations).length === 0) {
        delete activeRepresentations[periodId];
      }
    }
  }

  /**
   * Triggered each times a new Adaptation is considered for the current
   * content.
   * Store given Adaptation and emit it if from the current Period.
   * @param {Object} contentInfos
   * @param {Object} value
   */
  private _priv_onAdaptationChange(
    contentInfos: IPublicApiContentInfos,
    {
      type,
      adaptation,
      period,
    }: {
      type: IBufferType;
      adaptation: IAdaptationMetadata | null;
      period: IPeriodMetadata;
    },
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }

    // lazily create contentInfos.activeAdaptations
    if (contentInfos.activeAdaptations === null) {
      contentInfos.activeAdaptations = {};
    }

    const { activeAdaptations, currentPeriod } = contentInfos;
    const activePeriodAdaptations = activeAdaptations[period.id];
    if (isNullOrUndefined(activePeriodAdaptations)) {
      activeAdaptations[period.id] = { [type]: adaptation };
    } else {
      activePeriodAdaptations[type] = adaptation;
    }

    const { tracksStore } = contentInfos;
    const cancelSignal = contentInfos.currentContentCanceller.signal;
    if (
      tracksStore !== null &&
      currentPeriod !== null &&
      !isNullOrUndefined(period) &&
      period.id === currentPeriod.id
    ) {
      const periodRef = tracksStore.getPeriodObjectFromPeriod(period);
      if (periodRef === undefined) {
        return;
      }
      switch (type) {
        case "audio": {
          const audioTrack = tracksStore.getChosenAudioTrack(periodRef, true);
          this._priv_triggerEventIfNotStopped(
            "audioTrackChange",
            audioTrack,
            cancelSignal,
          );
          break;
        }
        case "text": {
          const textTrack = tracksStore.getChosenTextTrack(periodRef);
          this._priv_triggerEventIfNotStopped("textTrackChange", textTrack, cancelSignal);
          break;
        }
        case "video": {
          const videoTrack = tracksStore.getChosenVideoTrack(periodRef, true);
          this._priv_triggerEventIfNotStopped(
            "videoTrackChange",
            videoTrack,
            cancelSignal,
          );
          break;
        }
      }
    }
  }

  /**
   * Triggered each times a new Representation is considered during playback.
   *
   * Store given Representation and emit it if from the current Period.
   *
   * @param {Object} contentInfos
   * @param {Object} obj
   */
  private _priv_onRepresentationChange(
    contentInfos: IPublicApiContentInfos,
    {
      type,
      period,
      representation,
    }: {
      type: IBufferType;
      period: IPeriodMetadata;
      representation: IRepresentationMetadata | null;
    },
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }

    // lazily create contentInfos.activeRepresentations
    if (contentInfos.activeRepresentations === null) {
      contentInfos.activeRepresentations = {};
    }

    const { activeRepresentations, currentPeriod } = contentInfos;

    const activePeriodRepresentations = activeRepresentations[period.id];
    if (isNullOrUndefined(activePeriodRepresentations)) {
      activeRepresentations[period.id] = { [type]: representation };
    } else {
      activePeriodRepresentations[type] = representation;
    }

    if (
      !isNullOrUndefined(period) &&
      currentPeriod !== null &&
      currentPeriod.id === period.id
    ) {
      const cancelSignal = this._priv_contentInfos.currentContentCanceller.signal;
      if (type === "video") {
        this._priv_triggerEventIfNotStopped(
          "videoRepresentationChange",
          isNullOrUndefined(representation)
            ? representation
            : toVideoRepresentation(representation),
          cancelSignal,
        );
      } else if (type === "audio") {
        this._priv_triggerEventIfNotStopped(
          "audioRepresentationChange",
          isNullOrUndefined(representation)
            ? representation
            : toAudioRepresentation(representation),
          cancelSignal,
        );
      }
    }
  }

  /**
   * Triggered each time a bitrate estimate is calculated.
   *
   * Emit it.
   *
   * @param {Object} value
   */
  private _priv_onBitrateEstimateChange({
    type,
    bitrate,
  }: {
    type: IBufferType;
    bitrate: number | undefined;
  }): void {
    if (bitrate !== undefined) {
      this._priv_bitrateInfos.lastBitrates[type] = bitrate;
    }
    this.trigger(
      // !!! undocumented API :O !!!
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      "__priv_bitrateEstimateChange" as any,
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      { type, bitrate } as any,
    );
  }

  /**
   * Triggered each time the player state updates.
   *
   * Trigger the right Player Event.
   *
   * @param {string} newState
   */
  private _priv_setPlayerState(newState: IPlayerState): void {
    if (this.state !== newState) {
      this.state = newState;
      log.info("API", "playerStateChange event", { newState });
      this.trigger("playerStateChange", newState);
    }
  }

  /**
   * Triggered each time a playback observation.
   *
   * Trigger the right Player Event
   *
   * @param {Object} contentInfos
   * @param {Object} observation
   */
  private _priv_triggerPositionUpdate(
    contentInfos: IPublicApiContentInfos,
    observation: IPlaybackObservation,
  ): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }

    const { isDirectFile, manifest } = contentInfos;
    if ((!isDirectFile && manifest === null) || isNullOrUndefined(observation)) {
      return;
    }

    const maximumPosition =
      manifest !== null ? getMaximumSafePosition(manifest) : undefined;
    const positionData: IPositionUpdate = {
      position: observation.position.getPolled(),
      duration: observation.duration,
      playbackRate: observation.playbackRate,
      maximumPosition,

      // TODO bufferGap may be undefined
      bufferGap:
        observation.bufferGap === undefined || !isFinite(observation.bufferGap)
          ? 0
          : observation.bufferGap,
    };

    if (manifest !== null && manifest.isLive && observation.position.getPolled() > 0) {
      const ast = manifest.availabilityStartTime ?? 0;
      positionData.wallClockTime = observation.position.getPolled() + ast;
      const livePosition = getLivePosition(manifest);
      if (livePosition !== undefined) {
        positionData.liveGap = livePosition - observation.position.getPolled();
      }
    } else if (isDirectFile && this.videoElement !== null) {
      const startDate = getStartDate(this.videoElement);
      if (startDate !== undefined) {
        positionData.wallClockTime = startDate + observation.position.getPolled();
      }

      let directFileMaximumPosition;
      if (this.videoElement.seekable.length > 0) {
        directFileMaximumPosition = this.videoElement.seekable.end(
          this.videoElement.seekable.length - 1,
        );
      }

      if (directFileMaximumPosition !== undefined && !isNaN(directFileMaximumPosition)) {
        positionData.maximumPosition = directFileMaximumPosition;
        // infinity duration means the content is live
        if (this.videoElement.duration === Infinity) {
          positionData.liveGap =
            directFileMaximumPosition - this.videoElement.currentTime;
        }
      }
    }
    this.trigger("positionUpdate", positionData);
  }

  /**
   * @param {string} evt
   * @param {*} arg
   * @param {Object} currentContentCancelSignal
   */
  private _priv_triggerEventIfNotStopped<TEventName extends keyof IPublicAPIEvent>(
    evt: TEventName,
    arg: IEventPayload<IPublicAPIEvent, TEventName>,
    currentContentCancelSignal: CancellationSignal,
  ) {
    if (!currentContentCancelSignal.isCancelled()) {
      this.trigger(evt, arg);
    }
  }

  /**
   * @param {Object} cancelSignal
   * @returns {Object}
   */
  private _priv_initializeMediaElementTracksStore(
    cancelSignal: CancellationSignal,
  ): IMediaElementTracksStore {
    assert(
      features.directfile !== null,
      "Initializing `MediaElementTracksStore` without Directfile feature",
    );
    assert(
      this.videoElement !== null,
      "Initializing `MediaElementTracksStore` on a disposed RxPlayer",
    );

    const mediaElementTracksStore = new features.directfile.mediaElementTracksStore(
      this.videoElement,
    );

    this._priv_triggerEventIfNotStopped(
      "availableAudioTracksChange",
      mediaElementTracksStore.getAvailableAudioTracks(),
      cancelSignal,
    );
    this._priv_triggerEventIfNotStopped(
      "availableVideoTracksChange",
      mediaElementTracksStore.getAvailableVideoTracks(),
      cancelSignal,
    );
    this._priv_triggerEventIfNotStopped(
      "availableTextTracksChange",
      mediaElementTracksStore.getAvailableTextTracks(),
      cancelSignal,
    );

    this._priv_triggerEventIfNotStopped(
      "audioTrackChange",
      mediaElementTracksStore.getChosenAudioTrack() ?? null,
      cancelSignal,
    );
    this._priv_triggerEventIfNotStopped(
      "textTrackChange",
      mediaElementTracksStore.getChosenTextTrack() ?? null,
      cancelSignal,
    );
    this._priv_triggerEventIfNotStopped(
      "videoTrackChange",
      mediaElementTracksStore.getChosenVideoTrack() ?? null,
      cancelSignal,
    );

    mediaElementTracksStore.addEventListener("availableVideoTracksChange", (val) =>
      this.trigger("availableVideoTracksChange", val),
    );
    mediaElementTracksStore.addEventListener("availableAudioTracksChange", (val) =>
      this.trigger("availableAudioTracksChange", val),
    );
    mediaElementTracksStore.addEventListener("availableTextTracksChange", (val) =>
      this.trigger("availableTextTracksChange", val),
    );

    mediaElementTracksStore.addEventListener("audioTrackChange", (val) =>
      this.trigger("audioTrackChange", val),
    );
    mediaElementTracksStore.addEventListener("videoTrackChange", (val) =>
      this.trigger("videoTrackChange", val),
    );
    mediaElementTracksStore.addEventListener("textTrackChange", (val) =>
      this.trigger("textTrackChange", val),
    );

    return mediaElementTracksStore;
  }

  private _priv_callTracksStoreGetterSetter<T, U>(
    periodId: string | undefined,
    defaultValue: U,
    cb: (tcm: TracksStore, periodRef: ITSPeriodObject) => T,
  ): T | U {
    if (
      this._priv_contentInfos === null ||
      this._priv_contentInfos.tracksStore === null
    ) {
      log.warn("API", "Trying to call track API too soon");
      return defaultValue;
    }
    const { tracksStore } = this._priv_contentInfos;
    const currentPeriod = this._priv_contentInfos?.currentPeriod ?? undefined;
    const wantedPeriodId = periodId ?? currentPeriod?.id;
    if (wantedPeriodId === undefined) {
      return defaultValue;
    }
    const periodRef =
      wantedPeriodId === currentPeriod?.id
        ? tracksStore.getPeriodObjectFromPeriod(currentPeriod)
        : tracksStore.getPeriodObjectFromId(wantedPeriodId);

    if (periodRef === undefined) {
      return defaultValue;
    }
    return cb(tracksStore, periodRef);
  }

  /**
   * Method to call when some event lead to a high for possibility that the
   * available tracks for the given type have changed.
   * Send the corresponding `available*Tracks` change event with the last
   * available tracks.
   *
   * @param {string} trackType
   * @param {Object|undefined} [oPeriodRef] - optional period object used by the
   * `tracksStore` API, allows to optimize the method by bypassing this step.
   */
  private _priv_onAvailableTracksMayHaveChanged(
    trackType: IBufferType,
    oPeriodRef?: ITSPeriodObject,
  ): void {
    const contentInfos = this._priv_contentInfos;
    if (contentInfos === null) {
      return;
    }
    const { currentPeriod, tracksStore, currentContentCanceller } = contentInfos;
    const cancelSignal = currentContentCanceller.signal;
    if (isNullOrUndefined(currentPeriod) || tracksStore === null) {
      return;
    }
    const periodRef = oPeriodRef ?? tracksStore.getPeriodObjectFromPeriod(currentPeriod);
    if (periodRef === undefined) {
      return;
    }
    switch (trackType) {
      case "video": {
        const videoTracks = tracksStore.getAvailableVideoTracks(periodRef, true);
        this._priv_triggerEventIfNotStopped(
          "availableVideoTracksChange",
          videoTracks ?? [],
          cancelSignal,
        );
        break;
      }
      case "audio": {
        const audioTracks = tracksStore.getAvailableAudioTracks(periodRef, true);
        this._priv_triggerEventIfNotStopped(
          "availableAudioTracksChange",
          audioTracks ?? [],
          cancelSignal,
        );
        break;
      }
      case "text": {
        const textTracks = tracksStore.getAvailableTextTracks(periodRef);
        this._priv_triggerEventIfNotStopped(
          "availableTextTracksChange",
          textTracks ?? [],
          cancelSignal,
        );
        break;
      }
      default:
        assertUnreachable(trackType);
    }
  }

  /**
   * Method to call when a fatal error lead to the stopping of the current
   * content.
   *
   * @param {*} err - The error encountered.
   * @param {Object} contentInfos - The `IPublicApiContentInfos` object linked
   * to the content for which the error was received.
   */
  private _priv_onFatalError(err: unknown, contentInfos: IPublicApiContentInfos): void {
    if (contentInfos.contentId !== this._priv_contentInfos?.contentId) {
      return; // Event for another content
    }
    const formattedError = formatError(err, {
      defaultCode: "NONE",
      defaultReason: "An unknown error stopped content playback.",
    });
    formattedError.fatal = true;
    contentInfos.currentContentCanceller.cancel("fatal err");
    this._priv_cleanUpCurrentContentState();
    this._priv_currentError = formattedError;
    log.error("API", "The player stopped because of an error", formattedError);
    this._priv_setPlayerState(PLAYER_STATES.STOPPED);

    // TODO This condition is here because the eventual callback called when the
    // player state is updated can launch a new content, thus the error will not
    // be here anymore, in which case triggering the "error" event is unwanted.
    // This is very ugly though, and we should probable have a better solution
    if (this._priv_currentError === formattedError) {
      this.trigger("error", formattedError);
    }
  }

  /**
   * Returns `true` if the content concerned by those options should load in
   * multithread mode.
   * Returns `false` if it should load in main thread.
   * @param {Object} options - The `loadVideo` options for that content.
   * @returns {boolean} - `true` if the content should be loaded in multithread
   * mode.
   */
  private _priv_shouldLoadMultithread(options: IParsedLoadVideoOptions): boolean {
    if (options.mode === "main") {
      return false;
    }
    if (options.mode === "multithread") {
      return true;
    }
    if (options.transport === "directfile") {
      return false;
    }

    if (features.multithread === null || this._priv_workerData === null) {
      // No possibility for multithread
      return false;
    }
    if (!isNullOrUndefined(this.videoElement?.FORCED_MEDIA_SOURCE)) {
      // Special case: Dummy Media Element only in main thread for now
      // TODO: Make it work with multithread?
      return false;
    }
    if (options.transport === "directfile") {
      return false;
    }
    if (
      features.monothread === null ||
      typeof features.transports[options.transport] !== "function"
    ) {
      // We cannot play in monothread anyway, but we may be able to in
      // multithread
      return true;
    }
    if (
      options.manifestLoader !== undefined &&
      typeof options.manifestLoader.fn === "function" &&
      isNullOrUndefined(options.manifestLoader.workerId)
    ) {
      return false;
    }
    if (
      options.segmentLoader !== undefined &&
      typeof options.segmentLoader.fn === "function" &&
      isNullOrUndefined(options.segmentLoader.workerId)
    ) {
      return false;
    }
    if (
      options.representationFilter !== undefined &&
      typeof options.representationFilter.fn === "function" &&
      isNullOrUndefined(options.representationFilter.workerId) &&
      isNullOrUndefined(options.representationFilter.eval)
    ) {
      return false;
    }
    return true;
  }
}
Player.version = /* PLAYER_VERSION */ "4.5.0";

/** Every events sent by the RxPlayer's public API. */
interface IPublicAPIEvent {
  playerStateChange: string;
  positionUpdate: IPositionUpdate;
  audioTrackChange: IAudioTrack | null;
  textTrackChange: ITextTrack | null;
  videoTrackChange: IVideoTrack | null;
  audioRepresentationChange: IAudioRepresentation | null;
  videoRepresentationChange: IVideoRepresentation | null;
  volumeChange: {
    volume: number;
    muted: boolean;
  };
  error: IPlayerError | Error;
  warning: IPlayerError | Error;
  periodChange: IPeriodChangeEvent;
  availableAudioTracksChange: IAvailableAudioTrack[];
  availableTextTracksChange: IAvailableTextTrack[];
  availableVideoTracksChange: IAvailableVideoTrack[];
  play: null;
  pause: null;
  newAvailablePeriods: IPeriod[];
  brokenRepresentationsLock: IBrokenRepresentationsLockContext;
  trackUpdate: ITrackUpdateEventPayload;
  representationListUpdate: IRepresentationListUpdateContext;
  seeking: null;
  seeked: null;
  streamEvent: IStreamEvent;
  streamEventSkip: IStreamEvent;
  inbandEvents: IInbandEvent[];
  noPlayableTrack: INoPlayableTrackEventPayload;
}

/** State linked to a particular contents loaded by the public API. */
export interface IPublicApiContentInfos {
  /**
   * Unique identifier for this `IPublicApiContentInfos` object.
   * Allows to identify and thus compare this `contentInfos` object with another
   * one.
   */
  contentId: string;
  /** Original URL set to load the content. */
  originalUrl: string | undefined;
  /** `ContentInitializer` used to load the content. */
  initializer: ContentInitializer;
  /** interface emitting regularly playback observations. */
  playbackObserver: MediaElementPlaybackObserver;
  /** TaskCanceller triggered when it's time to stop the current content. */
  currentContentCanceller: TaskCanceller;
  /** The default behavior to adopt when switching the audio track. */
  defaultAudioTrackSwitchingMode: IAudioTrackSwitchingMode | undefined;
  /**
   * `true` if the current content is in DirectFile mode.
   * `false` is the current content has a transport protocol (Smooth/DASH...).
   */
  isDirectFile: boolean;
  /**
   * Manifest linked to the current content.
   * `null` if the current content loaded has no manifest or if the content is
   * not yet loaded.
   */
  manifest: IManifestMetadata | null;
  /**
   * Current Period being played.
   * `null` if no Period is being played.
   */
  currentPeriod: IPeriodMetadata | null;
  /**
   * Store currently considered adaptations, per active period.
   * `null` if no Adaptation is active
   */
  activeAdaptations: {
    [periodId: string]: Partial<Record<IBufferType, IAdaptationMetadata | null>>;
  } | null;
  /**
   * Store currently considered representations, per active period.
   * `null` if no Representation is active
   */
  activeRepresentations: {
    [periodId: string]: Partial<Record<IBufferType, IRepresentationMetadata | null>>;
  } | null;
  /**
   * TracksStore instance linked to the current content.
   * `null` if no content has been loaded or if the current content loaded
   * has no TracksStore.
   */
  tracksStore: TracksStore | null;
  /**
   * MediaElementTracksStore instance linked to the current content.
   * `null` if no content has been loaded or if the current content loaded
   * has no MediaElementTracksStore.
   */
  mediaElementTracksStore: IMediaElementTracksStore | null;
  /** Track types currently handled by the player for the loaded content. */
  handledTrackTypes: Record<ITrackType, boolean>;
  /**
   * If `true`, the RxPlayer's main logic is running in a WebWorker for this
   * content.
   */
  useWorker: boolean;
  /**
   * Function passed from the ContentInitializer that return segment sinks metrics.
   * This is used for monitor and debugging.
   */
  segmentSinkMetricsCallback: null | (() => Promise<ISegmentSinkMetrics | undefined>);
  /**
   * Function allowing to retrieve thumbnails from a content.
   */
  fetchThumbnailDataCallback:
    | null
    | ((
        periodId: string,
        thumbnailTrackId: string,
        time: number,
      ) => Promise<IThumbnailResponse>);
  /** Metadata related to thumbnail rendering for the current content. */
  thumbnailRequestsInfo: {
    /**
     * Thumbnail requests that are still pending, identified by the thumbnail
     * container.
     * The value allows to cancel that task.
     */
    pendingRequests: WeakMap<HTMLElement, TaskCanceller>;
    /**
     * Metadata about the last requested thumbnails.
     *
     * This is an optimization to avoid an unnecessary request and round-trip to
     * the core code as many times thumbnail previews asked by applications are
     * really close to the last asked one, often in the same thumbnail resource.
     */
    lastResponse: {
      /** Actual thumbnail data response from core RxPlayer code. */
      response: IThumbnailResponse;
      /** The identifier for the Period for which that request was made. */
      periodId: string;
      /** The identifier for the thumbnail track for which that request was made. */
      thumbnailTrackId: string;
    } | null;
  };
  /**
   * Specifies the behavior when all audio tracks are not playable.
   *
   * - If set to `"continue"`, the player will proceed to play the content without audio.
   * - If set to `"error"`, an error will be thrown to indicate that the audio tracks could not be played.
   *
   * Note: If neither the audio nor the video tracks are playable, an error will be thrown regardless of this setting.
   */
  onAudioTracksNotPlayable: "continue" | "error";

  /**
   * Specifies the behavior when all video tracks are not playable.
   *
   * - If set to `"continue"`, the player will proceed to play the content without video.
   * - If set to `"error"`, an error will be thrown to indicate that the video tracks could not be played.
   *
   * Note: If neither the audio nor the video tracks are playable, an error will be thrown regardless of this setting.
   */
  onVideoTracksNotPlayable: "continue" | "error";
}

/**
 * Object allowing communication with the Worker associated to an `RxPlayer`'s instance.
 */
interface IWorkerInterface {
  /**
   * Allows to send a message to a Worker associated with an `RxPlayer` instance.
   * It should be coupled with an `addMessageListener` done on the worker-side
   * for the corresponding `messageName`.
   * @param {string} messageName - Name for the associated event that has been
   * listened to through an `addMessageListener` call on the worker-side.
   * Can be any string.
   * @param {Object} payload - Payload for that particular event.
   */
  sendMessage(messageName: string, payload: unknown): void;
  /**
   * Listen to events sent by the worker-side, so that if it decides to call
   * sendMessage` there for that same `messageName`, you callback will be
   * triggered.
   * @param {string} messageName - Name for the event you want to listen to.
   * @param {Function} callback - callback that will be triggered any time that
   * event is received from the Worker-side with the sent payload as argument.
   */
  addMessageListener(messageName: string, callback: (x: unknown) => void): void;
  /**
   * Remove a callback previously registered through `addMessageListener` on this
   * same interface.
   * @param {string} messageName - Name for the event you want to unregister.
   * @param {Function} callback - Callback you want to unregister for that
   * event.
   * Should be the exact same callback (same reference to it) than the one
   * registered previously through `addMessageListener`.
   */
  removeMessageListener(messageName: string, callback: (x: unknown) => void): void;
}

export default Player;
