components_video_PlayerHostView.bs

import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/queueBackdropHelper.bs"
import "pkg:/source/utils/translate.bs"

' Routed host for video playback. See PlayerHostView.xml header.
'
' This file owns the video half of playback: the player factory
' (CreateVideoPlayerView -> mountPlayer), the end-of-playback / queue advancement state
' machine (onPlayerStateChange, host-internal), and the playback-time track-selection dialog
' handlers (onSelect*/process*). The player is a runtime child of this host, so advancement
' destroys + remounts the child.

sub init()
  m.log = new log.Logger("PlayerHostView")
  ' The player is fullscreen — keep the shared overhang hidden while this view is the
  ' router-active view (JRScene's overhang controller reads isOverhangVisible on mount).
  m.top.isOverhangVisible = false
end sub

' Router lifecycle (via JRScreen's onViewOpen bridge): mount the player for the
' current queue item. The launching code (QueueManager.playQueue -> JRScene) has
' already built the queue, so the host just reads getCurrentItem. Guard against a
' redundant call (e.g. screensaver-exit re-fires onScreenShown).
sub onScreenShown()
  if not isValid(m.view)
    mountPlayer()
  end if
end sub

sub onScreenHidden()
end sub

' Router: take remote focus. Hand it to the player child if mounted; the player also
' grabs focus itself once content loads (VideoPlayerView.onVideoContentLoaded).
function handleFocus(_data = {} as object) as boolean
  if isValid(m.view)
    m.view.setFocus(true)
  else
    m.top.setFocus(true)
  end if
  return true
end function

' Build + mount the VideoPlayerView as a child of this host. Ported from
' ViewCreator.CreateVideoPlayerView; the only behavioral change is appendChild to
' the host instead of SceneManager.pushScene.
sub mountPlayer()
  m.playbackData = {}
  m.selectedSubtitle = {}

  m.view = CreateObject("roSGNode", "VideoPlayerView")
  ' Keep video player invisible during loading to prevent black background from covering backdrop
  m.view.visible = false
  m.view.observeField("state", "onPlayerStateChange")
  m.view.observeField("selectPlaybackInfoPressed", "onSelectPlaybackInfoPressed")
  m.view.observeField("selectSubtitlePressed", "onSelectSubtitlePressed")
  m.view.observeField("selectAudioPressed", "onSelectAudioPressed")
  m.view.observeField("selectVideoSourcePressed", "onSelectVideoSourcePressed")

  mediaSourceId = m.global.queueManager.callFunc("getCurrentItem").mediaSourceId

  if not isValid(mediaSourceId) or mediaSourceId = ""
    mediaSourceId = m.global.queueManager.callFunc("getCurrentItem").id
  end if

  m.getPlaybackInfoTask = createObject("roSGNode", "GetPlaybackInfoTask")
  m.getPlaybackInfoTask.videoID = mediaSourceId
  m.getPlaybackInfoTask.observeField("data", "onPlaybackInfoLoaded")

  ' Update backdrop for current queue item (handles first item, shuffle, library play, etc.)
  updateQueueBackdrop()

  m.top.appendChild(m.view)

  ' Toast "Playing <title>" for a deep-link launch. Fired here (player mount = stable final
  ' destination, nothing churns after) rather than from ItemDetails mid-transition, which was
  ' unreliable after a heavy server switch. One-shot: clear the global after showing.
  if isValidAndNotEmpty(m.global.deepLinkOpeningTitle)
    displayToast(Substitute(translate(translationKeys.MessagePlayingContent), m.global.deepLinkOpeningTitle), "info")
    m.global.deepLinkOpeningTitle = ""
  end if
end sub

' Tear down the current player child (stop -> report stop to Jellyfin -> onDestroy -> remove).
sub destroyPlayer()
  if isValid(m.view)
    m.view.unobserveField("state")
    m.view.unobserveField("selectPlaybackInfoPressed")
    m.view.unobserveField("selectSubtitlePressed")
    m.view.unobserveField("selectAudioPressed")
    m.view.unobserveField("selectVideoSourcePressed")
    ' Stop so the player reports a stop playstate to Jellyfin (Video.onDestroy alone
    ' does not). Unobserving state first means the stop won't re-enter onPlayerStateChange.
    m.view.control = "stop"
    m.view.callFunc("onDestroy")
    m.top.removeChild(m.view)
    m.view = invalid
  end if

  if isValid(m.getPlaybackInfoTask)
    m.getPlaybackInfoTask.control = "STOP"
    m.getPlaybackInfoTask.unobserveField("data")
    m.getPlaybackInfoTask = invalid
  end if
end sub

' Host-internal advancement: remount the player for the CURRENT queue position.
' Callers (onPlayerStateChange next-item / Live TV restart; VideoPlayerView channel
' switch) set the position + backdrop first. Replaces ViewCreator's
' clearPreviousScene + playQueue (which created a new pushed scene).
sub playCurrentQueueItem()
  destroyPlayer()
  mountPlayer()
end sub

' A deep link arrived while this player is active. Stop + report the player NOW (synchronously)
' WITHOUT navigating — the caller (JRScene.replayDeepLinkReplacingPlayer) then
' goBacks, whose beforeViewClose -> onDestroy does the full teardown. destroyPlayer is idempotent
' (guards m.view), so that second teardown is a safe no-op — NOT a full onDestroy here, which
' would run twice and double-tear-down.
sub teardownForDeepLink()
  destroyPlayer()
end sub

' Leave the play route: pops this host off the router history (beforeViewClose ->
' onScreenHidden + onDestroy tear the player down) and resumes the view beneath
' (the item's details, or Home). Called on queue-exhaustion, playback error, and
' voice "stop". Replaces ViewCreator/VideoPlayerView's SceneManager.popScene.
sub exitPlayback()
  sgrouter.goBack()
end sub

' Router: this view is being destroyed (goBack / sign-out resetRouter). Tear down the
' player child. abandonApiPromises() is injected here by the auto-abandon-promises BSC
' plugin (JRScreen.onDestroy's floor doesn't chain to SG-component overrides).
sub onDestroy()
  if isValid(m.global.sceneManager)
    m.global.sceneManager.unobserveField("returnData")
  end if
  destroyPlayer()
end sub

' ===========================================================================
' End-of-playback / queue advancement state machine.
' Ported from ViewCreator.onStateChange; the pop/push has become host-internal
' destroy/remount, and the queue-exhausted pop has become exitPlayback (goBack).
' ===========================================================================
sub onPlayerStateChange()
  if LCase(m.view.state) <> "finished" then return

  ' Don't advance mid-retry; the DoVi fallback calls stop which can fire "finished".
  if isValid(m.view.isRetrying) and m.view.isRetrying then return

  sceneManager = m.global.sceneManager
  queueManager = m.global.queueManager

  ' Close any open dialogs
  if sceneManager.callFunc("isDialogOpen")
    sceneManager.callFunc("dismissDialog")
  end if

  ' Live TV channel that finished -> restart the same channel (same queue position)
  currentItem = queueManager.callFunc("getCurrentItem")
  if isValid(currentItem)
    currentItemType = queueManager.callFunc("getItemType", currentItem)
    if currentItemType = "tvchannel"
      playCurrentQueueItem()
      return
    end if
  end if

  ' More items in the queue -> advance and play the next one
  if queueManager.callFunc("getPosition") < queueManager.callFunc("getCount") - 1
    queueManager.callFunc("moveForward")
    updateQueueBackdrop()
    playCurrentQueueItem()
    return
  end if

  ' Queue exhausted -> leave the player and return to the launching view
  m.global.audioPlayer.loopMode = ""
  exitPlayback()
end sub

' handleTransport: voice transport delegate. main.bs forwards transport commands
' here (the router-active view is this host, not the inner player); forward to the
' child player's own handler.
function handleTransport(evt as object) as object
  if isValid(m.view) then return m.view.callFunc("handleTransport", evt)
  return { status: "error.no-media" }
end function

' ===========================================================================
' Playback-time track / source / info selection dialogs.
' Ported verbatim from ViewCreator (they read the child player via m.view and show
' dialogs through SceneManager, both unchanged under the host).
' ===========================================================================

' onSelectAudioPressed: Display audio selection dialog
sub onSelectAudioPressed()
  audioData = {
    data: []
  }

  m.log.debug("onSelectAudioPressed", "currentAudioIndex", m.view.audioIndex, "trackCount", m.view.fullAudioData.Count())

  for each item in m.view.fullAudioData

    audioStreamItem = {
      "index": item.index,
      "isExternal": item.isExternal,
      "track": {
        "description": formatAudioDisplayTitle(item)
      },
      "type": "audioselection"
    }

    isSelected = m.view.audioIndex = item.index
    if isSelected
      audioStreamItem.selected = true
    end if
    m.log.debug("Audio track item", "jellyfinIndex", item.index, "isSelected", isSelected, "title", formatAudioDisplayTitle(item))

    audioData.data.push(audioStreamItem)
  end for
  sceneManager = m.global.sceneManager
  sceneManager.callFunc("radioDialog", translate(translationKeys.LabelSelectAudio), audioData)
  sceneManager.observeField("returnData", "onSelectionMade")
end sub

' User requested subtitle selection popup
sub onSelectSubtitlePressed()
  subtitleData = {
    data: []
  }

  for each item in m.view.fullSubtitleData
    item.type = "subtitleselection"

    if m.view.selectedSubtitle <> SubtitleSelection.NONE
      ' Subtitle is a track within the file
      if item.index = m.view.selectedSubtitle
        item.selected = true
      end if
    else
      ' Subtitle is from an external source
      availableSubtitleTrackIndex = availSubtitleTrackIdx(item.track.TrackName)
      if availableSubtitleTrackIndex <> SubtitleSelection.NONE

        ' Convert Jellyfin subtitle track name to Roku track name
        subtitleFullTrackName = m.view.availableSubtitleTracks[availableSubtitleTrackIndex].TrackName

        if subtitleFullTrackName = m.view.subtitleTrack
          item.selected = true
        end if

      end if
    end if

    ' Put the selected item at the top of the option list
    if isValid(item.selected) and item.selected
      subtitleData.data.Unshift(item)
    else
      subtitleData.data.push(item)
    end if
  end for

  ' Manually create the None option and place at top
  subtitleData.data.Unshift({
    "Index": SubtitleSelection.NONE,
    "IsExternal": false,
    "Track": {
      "description": "None"
    },
    "Type": "subtitleselection"
  })

  sceneManager = m.global.sceneManager
  sceneManager.callFunc("radioDialog", translate(translationKeys.LabelSelectSubtitles), subtitleData)
  sceneManager.observeField("returnData", "onSelectionMade")
end sub

' onSelectVideoSourcePressed: Display video source selection dialog
sub onSelectVideoSourcePressed()
  videoSourceData = {
    data: []
  }

  if not isValid(m.view.fullVideoSourceData) then return

  hasMultipleSources = m.view.fullVideoSourceData.Count() > 1

  for each source in m.view.fullVideoSourceData
    sourceItem = {
      "Index": source.Id,
      "IsExternal": false,
      "Track": {
        "description": formatVideoSourceTitle(source, hasMultipleSources)
      },
      "Type": "videosourceselection"
    }

    if m.view.mediaSourceId = source.Id
      sourceItem.selected = true
    end if

    videoSourceData.data.push(sourceItem)
  end for

  sceneManager = m.global.sceneManager
  sceneManager.callFunc("radioDialog", translate(translationKeys.LabelSelectVideoSource), videoSourceData)
  sceneManager.observeField("returnData", "onSelectionMade")
end sub

' User has selected something from the radioDialog popup
sub onSelectionMade()
  sceneManager = m.global.sceneManager
  sceneManager.unobserveField("returnData")

  if not isValid(sceneManager.returnData) then return
  if not isValid(sceneManager.returnData.type) then return

  if LCase(sceneManager.returnData.type) = "subtitleselection"
    processSubtitleSelection()
    return
  end if

  if LCase(sceneManager.returnData.type) = "audioselection"
    processAudioSelection()
    return
  end if

  if LCase(sceneManager.returnData.type) = "videosourceselection"
    processVideoSourceSelection()
    return
  end if
end sub

' processAudioSelection: Audio track selection handler
sub processAudioSelection()
  selectedAudioTrack = m.global.sceneManager.returnData

  if isValid(selectedAudioTrack)
    if isValid(selectedAudioTrack.index)
      m.log.info("processAudioSelection", "selectedJellyfinIndex", selectedAudioTrack.index, "previousAudioIndex", m.view.audioIndex)
      m.view.audioIndex = selectedAudioTrack.index
    else
      m.log.warn("processAudioSelection: selectedAudioTrack.index is invalid", selectedAudioTrack)
    end if
  end if
end sub

' processVideoSourceSelection: Video source selection handler
' Triggers video reload with the new MediaSource ID
sub processVideoSourceSelection()
  selectedSource = m.global.sceneManager.returnData

  if isValid(selectedSource) and isValid(selectedSource.index)
    ' Only trigger reload if source actually changed
    if selectedSource.index <> m.view.mediaSourceId
      m.view.mediaSourceId = selectedSource.index
    end if
  end if
end sub

sub processSubtitleSelection()
  m.selectedSubtitle = m.global.sceneManager.returnData

  ' The selected encoded subtitle did not change.
  if m.view.selectedSubtitle <> SubtitleSelection.NONE or m.selectedSubtitle.index <> SubtitleSelection.NONE
    if m.view.selectedSubtitle = m.selectedSubtitle.index then return
  end if

  ' The playbackData is now outdated and must be refreshed
  m.playbackData = invalid

  ' Find previously selected subtitle and identify if it was encoded
  for each item in m.view.fullSubtitleData
    if item.index = m.view.selectedSubtitle
      m.view.isPreviousSubtitleEncoded = item.IsEncoded
      exit for
    end if
  end for

  if LCase(m.selectedSubtitle.track.description) = "none"
    m.view.globalCaptionMode = "Off"
    m.view.subtitleTrack = ""

    if m.view.selectedSubtitle <> SubtitleSelection.NONE
      m.view.selectedSubtitle = SubtitleSelection.NONE
    end if

    return
  end if

  if m.selectedSubtitle.IsEncoded
    ' Roku can not natively display these subtitles, so turn off the caption mode on the device
    m.view.globalCaptionMode = "Off"
  else
    ' Roku can natively display these subtitles, ensure the caption mode on the device is on
    m.view.globalCaptionMode = "On"

    ' Roku may rearrange subtitle tracks. Look up track based on name to ensure we get the correct index
    availableSubtitleTrackIndex = availSubtitleTrackIdx(m.selectedSubtitle.Track.TrackName)
    if availableSubtitleTrackIndex = SubtitleSelection.NONE then return

    m.view.subtitleTrack = m.view.availableSubtitleTracks[availableSubtitleTrackIndex].TrackName
  end if

  m.view.selectedSubtitle = m.selectedSubtitle.Index
end sub

' User requested playback info
sub onSelectPlaybackInfoPressed()
  ' Check if we already have playback info and show it in a popup
  if isValid(m.playbackData) and isValid(m.playbackData.playbackinfo)
    m.global.sceneManager.callFunc("standardDialog", translate(translationKeys.LabelPlaybackInfo), m.playbackData.playbackinfo)
    return
  end if

  ' Set cached playback info right before running (when it's actually available)
  m.getPlaybackInfoTask.cachedPlaybackInfo = m.view.cachedPlaybackInfo
  m.getPlaybackInfoTask.control = "RUN"
end sub

' The playback info task has returned data
sub onPlaybackInfoLoaded()
  m.playbackData = m.getPlaybackInfoTask.data

  ' Check if we have playback info and show it in a popup
  if isValid(m.playbackData) and isValid(m.playbackData.playbackinfo)
    m.global.sceneManager.callFunc("standardDialog", translate(translationKeys.LabelPlaybackInfo), m.playbackData.playbackinfo)
  end if
end sub

' Roku translates the info provided in subtitleTracks into availableSubtitleTracks
' Including ignoring tracks, if they are not understood, thus making indexing unpredictable.
' This function translates between our internel selected subtitle index
' and the corresponding index in availableSubtitleTracks.
function availSubtitleTrackIdx(tracknameToFind as string) as integer
  idx = 0
  for each availTrack in m.view.availableSubtitleTracks
    ' The TrackName must contain the URL we supplied originally, though
    ' Roku mangles the name a bit, so we check if the URL is a substring, rather
    ' than strict equality
    if inStr(1, availTrack.TrackName, tracknameToFind)
      return idx
    end if
    idx = idx + 1
  end for
  return SubtitleSelection.NONE
end function