playbackstrategy_modifiers_samsungstreaming2015.js

/**
 * @preserve Copyright (c) 2017-present British Broadcasting Corporation. All rights reserved.
 * @license See https://github.com/fmtvp/tal/blob/master/LICENSE for full licence
 */
import MediaPlayerBase from "../modifiers/mediaplayerbase"
import DebugTool from "../../debugger/debugtool"

function SamsungStreaming2015() {
  let state = MediaPlayerBase.STATE.EMPTY
  let currentPlayer
  let deferSeekingTo = null
  let nextSeekingTo = null
  let postBufferingState = null
  let tryingToPause = false
  let currentTimeKnown = false
  let updatingTime = false
  let lastWindowRanged = false

  let mediaType
  let source
  let mimeType

  let range
  let currentTime

  let eventCallbacks = []
  let eventCallback

  let playerPlugin
  let tvmwPlugin
  let originalSource

  try {
    _registerSamsungPlugins()
  } catch (ignoreErr) {}

  const PlayerEventCodes = {
    CONNECTION_FAILED: 1,
    AUTHENTICATION_FAILED: 2,
    STREAM_NOT_FOUND: 3,
    NETWORK_DISCONNECTED: 4,
    NETWORK_SLOW: 5,
    RENDER_ERROR: 6,
    RENDERING_START: 7,
    RENDERING_COMPLETE: 8,
    STREAM_INFO_READY: 9,
    DECODING_COMPLETE: 10,
    BUFFERING_START: 11,
    BUFFERING_COMPLETE: 12,
    BUFFERING_PROGRESS: 13,
    CURRENT_PLAYBACK_TIME: 14,
    AD_START: 15,
    AD_END: 16,
    RESOLUTION_CHANGED: 17,
    BITRATE_CHANGED: 18,
    SUBTITLE: 19,
    CUSTOM: 20,
  }

  const PlayerEmps = {
    Player: 0,
    StreamingPlayer: 1,
  }

  /**
   * @constant {Number} Time (in seconds) compared to current time within which seeking has no effect.
   * Jumping to time lower than 3s causes error in PlayFrom60 on HLS live - player jumps to previous chunk.
   * Value set to 4s to be ahead of potential wrong player jumps.
   */
  const CURRENT_TIME_TOLERANCE = 4
  const CLAMP_OFFSET_FROM_END_OF_LIVE_RANGE = 10
  const CLAMP_OFFSET_FROM_START_OF_RANGE = 1.1
  const CLAMP_OFFSET_FROM_END_OF_RANGE = 1.1
  const RANGE_UPDATE_TOLERANCE = 8
  const RANGE_END_TOLERANCE = 100

  function initialiseMedia(type, url, mediaMimeType) {
    if (this.getState() === MediaPlayerBase.STATE.EMPTY) {
      mediaType = type
      source = url
      mimeType = mediaMimeType
      _registerEventHandlers()
      _toStopped()

      if (_isHlsMimeType()) {
        source += "|COMPONENT=HLS"
      }
      _openPlayerPlugin()

      _initPlayer(source)
    } else {
      _toError("Cannot set source unless in the '" + MediaPlayerBase.STATE.EMPTY + "' state")
    }
  }

  function resume() {
    postBufferingState = MediaPlayerBase.STATE.PLAYING
    switch (getState()) {
      case MediaPlayerBase.STATE.PLAYING:
        break

      case MediaPlayerBase.STATE.BUFFERING:
        if (tryingToPause) {
          tryingToPause = false
          toPlaying()
        }
        break

      case MediaPlayerBase.STATE.PAUSED:
        playerPlugin.Execute("Resume")
        toPlaying()
        break

      default:
        _toError("Cannot resume while in the '" + getState() + "' state")
        break
    }
  }

  function playFrom(seconds) {
    postBufferingState = MediaPlayerBase.STATE.PLAYING
    const seekingTo = range ? _getClampedTimeForPlayFrom(seconds) : seconds

    switch (getState()) {
      case MediaPlayerBase.STATE.BUFFERING:
        // deferSeekingTo = seekingTo;
        nextSeekingTo = seekingTo
        break

      case MediaPlayerBase.STATE.PLAYING:
        _toBuffering()
        if (!currentTimeKnown) {
          deferSeekingTo = seekingTo
        } else if (_isNearToCurrentTime(seekingTo)) {
          toPlaying()
        } else {
          _seekToWithFailureStateTransition(seekingTo)
        }
        break

      case MediaPlayerBase.STATE.PAUSED:
        _toBuffering()
        if (!currentTimeKnown) {
          deferSeekingTo = seekingTo
        } else if (_isNearToCurrentTime(seekingTo)) {
          playerPlugin.Execute("Resume")
          toPlaying()
        } else {
          _seekToWithFailureStateTransition(seekingTo)
          playerPlugin.Execute("Resume")
        }
        break

      case MediaPlayerBase.STATE.COMPLETE:
        playerPlugin.Execute("Stop")
        _initPlayer(source)
        playerPlugin.Execute("StartPlayback", seekingTo)
        _toBuffering()
        break

      default:
        _toError("Cannot playFrom while in the '" + getState() + "' state")
        break
    }
  }

  function beginPlayback() {
    postBufferingState = MediaPlayerBase.STATE.PLAYING
    switch (getState()) {
      case MediaPlayerBase.STATE.STOPPED:
        _toBuffering()
        playerPlugin.Execute("StartPlayback")
        break

      default:
        _toError("Cannot beginPlayback while in the '" + getState() + "' state")
        break
    }
  }

  function beginPlaybackFrom(seconds) {
    postBufferingState = MediaPlayerBase.STATE.PLAYING
    let seekingTo = getSeekableRange() ? _getClampedTimeForPlayFrom(seconds) : seconds

    // StartPlayback from near start of range causes spoiler defect
    if (seekingTo < CLAMP_OFFSET_FROM_START_OF_RANGE && _isLiveMedia()) {
      seekingTo = CLAMP_OFFSET_FROM_START_OF_RANGE
    } else {
      seekingTo = parseInt(Math.floor(seekingTo), 10)
    }

    switch (getState()) {
      case MediaPlayerBase.STATE.STOPPED:
        playerPlugin.Execute("StartPlayback", seekingTo)
        _toBuffering()
        break

      default:
        _toError("Cannot beginPlayback while in the '" + getState() + "' state")
        break
    }
  }

  function pause() {
    postBufferingState = MediaPlayerBase.STATE.PAUSED
    switch (getState()) {
      case MediaPlayerBase.STATE.BUFFERING:
      case MediaPlayerBase.STATE.PAUSED:
        break

      case MediaPlayerBase.STATE.PLAYING:
        _tryPauseWithStateTransition()
        break

      default:
        _toError("Cannot pause while in the '" + getState() + "' state")
        break
    }
  }

  function stop() {
    switch (getState()) {
      case MediaPlayerBase.STATE.STOPPED:
        break

      case MediaPlayerBase.STATE.BUFFERING:
      case MediaPlayerBase.STATE.PLAYING:
      case MediaPlayerBase.STATE.PAUSED:
      case MediaPlayerBase.STATE.COMPLETE:
        _stopPlayer()
        _toStopped()
        break

      default:
        _toError("Cannot stop while in the '" + getState() + "' state")
        break
    }
  }

  function reset() {
    switch (getState()) {
      case MediaPlayerBase.STATE.EMPTY:
        break

      case MediaPlayerBase.STATE.STOPPED:
      case MediaPlayerBase.STATE.ERROR:
        _toEmpty()
        break

      default:
        _toError("Cannot reset while in the '" + getState() + "' state")
        break
    }
  }

  function getSource() {
    return source
  }

  function getMimeType() {
    return mimeType
  }

  function getCurrentTime() {
    if (getState() === MediaPlayerBase.STATE.STOPPED) {
      return undefined
    } else {
      return currentTime
    }
  }

  function getSeekableRange() {
    switch (getState()) {
      case MediaPlayerBase.STATE.STOPPED:
      case MediaPlayerBase.STATE.ERROR:
        break

      default:
        return range
    }

    return undefined
  }

  function getDuration() {
    if (range) {
      return range.end
    }

    return undefined
  }

  function getState() {
    return state
  }

  function getPlayerElement() {
    return playerPlugin
  }

  function toPlaying() {
    if (_isHlsMimeType() && _isLiveMedia() && !updatingTime) {
      _updateRange()
    }

    state = MediaPlayerBase.STATE.PLAYING
    _emitEvent(MediaPlayerBase.EVENT.PLAYING)
  }

  function toPaused() {
    state = MediaPlayerBase.STATE.PAUSED
    _emitEvent(MediaPlayerBase.EVENT.PAUSED)
  }

  function _toStopped() {
    currentTime = 0
    range = undefined
    state = MediaPlayerBase.STATE.STOPPED
    _emitEvent(MediaPlayerBase.EVENT.STOPPED)
  }

  function _toBuffering() {
    state = MediaPlayerBase.STATE.BUFFERING
    _emitEvent(MediaPlayerBase.EVENT.BUFFERING)
  }

  function _toComplete() {
    state = MediaPlayerBase.STATE.COMPLETE
    _emitEvent(MediaPlayerBase.EVENT.COMPLETE)
  }

  function _toEmpty() {
    _wipe()
    state = MediaPlayerBase.STATE.EMPTY
  }

  function _toError(errorMessage) {
    _wipe()
    state = MediaPlayerBase.STATE.ERROR
    _reportError(errorMessage)
    throw new Error("ApiError: " + errorMessage)
  }

  function _registerSamsungPlugins() {
    playerPlugin = document.getElementById("sefPlayer")
    tvmwPlugin = document.getElementById("pluginObjectTVMW")
    originalSource = tvmwPlugin.GetSource()
    window.addEventListener(
      "hide",
      () => {
        stop()
        tvmwPlugin.SetSource(originalSource)
      },
      false
    )
  }

  function _getClampedTime(seconds) {
    const range = getSeekableRange()
    const offsetFromEnd = _getClampOffsetFromConfig()
    const nearToEnd = Math.max(range.end - offsetFromEnd, range.start)

    if (seconds < range.start) {
      return range.start
    } else if (seconds > nearToEnd) {
      return nearToEnd
    } else {
      return seconds
    }
  }

  function _openPlayerPlugin() {
    if (currentPlayer !== undefined) {
      playerPlugin.Close()
    }

    playerPlugin.Open("Player", "1.010", "Player")
    currentPlayer = PlayerEmps.Player
  }

  function _isLiveRangeOutdated() {
    const time = Math.floor(currentTime)

    if (time % 8 === 0 && !updatingTime && lastWindowRanged !== time) {
      lastWindowRanged = time
      return true
    } else {
      return false
    }
  }

  function _closePlugin() {
    playerPlugin.Close()
    currentPlayer = undefined
  }

  function _initPlayer(source) {
    const result = playerPlugin.Execute("InitPlayer", source)

    if (result !== 1) {
      _toError("Failed to initialize video: " + source)
    }
  }

  function _onFinishedBuffering() {
    if (getState() !== MediaPlayerBase.STATE.BUFFERING) {
      return
    }

    if (!_isInitialBufferingFinished() && nextSeekingTo !== null) {
      deferSeekingTo = nextSeekingTo
      nextSeekingTo = null
    }

    if (deferSeekingTo === null) {
      if (postBufferingState === MediaPlayerBase.STATE.PAUSED) {
        _tryPauseWithStateTransition()
      } else {
        toPlaying()
      }
    }
  }

  function _onDeviceError(message) {
    _reportError(message)
  }

  function _onDeviceBuffering() {
    if (getState() === MediaPlayerBase.STATE.PLAYING) {
      _toBuffering()
    }
  }

  function _onEndOfMedia() {
    _toComplete()
  }

  function _stopPlayer() {
    playerPlugin.Execute("Stop")
    currentTimeKnown = false
  }

  function _tryPauseWithStateTransition() {
    let success = playerPlugin.Execute("Pause")
    success = success && success !== -1

    if (success) {
      toPaused()
    }

    tryingToPause = !success
  }

  function _onStatus() {
    const state = getState()

    if (state === MediaPlayerBase.STATE.PLAYING) {
      _emitEvent(MediaPlayerBase.EVENT.STATUS)
    }
  }

  function _updateRange() {
    if (_isHlsMimeType() && _isLiveMedia()) {
      const playingRange = playerPlugin.Execute("GetLiveDuration").split("|")

      range = {
        start: Math.floor(playingRange[0] / 1000),
        end: Math.floor(playingRange[1] / 1000),
      }

      // don't call range for the next 8 seconds
      updatingTime = true
      setTimeout(() => {
        updatingTime = false
      }, RANGE_UPDATE_TOLERANCE * 1000)
    } else {
      const duration = playerPlugin.Execute("GetDuration") / 1000
      range = {
        start: 0,
        end: duration,
      }
    }
  }

  function _onCurrentTime(timeInMillis) {
    currentTime = timeInMillis / 1000
    _onStatus()
    currentTimeKnown = true

    // [optimisation] do not call player API periodically in HLS live
    // - calculate range manually when possible
    // - do not calculate range if player API was called less than RANGE_UPDATE_TOLERANCE seconds ago
    if (_isLiveMedia() && _isLiveRangeOutdated()) {
      range.start += 8
      range.end += 8
    }

    if (nextSeekingTo !== null) {
      deferSeekingTo = nextSeekingTo
      nextSeekingTo = null
    }

    if (deferSeekingTo !== null) {
      _deferredSeek()
    }

    if (tryingToPause) {
      _tryPauseWithStateTransition()
    }
  }

  function _deferredSeek() {
    const clampedTime = _getClampedTimeForPlayFrom(deferSeekingTo)
    const isNearCurrentTime = _isNearToCurrentTime(clampedTime)

    if (isNearCurrentTime) {
      toPlaying()
      deferSeekingTo = null
    } else {
      const seekResult = _seekTo(clampedTime)
      if (seekResult) {
        deferSeekingTo = null
      }
    }
  }

  function _getClampedTimeForPlayFrom(seconds) {
    if (_isHlsMimeType() && _isLiveMedia() && !updatingTime) {
      _updateRange()
    }

    const clampedTime = _getClampedTime(seconds)

    if (clampedTime !== seconds) {
      DebugTool.info(
        "playFrom " +
          seconds +
          " clamped to " +
          clampedTime +
          " - seekable range is { start: " +
          range.start +
          ", end: " +
          range.end +
          " }"
      )
    }

    return clampedTime
  }

  function _getClampOffsetFromConfig() {
    if (_isLiveMedia()) {
      return CLAMP_OFFSET_FROM_END_OF_LIVE_RANGE
    } else {
      return CLAMP_OFFSET_FROM_END_OF_RANGE
    }
  }

  function _registerEventHandlers() {
    playerPlugin.OnEvent = (eventType, param1) => {
      switch (eventType) {
        case PlayerEventCodes.STREAM_INFO_READY:
          _updateRange()
          break

        case PlayerEventCodes.CURRENT_PLAYBACK_TIME:
          if (range && _isLiveMedia()) {
            const seconds = Math.floor(param1 / 1000)

            // jump to previous current time if PTS out of range occurs
            if (seconds > range.end + RANGE_END_TOLERANCE) {
              playFrom(currentTime)
              break
              // call GetPlayingRange() on SEF emp if current time is out of range
            } else if (!_isCurrentTimeInRangeTolerance(seconds)) {
              _updateRange()
            }
          }
          _onCurrentTime(param1)
          break

        case PlayerEventCodes.BUFFERING_START:
        case PlayerEventCodes.BUFFERING_PROGRESS:
          _onDeviceBuffering()
          break

        case PlayerEventCodes.BUFFERING_COMPLETE:
          // For live HLS, don't update the range more than once every 8 seconds
          if (!updatingTime) {
            _updateRange()
          }

          // [optimisation] if Stop() is not called after RENDERING_COMPLETE then player sends periodically BUFFERING_COMPLETE and RENDERING_COMPLETE
          // ignore BUFFERING_COMPLETE if player is already in COMPLETE state
          if (getState() !== MediaPlayerBase.STATE.COMPLETE) {
            _onFinishedBuffering()
          }
          break

        case PlayerEventCodes.RENDERING_COMPLETE:
          // [optimisation] if Stop() is not called after RENDERING_COMPLETE then player sends periodically BUFFERING_COMPLETE and RENDERING_COMPLETE
          // ignore RENDERING_COMPLETE if player is already in COMPLETE state
          if (getState() !== MediaPlayerBase.STATE.COMPLETE) {
            _onEndOfMedia()
          }
          break

        case PlayerEventCodes.CONNECTION_FAILED:
          _onDeviceError("Media element emitted OnConnectionFailed")
          break

        case PlayerEventCodes.NETWORK_DISCONNECTED:
          _onDeviceError("Media element emitted OnNetworkDisconnected")
          break

        case PlayerEventCodes.AUTHENTICATION_FAILED:
          _onDeviceError("Media element emitted OnAuthenticationFailed")
          break

        case PlayerEventCodes.RENDER_ERROR:
          _onDeviceError("Media element emitted OnRenderError")
          break

        case PlayerEventCodes.STREAM_NOT_FOUND:
          _onDeviceError("Media element emitted OnStreamNotFound")
          break
      }
    }

    window.addEventListener("hide", _onWindowHide, false)
    window.addEventListener("unload", _onWindowHide, false)
  }

  function _onWindowHide() {
    stop()
  }

  function _unregisterEventHandlers() {
    playerPlugin.OnEvent = undefined
    window.removeEventListener("hide", _onWindowHide, false)
    window.removeEventListener("unload", _onWindowHide, false)
  }

  function _wipe() {
    _stopPlayer()
    _closePlugin()
    _unregisterEventHandlers()

    mediaType = undefined
    source = undefined
    mimeType = undefined
    currentTime = undefined
    range = undefined
    deferSeekingTo = null
    nextSeekingTo = null
    tryingToPause = false
    currentTimeKnown = false
    updatingTime = false
    lastWindowRanged = false
  }

  function _seekTo(seconds) {
    const offset = seconds - getCurrentTime()
    const success = _jump(offset)

    if (success === 1) {
      currentTime = seconds
    }

    return success
  }

  function _seekToWithFailureStateTransition(seconds) {
    const success = _seekTo(seconds)

    if (success !== 1) {
      toPlaying()
    }
  }

  function _jump(offsetSeconds) {
    let result

    if (offsetSeconds > 0) {
      result = playerPlugin.Execute("JumpForward", offsetSeconds)
      return result
    } else {
      result = playerPlugin.Execute("JumpBackward", Math.abs(offsetSeconds))
      return result
    }
  }

  function _isHlsMimeType() {
    const mime = mimeType.toLowerCase()
    return mime === "application/vnd.apple.mpegurl" || mime === "application/x-mpegurl"
  }

  function _isCurrentTimeInRangeTolerance(seconds) {
    if (seconds > range.end + RANGE_UPDATE_TOLERANCE) {
      return false
    } else if (seconds < range.start - RANGE_UPDATE_TOLERANCE) {
      return false
    } else {
      return true
    }
  }

  function _isInitialBufferingFinished() {
    if (currentTime === undefined || currentTime === 0) {
      return false
    } else {
      return true
    }
  }

  function _reportError(errorMessage) {
    DebugTool.info(errorMessage)
    _emitEvent(MediaPlayerBase.EVENT.ERROR, { errorMessage: errorMessage })
  }

  function _isNearToCurrentTime(seconds) {
    const currentTime = getCurrentTime()
    const targetTime = _getClampedTime(seconds)

    return Math.abs(currentTime - targetTime) <= CURRENT_TIME_TOLERANCE
  }

  function _isLiveMedia() {
    return mediaType === MediaPlayerBase.TYPE.LIVE_VIDEO || mediaType === MediaPlayerBase.TYPE.LIVE_AUDIO
  }

  function _emitEvent(eventType, eventLabels) {
    const event = {
      type: eventType,
      currentTime: getCurrentTime(),
      seekableRange: getSeekableRange(),
      duration: getDuration(),
      url: getSource(),
      mimeType: getMimeType(),
      state: getState(),
    }

    if (eventLabels) {
      for (const key in eventLabels) {
        if (eventLabels.hasOwnProperty(key)) {
          event[key] = eventLabels[key]
        }
      }
    }

    for (let index = 0; index < eventCallbacks.length; index++) {
      eventCallbacks[index](event)
    }
  }

  return {
    addEventCallback: (thisArg, newCallback) => {
      eventCallback = (event) => newCallback.call(thisArg, event)
      eventCallbacks.push(eventCallback)
    },

    removeEventCallback: (callback) => {
      const index = eventCallbacks.indexOf(callback)

      if (index !== -1) {
        eventCallbacks.splice(index, 1)
      }
    },

    removeAllEventCallbacks: () => {
      eventCallbacks = []
    },

    initialiseMedia: initialiseMedia,
    playFrom: playFrom,
    beginPlayback: beginPlayback,
    beginPlaybackFrom: beginPlaybackFrom,
    resume: resume,
    pause: pause,
    stop: stop,
    reset: reset,
    getSeekableRange: getSeekableRange,
    getState: getState,
    getPlayerElement: getPlayerElement,
    getSource: getSource,
    getMimeType: getMimeType,
    getCurrentTime: getCurrentTime,
    getDuration: getDuration,
    toPaused: toPaused,
    toPlaying: toPlaying,
  }
}

export default SamsungStreaming2015