bigscreenplayer.js

/**
 * @module bigscreenplayer/bigscreenplayer
 */
import MediaState from "./models/mediastate"
import PlayerComponent from "./playercomponent"
import PauseTriggers from "./models/pausetriggers"
import { canPauseAndSeek } from "./dynamicwindowutils"
import MockBigscreenPlayer from "./mockbigscreenplayer"
import Plugins from "./plugins"
import DebugTool from "./debugger/debugtool"
import {
  presentationTimeToMediaSampleTimeInSeconds,
  mediaSampleTimeToPresentationTimeInSeconds,
  presentationTimeToAvailabilityTimeInMilliseconds,
  availabilityTimeToPresentationTimeInSeconds,
} from "./utils/timeutils"
import callCallbacks from "./utils/callcallbacks"
import MediaSources from "./mediasources"
import Version from "./version"
import Resizer from "./resizer"
import ReadyHelper from "./readyhelper"
import Subtitles from "./subtitles/subtitles"
// TODO: Remove when this becomes a TypeScript file
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { InitData, InitCallbacks, SubtitlesCustomisationOptions, PlaybackTime } from "./types"
import { ManifestType } from "./models/manifesttypes"
import { Timeline } from "./models/timeline"

function BigscreenPlayer() {
  let stateChangeCallbacks = []
  let timeUpdateCallbacks = []
  let subtitleCallbacks = []
  let broadcastMixADCallbacks = []

  let playerReadyCallback
  let playerErrorCallback
  let mediaKind
  let initialPlaybackTime
  let playerComponent
  let resizer
  let pauseTrigger
  let isSeeking = false
  let endOfStream
  let mediaSources
  let playbackElement
  let readyHelper
  let subtitles

  const END_OF_STREAM_TOLERANCE = 10

  function mediaStateUpdateCallback(evt) {
    if (evt.timeUpdate) {
      callCallbacks(timeUpdateCallbacks, {
        currentTime: evt.data.currentTime,
        endOfStream,
      })
    } else {
      let stateObject = { state: evt.data.state }

      if (evt.data.state === MediaState.PAUSED) {
        endOfStream = false
        stateObject.trigger = pauseTrigger || PauseTriggers.DEVICE
        pauseTrigger = undefined
      }

      if (evt.data.state === MediaState.FATAL_ERROR) {
        stateObject = {
          state: MediaState.FATAL_ERROR,
          isBufferingTimeoutError: evt.isBufferingTimeoutError,
          code: evt.code,
          message: evt.message,
        }
      }

      if (evt.data.state === MediaState.WAITING) {
        stateObject.isSeeking = isSeeking
        isSeeking = false
      }

      stateObject.endOfStream = endOfStream
      DebugTool.statechange(evt.data.state)

      callCallbacks(stateChangeCallbacks, stateObject)
    }

    if (
      evt.data.seekableRange &&
      typeof evt.data.seekableRange.start === "number" &&
      typeof evt.data.seekableRange.end === "number"
    ) {
      DebugTool.staticMetric("seekable-range", [evt.data.seekableRange.start, evt.data.seekableRange.end])
    }

    if (evt.data.duration) {
      DebugTool.staticMetric("duration", evt.data.duration)
    }

    if (playerComponent && readyHelper) {
      readyHelper.callbackWhenReady(evt)
    }
  }

  function bigscreenPlayerDataLoaded({ media, enableSubtitles }) {
    const initialPresentationTime =
      initialPlaybackTime == null ? undefined : convertPlaybackTimeToPresentationTimeInSeconds(initialPlaybackTime)

    endOfStream =
      mediaSources.time().manifestType === ManifestType.DYNAMIC &&
      !initialPresentationTime &&
      initialPresentationTime !== 0

    readyHelper = ReadyHelper(
      initialPresentationTime,
      mediaSources.time().manifestType,
      PlayerComponent.getLiveSupport(),
      playerReadyCallback
    )

    playerComponent = PlayerComponent(
      playbackElement,
      { media, initialPlaybackTime: initialPresentationTime },
      mediaSources,
      mediaStateUpdateCallback,
      playerErrorCallback,
      callBroadcastMixADCallbacks
    )

    subtitles = Subtitles(
      playerComponent,
      enableSubtitles,
      playbackElement,
      media.subtitleCustomisation,
      mediaSources,
      callSubtitlesCallbacks
    )
  }

  /**
   * @typedef {Object} PlaybackTimeInit
   * @property {number} seconds
   * @property {Timeline} [timeline]
   */

  /**
   * Normalise time input to the 'PlaybackTime' model, so the unit and timeline is explicit.
   * @param {number | PlaybackTimeInit} init
   * @returns {PlaybackTime}
   */
  function createPlaybackTime(init) {
    if (typeof init === "number") {
      return { seconds: init, timeline: Timeline.PRESENTATION_TIME }
    }

    if (init == null || typeof init !== "object" || typeof init.seconds !== "number") {
      throw new TypeError("A numerical playback time must be provided")
    }

    return { seconds: init.seconds, timeline: init.timeline ?? Timeline.PRESENTATION_TIME }
  }

  function convertPlaybackTimeToPresentationTimeInSeconds(playbackTime) {
    const { seconds, timeline } = playbackTime

    switch (timeline) {
      case Timeline.PRESENTATION_TIME:
        return seconds
      case Timeline.MEDIA_SAMPLE_TIME:
        return convertMediaSampleTimeToPresentationTimeInSeconds(seconds)
      case Timeline.AVAILABILITY_TIME:
        return convertAvailabilityTimeToPresentationTimeInSeconds(seconds * 1000)
      default:
        return seconds
    }
  }

  function convertPresentationTimeToMediaSampleTimeInSeconds(presentationTimeInSeconds) {
    return mediaSources?.time() == null
      ? null
      : presentationTimeToMediaSampleTimeInSeconds(
          presentationTimeInSeconds,
          mediaSources.time().presentationTimeOffsetInMilliseconds
        )
  }

  function convertMediaSampleTimeToPresentationTimeInSeconds(mediaSampleTimeInSeconds) {
    return mediaSources?.time() == null
      ? null
      : mediaSampleTimeToPresentationTimeInSeconds(
          mediaSampleTimeInSeconds,
          mediaSources.time().presentationTimeOffsetInMilliseconds
        )
  }

  function convertPresentationTimeToAvailabilityTimeInMilliseconds(presentationTimeInSeconds) {
    return mediaSources?.time() == null || mediaSources?.time().manifestType === ManifestType.STATIC
      ? null
      : presentationTimeToAvailabilityTimeInMilliseconds(
          presentationTimeInSeconds,
          mediaSources.time().availabilityStartTimeInMilliseconds
        )
  }

  function convertAvailabilityTimeToPresentationTimeInSeconds(availabilityTimeInMilliseconds) {
    return mediaSources?.time() == null || mediaSources?.time().manifestType === ManifestType.STATIC
      ? null
      : availabilityTimeToPresentationTimeInSeconds(
          availabilityTimeInMilliseconds,
          mediaSources.time().availabilityStartTimeInMilliseconds
        )
  }

  function getInitialPlaybackTime() {
    return initialPlaybackTime
  }

  function toggleDebug() {
    if (playerComponent) {
      DebugTool.toggleVisibility()
    }
  }

  function callSubtitlesCallbacks(enabled) {
    callCallbacks(subtitleCallbacks, { enabled })
  }

  function setSubtitlesEnabled(enabled) {
    enabled ? subtitles.enable() : subtitles.disable()
    callSubtitlesCallbacks(enabled)

    if (!resizer.isResized()) {
      enabled ? subtitles.show() : subtitles.hide()
    }
  }

  function isSubtitlesEnabled() {
    return subtitles ? subtitles.enabled() : false
  }

  function isSubtitlesAvailable() {
    return subtitles ? subtitles.available() : false
  }

  function getTimeShiftBufferDepthInMilliseconds() {
    return mediaSources.time()?.timeShiftBufferDepthInMilliseconds ?? null
  }

  function getPresentationTimeOffsetInMilliseconds() {
    return mediaSources.time()?.presentationTimeOffsetInMilliseconds ?? null
  }

  function callBroadcastMixADCallbacks(enabled) {
    callCallbacks(broadcastMixADCallbacks, { enabled })
  }

  return /** @alias module:bigscreenplayer/bigscreenplayer */ {
    /**
     * Call first to initialise bigscreen player for playback.
     * @function
     * @name init
     * @param {HTMLDivElement} playbackElement - The Div element where content elements should be rendered
     * @param {InitData} bigscreenPlayerData
     * @param {InitCallbacks} callbacks
     */
    init: (newPlaybackElement, bigscreenPlayerData, callbacks = {}) => {
      playbackElement = newPlaybackElement
      DebugTool.init()
      DebugTool.setRootElement(playbackElement)
      resizer = Resizer()

      mediaKind = bigscreenPlayerData.media.kind

      if (bigscreenPlayerData.initialPlaybackTime || bigscreenPlayerData.initialPlaybackTime === 0) {
        initialPlaybackTime = createPlaybackTime(bigscreenPlayerData.initialPlaybackTime)
      }

      DebugTool.staticMetric("version", Version)

      if (initialPlaybackTime) {
        const { seconds, timeline } = initialPlaybackTime
        DebugTool.staticMetric("initial-playback-time", [seconds, timeline])
      }

      if (typeof window.bigscreenPlayer?.playbackStrategy === "string") {
        DebugTool.staticMetric("strategy", window.bigscreenPlayer && window.bigscreenPlayer.playbackStrategy)
      }

      playerReadyCallback = callbacks.onSuccess
      playerErrorCallback = callbacks.onError

      mediaSources = MediaSources()

      mediaSources
        .init(bigscreenPlayerData.media)
        .then(() => bigscreenPlayerDataLoaded(bigscreenPlayerData))
        .catch((reason) => {
          if (typeof callbacks?.onError === "function") {
            callbacks.onError(reason)
          }
        })
    },

    /**
     * Should be called at the end of all playback sessions. Resets state and clears any UI.
     * @function
     * @name tearDown
     */
    tearDown() {
      if (subtitles) {
        subtitles.tearDown()
        subtitles = undefined
      }

      if (playerComponent) {
        playerComponent.tearDown()
        playerComponent = undefined
      }

      if (mediaSources) {
        mediaSources.tearDown()
        mediaSources = undefined
      }

      stateChangeCallbacks = []
      timeUpdateCallbacks = []
      subtitleCallbacks = []
      broadcastMixADCallbacks = []
      endOfStream = undefined
      mediaKind = undefined
      pauseTrigger = undefined
      resizer = undefined
      this.unregisterPlugin()
      DebugTool.tearDown()
    },

    /**
     * Pass a function to call whenever the player transitions state.
     * @see {@link module:models/mediastate}
     * @function
     * @param {Function} callback
     */
    registerForStateChanges: (callback) => {
      stateChangeCallbacks.push(callback)
      return callback
    },

    /**
     * Unregisters a previously registered callback.
     * @function
     * @param {Function} callback
     */
    unregisterForStateChanges: (callback) => {
      const indexOf = stateChangeCallbacks.indexOf(callback)
      if (indexOf !== -1) {
        stateChangeCallbacks.splice(indexOf, 1)
      }
    },

    /**
     * Pass a function to call whenever the player issues a time update.
     * @function
     * @param {Function} callback
     */
    registerForTimeUpdates: (callback) => {
      timeUpdateCallbacks.push(callback)
      return callback
    },

    /**
     * Unregisters a previously registered callback.
     * @function
     * @param {Function} callback
     */
    unregisterForTimeUpdates: (callback) => {
      const indexOf = timeUpdateCallbacks.indexOf(callback)
      if (indexOf !== -1) {
        timeUpdateCallbacks.splice(indexOf, 1)
      }
    },

    /**
     * Pass a function to be called whenever subtitles are enabled or disabled.
     * @function
     * @param {Function} callback
     */
    registerForSubtitleChanges: (callback) => {
      subtitleCallbacks.push(callback)
      return callback
    },

    /**
     * Unregisters a previously registered callback for changes to subtitles.
     * @function
     * @param {Function} callback
     */
    unregisterForSubtitleChanges: (callback) => {
      const indexOf = subtitleCallbacks.indexOf(callback)
      if (indexOf !== -1) {
        subtitleCallbacks.splice(indexOf, 1)
      }
    },

    /**
     * Pass a function to be called whenever BroadcastMixAD is enabled or disabled.
     * @function
     * @param {Function} callback
     */
    registerForBroadcastMixADChanges: (callback) => {
      broadcastMixADCallbacks.push(callback)
      return callback
    },

    /**
     * Unregisters a previously registered callback for changes to BroadcastMixAD.
     * @function
     * @param {Function} callback
     */
    unregisterForBroadcastMixADChanges: (callback) => {
      const indexOf = broadcastMixADCallbacks.indexOf(callback)
      if (indexOf !== -1) {
        broadcastMixADCallbacks.splice(indexOf, 1)
      }
    },

    /**
     * Sets the current time of the media asset.
     * @function
     * @param {number} seconds
     * @param {Timeline} timeline
     */
    setCurrentTime(seconds, timeline) {
      const playbackTime = createPlaybackTime({ seconds, timeline })

      DebugTool.apicall("setCurrentTime", [playbackTime.seconds.toFixed(3), playbackTime.timeline])

      if (playerComponent) {
        // this flag must be set before calling into playerComponent.setCurrentTime - as this synchronously fires a WAITING event (when native strategy).
        isSeeking = true

        const presentationTimeInSeconds = convertPlaybackTimeToPresentationTimeInSeconds(playbackTime)

        playerComponent.setCurrentTime(presentationTimeInSeconds)

        endOfStream =
          mediaSources.time().manifestType === ManifestType.DYNAMIC &&
          Math.abs(this.getSeekableRange().end - presentationTimeInSeconds) < END_OF_STREAM_TOLERANCE
      }
    },

    /**
     * Set the media element playback rate
     *
     * @function
     * @param {Number} rate
     */
    setPlaybackRate: (rate) => {
      if (playerComponent) {
        playerComponent.setPlaybackRate(rate)
      }
    },

    /**
     * Get the current playback rate
     * @function
     * @returns {Number} the current media playback rate
     */
    getPlaybackRate: () => playerComponent && playerComponent.getPlaybackRate(),

    /**
     * Returns the media asset's current time in seconds.
     * @function
     * @returns {Number}
     */
    getCurrentTime: () => (playerComponent && playerComponent.getCurrentTime()) || 0,

    /**
     * Returns the current media kind.
     * 'audio' or 'video'
     * @function
     */
    getMediaKind: () => mediaKind,

    /**
     * Returns an object including the current start and end times.
     * @function
     * @returns {Object | null} {start: Number, end: Number}
     */
    getSeekableRange: () => playerComponent?.getSeekableRange() ?? null,

    /**
     * @function
     * @returns {boolean} Returns true if media is initialised and playing a live stream within a tolerance of the end of the seekable range (10 seconds).
     */
    isPlayingAtLiveEdge() {
      return (
        !!playerComponent &&
        mediaSources.time().manifestType === ManifestType.DYNAMIC &&
        Math.abs(this.getSeekableRange().end - this.getCurrentTime()) < END_OF_STREAM_TOLERANCE
      )
    },

    /**
     * @function
     * @returns the duration of the media asset.
     */
    getDuration: () => playerComponent && playerComponent.getDuration(),

    /**
     * @function
     * @returns if the player is paused.
     */
    isPaused: () => (playerComponent ? playerComponent.isPaused() : true),

    /**
     * @function
     * @returns if the media asset has ended.
     */
    isEnded: () => (playerComponent ? playerComponent.isEnded() : false),

    /**
     * Play the media assest from the current point in time.
     * @function
     */
    play: () => {
      DebugTool.apicall("play")

      playerComponent.play()
    },
    /**
     * Pause the media asset.
     * @function
     * @param {*} opts
     * @param {boolean} opts.userPause
     */
    pause: (opts) => {
      DebugTool.apicall("pause")

      pauseTrigger = opts?.userPause || opts?.userPause == null ? PauseTriggers.USER : PauseTriggers.APP

      playerComponent.pause()
    },

    /**
     * Resize the video container div in the most compatible way
     *
     * @function
     * @param {Number} top - px
     * @param {Number} left -  px
     * @param {Number} width -  px
     * @param {Number} height -  px
     * @param {Number} zIndex
     */
    resize: (top, left, width, height, zIndex) => {
      subtitles.hide()
      resizer.resize(playbackElement, top, left, width, height, zIndex)
    },

    /**
     * Clear any resize properties added with `resize`
     * @function
     */
    clearResize: () => {
      if (subtitles.enabled()) {
        subtitles.show()
      } else {
        subtitles.hide()
      }
      resizer.clear(playbackElement)
    },

    /**
     * Set whether or not subtitles should be enabled.
     * @function
     * @param {boolean} value
     */
    setSubtitlesEnabled,

    /**
     * @function
     * @return if subtitles are currently enabled.
     */
    isSubtitlesEnabled,

    /**
     * @function
     * @return Returns whether or not subtitles are currently enabled.
     */
    isSubtitlesAvailable,

    /**
     * Returns if a device supports the customisation of subtitles
     *
     * @returns boolean
     */
    areSubtitlesCustomisable: () =>
      !(window.bigscreenPlayer && window.bigscreenPlayer.overrides && window.bigscreenPlayer.overrides.legacySubtitles),

    /**
     * Customise the rendered subitles style
     *
     * @param {SubtitlesCustomisationOptions} styleOpts
     */
    customiseSubtitles: (styleOpts) => {
      if (subtitles) {
        subtitles.customise(styleOpts)
      }
    },

    /**
     * Render an example subtitles string with a given style and location
     *
     * @param {string} xmlString - EBU-TT-D compliant XML String
     * @param {SubtitlesCustomisationOptions} styleOpts
     * @param {DOMRect} safePosition
     */
    renderSubtitleExample: (xmlString, styleOpts, safePosition) => {
      if (subtitles) {
        subtitles.renderExample(xmlString, styleOpts, safePosition)
      }
    },

    /**
     * Clear the example subtitle string
     */
    clearSubtitleExample: () => {
      if (subtitles) {
        subtitles.clearExample()
      }
    },

    /**
     * @function
     * @returns {boolean} true if there if an AD track is available
     */
    isBroadcastMixADAvailable: () => playerComponent && playerComponent.isBroadcastMixADAvailable(),

    /**
     * @function
     * @returns {boolean} true if there is an the AD audio track is current being used
     */
    isBroadcastMixADEnabled: () => playerComponent && playerComponent.isBroadcastMixADEnabled(),

    /**
     * @function
     */
    setBroadcastMixADEnabled: (enabled) => {
      enabled ? playerComponent.setBroadcastMixADOn() : playerComponent.setBroadcastMixADOff()
    },

    /**
     *
     * An enum may be used to set the on-screen position of any transport controls
     * (work in progress to remove this - UI concern).
     * @function
     * @param {*} position
     */
    setTransportControlsPosition: (position) => {
      if (subtitles) {
        subtitles.setPosition(position)
      }
    },

    /**
     * @function
     * @return Returns whether the current media asset is seekable.
     */
    canSeek() {
      return (
        mediaSources.time().manifestType === ManifestType.STATIC ||
        canPauseAndSeek(getLiveSupport(), this.getSeekableRange())
      )
    },

    /**
     * @function
     * @return Returns whether the current media asset is pausable.
     */
    canPause() {
      return (
        mediaSources.time().manifestType === ManifestType.STATIC ||
        canPauseAndSeek(getLiveSupport(), this.getSeekableRange())
      )
    },

    /**
     * Return a mock for in place testing.
     * @function
     * @param {*} opts
     */
    mock(opts) {
      MockBigscreenPlayer.mock(this, opts)
    },

    /**
     * Unmock the player.
     * @function
     */
    unmock() {
      MockBigscreenPlayer.unmock(this)
    },

    /**
     * Return a mock for unit tests.
     * @function
     * @param {*} opts
     */
    mockJasmine(opts) {
      MockBigscreenPlayer.mockJasmine(this, opts)
    },

    /**
     * Register a plugin for extended events.
     * @function
     * @param {*} plugin
     */
    registerPlugin: (plugin) => Plugins.registerPlugin(plugin),

    /**
     * Unregister a previously registered plugin.
     * @function
     * @param {*} plugin
     */
    unregisterPlugin: (plugin) => Plugins.unregisterPlugin(plugin),

    /**
     * Returns an object with a number of functions related to the ability to transition state
     * given the current state and the playback strategy in use.
     * @function
     */
    transitions: () => (playerComponent ? playerComponent.transitions() : {}),

    /**
     * @function
     * @return The media element currently being used.
     */
    getPlayerElement: () => playerComponent && playerComponent.getPlayerElement(),

    /**
     * @function
     * @return The runtime version of the library.
     */
    getFrameworkVersion: () => Version,

    /**
     * Toggle the visibility of the debug tool overlay.
     * @function
     */
    toggleDebug,

    /**
     * @function
     * @return {Object} - Key value pairs of available log levels
     */
    getLogLevels: () => DebugTool.logLevels,

    /**
     * @function
     * @param logLevel -  log level to display @see getLogLevels
     */
    setLogLevel: (level) => DebugTool.setLogLevel(level),
    getDebugLogs: () => DebugTool.getDebugLogs(),
    convertPresentationTimeToMediaSampleTimeInSeconds,
    convertMediaSampleTimeToPresentationTimeInSeconds,
    convertPresentationTimeToAvailabilityTimeInMilliseconds,
    convertAvailabilityTimeToPresentationTimeInSeconds,
    getInitialPlaybackTime,
    getTimeShiftBufferDepthInMilliseconds,
    getPresentationTimeOffsetInMilliseconds,
  }
}

function getLiveSupport() {
  return PlayerComponent.getLiveSupport()
}

BigscreenPlayer.getLiveSupport = getLiveSupport

BigscreenPlayer.version = Version

export default BigscreenPlayer