components_video_VideoPlayerView.bs

' bsc-disable-file print-locations — legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/enums/MediaSegmentAction.bs"
import "pkg:/source/enums/MediaSegmentType.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/mediaSegments.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/queueBackdropHelper.bs"
import "pkg:/source/utils/session.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/trickplay.bs"
import "pkg:/source/utils/voiceTransport.bs"

' Child index for position label in Roku's built-in trickPlayBar component.
' The trickPlayBar is part of the Video node and doesn't expose child IDs,
' so we must access by index. Fallback search logic exists if Roku changes the structure.
' Defined at module level (BrighterScript const requirement) to provide immutability
' and enable reuse across multiple helper functions if needed.
const TRICKPLAYBAR_POSITION_LABEL_INDEX = 8

sub init()
  m.log = new log.Logger("VideoPlayerView")
  ' Hide the overhang on init to prevent showing 2 clocks
  m.top.getScene().findNode("overhang").visible = false
  m.forceFinished = false
  userSettings = m.global.user.settings
  m.currentItem = m.global.queueManager.callFunc("getCurrentItem")
  m.originalClosedCaptionState = invalid

  m.top.id = m.currentItem.id
  m.top.seekMode = "accurate"

  m.playbackEnum = {
    null: -10
  }

  ' Load meta data
  m.LoadMetaDataTask = CreateObject("roSGNode", "LoadVideoContentTask")
  m.LoadMetaDataTask.itemId = m.currentItem.id
  m.LoadMetaDataTask.itemType = m.currentItem.type
  m.LoadMetaDataTask.selectedAudioStreamIndex = m.currentItem.selectedAudioStreamIndex
  ' Subtitle override set pre-playback by ItemDetails' TrackDropdown. When unset we leave
  ' LoadVideoContentTask's default (-2 = auto-detect) so the server's language/Smart-mode
  ' rules still apply for callers that don't pick a track upfront.
  if isValid(m.currentItem.selectedSubtitleStreamIndex)
    m.LoadMetaDataTask.selectedSubtitleIndex = m.currentItem.selectedSubtitleStreamIndex
    m.top.SelectedSubtitle = m.currentItem.selectedSubtitleStreamIndex
  end if
  if isValidAndNotEmpty(m.currentItem.mediaSourceId)
    m.LoadMetaDataTask.mediaSourceId = m.currentItem.mediaSourceId
  end if
  m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
  m.LoadMetaDataTask.control = "RUN"

  m.chapterList = m.top.findNode("chapterList")
  m.chapterMenu = m.top.findNode("chapterMenu")
  m.chapterContent = m.top.findNode("chapterContent")
  m.osd = m.top.findNode("osd")
  m.osd.observeField("action", "onOSDAction")
  m.trickplayCarousel = m.top.findNode("trickplayCarousel")

  ' Trickplay seek position tracking - we parse Roku's trickPlayBar text to get
  ' the authoritative seek position and sync our carousel to it
  m.isTrackingSeek = false
  m.carouselHiddenForSeek = false ' Track when carousel is hidden during seek confirmation
  m.lastSentThumbnailIndex = -1 ' Dedup thumbnail index updates to prevent double-rendering at tile boundaries

  m.playbackTimer = m.top.findNode("playbackTimer")
  m.bufferCheckTimer = m.top.findNode("bufferCheckTimer")
  m.top.observeField("content", "onContentChange")
  m.top.observeField("selectedSubtitle", "onSubtitleChange")
  m.top.observeField("audioIndex", "onAudioIndexChange")

  ' Custom Caption Function
  m.top.observeField("shouldAllowCaptions", "onAllowCaptionsChange")

  m.playbackTimer.observeField("fire", "reportPlayback")
  m.bufferPercentage = 0 ' Track whether content is being loaded
  m.isPlayReported = false
  m.top.transcodeReasons = []
  m.bufferCheckTimer.duration = 30

  if userSettings.uiDesignHideClock = true
    clockNode = findNodeBySubtype(m.top, "clock")
    if isValid(clockNode[0]) then clockNode[0].parent.removeChild(clockNode[0].node)
  end if

  ' Video notifications (Next Episode + Media Segments)
  m.nextEpisodeNotification = m.top.findNode("nextEpisodeNotification")
  m.nextEpisodeNotification.notificationType = "nextEpisode"
  m.nextEpisodeNotification.observeField("action", "onNextEpisodeNotificationAction")
  m.nextupbuttonseconds = userSettings.playbackNextUpButtonSeconds

  m.segmentNotification = m.top.findNode("segmentNotification")
  m.segmentNotification.notificationType = "segment"
  m.segmentNotification.observeField("action", "onSegmentNotificationAction")
  m.segmentNotification.observeField("state", "onNotificationStateChanged")
  m.nextEpisodeNotification.observeField("state", "onNotificationStateChanged")

  ' Media segment tracking state
  m.mediaSegments = []
  m.skippedSegments = {} ' Permanent: segments the user activated "Skip" on
  m.dismissedSegmentId = "" ' Transient: dismissed this visit, cleared on segment exit
  m.activeSegmentId = ""
  m.segmentTypeLabels = {
    "Intro": translate(translationKeys.LabelIntro),
    "Outro": translate(translationKeys.LabelOutro),
    "Commercial": translate(translationKeys.LabelCommercial),
    "Preview": translate(translationKeys.LabelPreview),
    "Recap": translate(translationKeys.LabelRecap),
    "Unknown": translate(translationKeys.LabelUnknown)
  }

  ' Live TV metadata refresh: triggered when currentProgram.endDate passes,
  ' or on fallback interval when endDate is missing
  m.liveTvRefreshTask = invalid
  m.liveTvProgramEndTime = 0
  m.liveTvNextFallbackRefreshAt = 0

  ' Live TV channel navigation: queue is populated with all channels in background.
  ' Every Live TV entry point (main.bs, quickplay) clears the queue and pushes a single
  ' TvChannel, so count=1 means this is the first playback and we need to fetch the list.
  ' count>1 means a prior VideoPlayerView already populated the queue — skip the refetch
  ' and reuse it. Staleness is bounded by the app lifecycle (Roku cold-boots each session).
  m.loadChannelListTask = invalid
  m.channelListLoaded = isLiveTvPlayback() and m.global.queueManager.callFunc("getCount") > 1

  m.checkedForNextEpisode = false
  m.getNextEpisodeTask = createObject("roSGNode", "GetNextEpisodeTask")
  m.getNextEpisodeTask.observeField("nextEpisodeData", "onNextEpisodeDataLoaded")

  constants = m.global.constants

  m.top.retrievingBar.filledBarBlendColor = constants.colorSecondary

  m.top.bufferingBar.filledBarBlendColor = constants.colorSecondary

  m.top.trickPlayBar.filledBarBlendColor = constants.colorSecondary
  m.top.trickPlayBar.thumbBlendColor = constants.colorTextPrimary
  m.top.trickPlayBar.textColor = constants.colorTextPrimary
  m.top.trickPlayBar.currentTimeMarkerBlendColor = constants.colorTextPrimary

  ' Hook into trickPlayBar's internal position text to sync carousel with Roku's authoritative seek position
  ' Use defensive search to handle different Roku OS versions
  m.trickPlayBarPositionText = findTrickPlayBarPositionLabel()
  if isValid(m.trickPlayBarPositionText)
    m.trickPlayBarPositionText.observeField("text", "onTrickPlayBarTextChange")
    m.log.info("TrickPlayBar position text observer attached successfully")
  else
    m.log.warn("TrickPlayBar position text not found - carousel will only update during playback, not during scrubbing")
  end if
end sub

' findTrickPlayBarPositionLabel: Defensively finds the position text label in trickPlayBar
'
' Tries multiple strategies to find the Label that displays seek position:
' 1. Expected child index (8) - fast path for current Roku OS
' 2. Search for FIRST Label child - position text comes before remaining time
' 3. Return invalid if not found - feature gracefully degrades
'
' @return {dynamic} - Label node or invalid if not found
function findTrickPlayBarPositionLabel() as dynamic
  if not isValid(m.top.trickPlayBar) then return invalid

  ' Strategy 1: Try expected child index (current Roku structure)
  child = m.top.trickPlayBar.getChild(TRICKPLAYBAR_POSITION_LABEL_INDEX)
  if isValid(child) and child.subtype() = "Label"
    m.log.debug("Found trickPlayBar position label at expected index", TRICKPLAYBAR_POSITION_LABEL_INDEX)
    return child
  end if

  ' Strategy 2: Search for FIRST Label child (position comes before remaining time)
  childCount = m.top.trickPlayBar.getChildCount()
  m.log.warn("Position label not at expected index, searching for first Label child", "count", childCount)

  for i = 0 to childCount - 1
    child = m.top.trickPlayBar.getChild(i)
    if isValid(child) and child.subtype() = "Label"
      m.log.info("Found trickPlayBar position label via search", "index", i)
      return child
    end if
  end for

  ' Strategy 3: Not found - graceful degradation
  m.log.error("Could not find trickPlayBar position label - carousel sync during scrub will not work")
  return invalid
end function

' handleChapterSkipAction: Handles user command to skip chapters in playing video
'
sub handleChapterSkipAction(action as string)
  if not isValidAndNotEmpty(m.chapters) then return

  currentChapter = getCurrentChapterIndex()

  if action = "chapternext"
    gotoChapter = currentChapter + 1
    ' If there is no next chapter, exit
    if gotoChapter > m.chapters.count() - 1 then return

    m.top.seek = m.chapters[gotoChapter].StartPositionTicks / 10000000#
    return
  end if

  if action = "chapterback"
    gotoChapter = currentChapter - 1
    ' If there is no previous chapter, restart current chapter
    if gotoChapter < 0 then gotoChapter = 0

    m.top.seek = m.chapters[gotoChapter].StartPositionTicks / 10000000#
    return
  end if
end sub

' handleItemSkipAction: Handles user command to skip items
'
' @param {string} action - skip action to take
sub handleItemSkipAction(action as string)
  queueManager = m.global.queueManager
  queueCount = queueManager.callFunc("getCount")
  queuePosition = queueManager.callFunc("getPosition")

  ' Live TV wraps around at both ends; regular content stops at boundaries
  isTvChannel = isLiveTvPlayback() and m.channelListLoaded and queueCount > 1

  if action = "itemnext"
    targetPosition = -1
    if queuePosition < queueCount - 1
      targetPosition = queuePosition + 1
    else if isTvChannel
      targetPosition = 0
    end if

    if targetPosition >= 0
      switchToQueueItem(queueManager, targetPosition)
    end if

    return
  end if

  if action = "itemback"
    targetPosition = -1
    if queuePosition > 0
      targetPosition = queuePosition - 1
    else if isTvChannel
      targetPosition = queueCount - 1
    end if

    if targetPosition >= 0
      switchToQueueItem(queueManager, targetPosition)
    end if

    return
  end if
end sub

' m.isSwitchingChannel prevents the old view's onState("stopped") from hiding the spinner
' before the new VideoPlayerView finishes loading.
sub switchToQueueItem(queueManager as object, targetPosition as integer)
  nextItem = queueManager.callFunc("getItemByIndex", targetPosition)
  showChannelSwitchSpinner(nextItem)

  m.isSwitchingChannel = true
  m.top.control = "stop"
  m.global.sceneManager.callFunc("clearPreviousScene")
  queueManager.callFunc("setPosition", targetPosition)
  updateQueueBackdrop()
  queueManager.callFunc("playQueue")
end sub

sub showChannelSwitchSpinner(nextItem as object)
  if not isValid(nextItem) or LCase(nextItem.type) <> "tvchannel" then return

  channelLabel as string = ""
  if isValidAndNotEmpty(nextItem.channelNumber)
    channelLabel = `${translate(translationKeys.LabelCh)} ${nextItem.channelNumber}`
  end if
  channelTitle as string = ""
  if isValidAndNotEmpty(nextItem.title)
    channelTitle = `${nextItem.title}`
  end if
  loadingText as string = ""
  if channelLabel <> "" and channelTitle <> ""
    loadingText = `${channelLabel} · ${channelTitle}`
  else if channelLabel <> ""
    loadingText = channelLabel
  else
    loadingText = channelTitle
  end if

  startLoadingSpinner(false, loadingText)
end sub

' handleHideAction: Handles action to hide OSD menu
'
' @param {boolean} resume - controls whether or not to resume video playback when sub is called
'
sub handleHideAction(resume as boolean)
  m.osd.visible = false
  m.chapterList.visible = false
  m.osd.shouldShowChapterList = false
  m.chapterList.setFocus(false)
  m.osd.hasFocus = false
  m.osd.setFocus(false)
  m.top.setFocus(true)
  if resume
    m.top.control = "resume"
  end if

  ' Re-check segments after OSD closes — re-shows paused notification if still in segment
  checkMediaSegments()
end sub

' handleChapterListAction: Handles action to show chapter list
'
sub handleChapterListAction()
  m.chapterList.visible = m.osd.shouldShowChapterList

  if not m.chapterList.visible then return

  m.chapterMenu.jumpToItem = getCurrentChapterIndex()

  m.osd.hasFocus = false
  m.osd.setFocus(false)
  m.chapterMenu.setFocus(true)
end sub

' getCurrentChapterIndex: Finds current chapter index
'
' @return {integer} indicating index of current chapter within chapter data or 0 if chapter lookup fails
'
function getCurrentChapterIndex() as integer
  if not isValidAndNotEmpty(m.chapters) then return 0

  ' Give a 15 second buffer to compensate for user expectation and roku video position inaccuracy
  ' Web client uses 10 seconds, but this wasn't enough for Roku in testing
  currentPosition = m.top.position + 15
  currentChapter = 0

  for i = m.chapters.count() - 1 to 0 step -1
    if currentPosition >= (m.chapters[i].StartPositionTicks / 10000000#)
      currentChapter = i
      exit for
    end if
  end for

  return currentChapter
end function

' handleVideoPlayPauseAction: Handles action to either play or pause the video content
'
sub handleVideoPlayPauseAction()
  ' If video is paused, resume it
  if m.top.state = "paused"
    handleHideAction(true)
    return
  end if

  ' Pause video
  m.top.control = "pause"
end sub

' handleShowSubtitleMenuAction: Handles action to show subtitle selection menu
'
sub handleShowSubtitleMenuAction()
  m.top.selectSubtitlePressed = true
end sub

' handleShowAudioMenuAction: Handles action to show audio selection menu
'
sub handleShowAudioMenuAction()
  m.top.selectAudioPressed = true
end sub

' handleShowVideoSourceMenuAction: Handles action to show video source selection menu
'
sub handleShowVideoSourceMenuAction()
  m.top.selectVideoSourcePressed = true
end sub

' handleShowVideoInfoPopupAction: Handles action to show video info popup
'
sub handleShowVideoInfoPopupAction()
  m.top.selectPlaybackInfoPressed = true
end sub

sub handleGoToLiveAction()
  m.top.seek = LIVE_EDGE_SEEK
  if m.top.state = "paused"
    m.top.control = "resume"
  end if
  handleHideAction(false)
end sub

' onOSDAction: Process action events from OSD to their respective handlers
'
sub onOSDAction()
  action = LCase(m.osd.action)

  if action = "hide"
    handleHideAction(false)
    return
  end if

  if action = "play"
    handleHideAction(true)
    return
  end if

  if action = "chapterback" or action = "chapternext"
    handleChapterSkipAction(action)
    return
  end if

  if action = "chapterlist"
    handleChapterListAction()
    return
  end if

  if action = "videoplaypause"
    handleVideoPlayPauseAction()
    return
  end if

  if action = "showsubtitlemenu"
    handleShowSubtitleMenuAction()
    return
  end if

  if action = "showaudiomenu"
    handleShowAudioMenuAction()
    return
  end if

  if action = "showvideosourcemenu"
    handleShowVideoSourceMenuAction()
    return
  end if

  if action = "showvideoinfopopup"
    handleShowVideoInfoPopupAction()
    return
  end if

  if action = "gotolive"
    handleGoToLiveAction()
    return
  end if

  if action = "itemback" or action = "itemnext"
    handleItemSkipAction(action)
    return
  end if
end sub

' Determines if custom subtitles should be used for the current selection
' Custom subtitles should only be used for external subtitles when the user has enabled the setting
function shouldUseCustomSubtitlesForCurrentSelection() as boolean
  ' First check if the user has enabled custom subtitles at all
  if not m.global.user.settings.playbackSubsCustom
    m.log.debug("Custom subtitles disabled by user setting")
    return false
  end if

  ' If no subtitle is currently selected, no need for custom subtitles
  if not isValid(m.top.selectedSubtitle) or m.top.selectedSubtitle = -1
    m.log.debug("No subtitle selected", m.top.selectedSubtitle)
    return false
  end if

  ' Find the selected subtitle in the fullSubtitleData
  selectedSubtitle = invalid
  if isValid(m.top.fullSubtitleData)
    for each subtitle in m.top.fullSubtitleData
      if subtitle.Index = m.top.selectedSubtitle
        selectedSubtitle = subtitle
        exit for
      end if
    end for
  end if

  ' If we found the selected subtitle and it's external, use custom subtitles
  if isValid(selectedSubtitle) and isValid(selectedSubtitle.IsExternal)
    m.log.debug("Subtitle found", selectedSubtitle.IsExternal, selectedSubtitle.Index)
    return selectedSubtitle.IsExternal
  end if

  m.log.debug("Selected subtitle not found in fullSubtitleData or invalid IsExternal flag")
  ' Default to false (use native Roku subtitles)
  return false
end function

' Only setup caption items if captions are allowed
sub onAllowCaptionsChange()
  if not m.top.shouldAllowCaptions then return

  m.captionGroup = m.top.findNode("captionGroup")
  m.captionGroup.createchildren(9, "LayoutGroup")
  m.captionTask = createObject("roSGNode", "captionTask")
  m.captionTask.observeField("currentCaption", "updateCaption")
  m.captionTask.observeField("useThis", "checkCaptionMode")
  m.top.observeField("subtitleTrack", "loadCaption")
  m.top.observeField("globalCaptionMode", "toggleCaption")

end sub

' Set caption url to server subtitle track and run the fetch task
sub loadCaption()
  m.log.debug("loadCaption() called", m.top.subtitleTrack, m.top.suppressCaptions)
  if m.top.suppressCaptions and isValid(m.top.subtitleTrack) and m.top.subtitleTrack <> ""
    m.log.debug("Setting captionTask.url", m.top.subtitleTrack)
    m.captionTask.control = "STOP"
    m.captionTask.url = m.top.subtitleTrack
    m.captionTask.control = "RUN"
  end if
end sub

' Toggles visibility of custom subtitles and sets captionTask's player state
sub toggleCaption()
  m.captionTask.playerState = m.top.state + m.top.globalCaptionMode
  if LCase(m.top.globalCaptionMode) = "on"
    m.captionTask.playerState = m.top.state + m.top.globalCaptionMode + "w"
    m.captionGroup.visible = true
  else
    m.captionGroup.visible = false
  end if
end sub

' Removes old subtitle lines and adds new subtitle lines
sub updateCaption()
  m.captionGroup.removeChildrenIndex(m.captionGroup.getChildCount(), 0)
  m.captionGroup.appendChildren(m.captionTask.currentCaption)
end sub

' Event handler for when selectedSubtitle changes
sub onSubtitleChange()
  shouldSwitchWithoutRefresh = true

  if m.top.SelectedSubtitle <> SubtitleSelection.NONE
    ' If the global caption mode is off, then Roku can't display the subtitles natively and needs a video stop/start
    if LCase(m.top.globalCaptionMode) <> "on" then shouldSwitchWithoutRefresh = false
  end if

  ' If previous sustitle was encoded, then we need to a video stop/start to change subtitle content
  if m.top.isPreviousSubtitleEncoded then shouldSwitchWithoutRefresh = false

  ' Update custom subtitle behavior based on new selection
  if m.top.shouldAllowCaptions
    shouldUseCustomSubtitles = shouldUseCustomSubtitlesForCurrentSelection()

    m.log.debug("Subtitle changed", shouldUseCustomSubtitles, m.top.suppressCaptions)

    if shouldUseCustomSubtitles <> m.top.suppressCaptions
      ' Custom subtitle mode has changed, update it
      m.log.debug("Changing custom subtitle mode to", shouldUseCustomSubtitles)
      m.top.suppressCaptions = shouldUseCustomSubtitles
      if m.top.suppressCaptions
        ' Turn on globalCaptionMode so toggleCaption() will show the caption group
        m.top.globalCaptionMode = "On"
        ' Manually load the caption since loadCaption() observer may have already fired
        ' with the wrong suppressCaptions value
        if isValid(m.captionTask) and isValid(m.top.subtitleTrack) and m.top.subtitleTrack <> ""
          m.captionTask.control = "STOP"
          m.captionTask.url = m.top.subtitleTrack
          m.captionTask.control = "RUN"
        end if
        toggleCaption()
      else
        ' Explicitly clean up custom subtitle system when switching away from custom subs
        m.captionGroup.visible = false
        if isValid(m.captionTask)
          m.captionTask.control = "STOP"
          ' captionTask no longer observes url, so setting url="" is a no-op.
          ' Publish empty captionData through the bridge so onCaptionDataReceived
          ' stops the caption timer and clears currentCaption on the render thread.
          m.captionTask.captionData = { entries: [] }
        end if
      end if
    end if
  end if

  if shouldSwitchWithoutRefresh then return

  m.log.info("Subtitle change requires stream reload", "subtitleIndex", m.top.SelectedSubtitle, "position", m.top.position)

  ' Save the current video position
  m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)

  m.top.control = "stop"

  m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
  m.LoadMetaDataTask.selectedAudioStreamIndex = m.top.audioIndex
  m.LoadMetaDataTask.itemId = m.currentItem.id
  m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
  m.LoadMetaDataTask.control = "RUN"
end sub

' Event handler for when audioIndex changes.
' For direct play: switches audio track directly using Roku's availableAudioTracks
' (no stop/reload needed since all tracks are in the container).
' For transcoded — or when the newly-selected stream exceeds the device's audio
' capability (e.g. switching from stereo back to an 8ch track on a stereo Roku) —
' must reload so the server transcodes the new selection.
sub onAudioIndexChange()
  m.log.info("Audio index changed by user", "newIndex", m.top.audioIndex, "isTranscoded", m.top.isTranscoded, "position", m.top.position, "currentAudioTrack", m.top.audioTrack)

  ' Direct play: all audio tracks are in the container — just switch the Roku track,
  ' but only if the newly-selected stream is actually decodable by the device.
  ' Roku's native audioTrack switch can't make an undecodable track playable, so
  ' picking e.g. an 8ch track on a stereo device must go through the reload path.
  if not m.top.isTranscoded and isValid(m.top.availableAudioTracks) and isSelectedAudioStreamDirectPlayable(m.top.audioIndex, m.top.fullAudioData)
    newTrack = getRokuAudioTrackPosition(m.top.audioIndex, m.top.fullAudioData, m.top.availableAudioTracks)
    m.log.info("Direct play audio switch", "jellyfinIndex", m.top.audioIndex, "rokuTrack", newTrack)
    m.top.audioTrack = newTrack
    reportPlayback()
    return
  end if

  ' Transcoded, availableAudioTracks not available, or stream not direct-playable: must reload
  m.log.info("Audio change requires reload", "isTranscoded", m.top.isTranscoded)

  ' Save the current video position
  m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)

  m.top.control = "stop"

  m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
  m.LoadMetaDataTask.selectedAudioStreamIndex = m.top.audioIndex
  m.LoadMetaDataTask.itemId = m.currentItem.id
  m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
  m.LoadMetaDataTask.control = "RUN"
end sub

' onVideoSourceChange: Handles video source switching from OSD
' Saves position, stops playback, re-selects audio for the new source's streams,
' and reloads video content with the new MediaSource ID.
sub onVideoSourceChange()
  newSourceId = m.top.mediaSourceId
  if not isValidAndNotEmpty(newSourceId) then return

  ' Save the current video position
  m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)

  m.top.control = "stop"

  ' Re-determine best audio stream for the new source's MediaStreams
  audioStreamIdx = 0
  if isValid(m.top.fullVideoSourceData)
    for each source in m.top.fullVideoSourceData
      if source.Id = newSourceId and isValid(source.MediaStreams)
        localUser = m.global.user
        playDefault = resolvePlayDefaultAudioTrack(localUser.settings, localUser.config)
        audioStreamIdx = findBestAudioStreamIndex(source.MediaStreams, playDefault, resolveAudioLanguagePreference(localUser.settings, localUser.config))
        exit for
      end if
    end for
  end if

  m.LoadMetaDataTask.selectedSubtitleIndex = -2 ' Reset to auto-detect for new source
  m.LoadMetaDataTask.selectedAudioStreamIndex = audioStreamIdx
  m.LoadMetaDataTask.itemId = m.currentItem.id
  m.LoadMetaDataTask.mediaSourceId = newSourceId
  m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
  m.LoadMetaDataTask.control = "RUN"
end sub

sub onPlaybackErrorDialogClosed(msg)
  sourceNode = msg.getRoSGNode()
  sourceNode.unobserveField("buttonSelected")
  sourceNode.unobserveField("wasClosed")

  m.global.sceneManager.callFunc("popScene")
end sub

sub onPlaybackErrorButtonSelected(msg)
  sourceNode = msg.getRoSGNode()
  sourceNode.close = true
end sub

sub showPlaybackErrorDialog(errorMessage = "" as string)
  dialog = createObject("roSGNode", "Dialog")
  dialog.title = translate(translationKeys.ErrorDuringPlayback)
  dialog.buttons = [translate(translationKeys.ButtonOk)]
  dialog.message = errorMessage
  dialog.observeField("buttonSelected", "onPlaybackErrorButtonSelected")
  dialog.observeField("wasClosed", "onPlaybackErrorDialogClosed")

  m.top.getScene().dialog = dialog
end sub

sub onVideoContentLoaded()
  m.LoadMetaDataTask.unobserveField("content")
  m.LoadMetaDataTask.control = "STOP"

  m.top.isRetrying = false

  videoContent = m.LoadMetaDataTask.content
  m.LoadMetaDataTask.content = []

  ' If we have nothing to play, show an error dialog and return.
  ' Prefer the task's specific errorMsg (e.g. NoCompatibleStream) over the generic fallback
  ' so the user sees an actionable message rather than a generic one.
  if not isValid(videoContent) or not isValid(videoContent[0])
    stopLoadingSpinner()
    taskErrorMsg = m.LoadMetaDataTask.errorMsg
    if isValidAndNotEmpty(taskErrorMsg)
      showPlaybackErrorDialog(taskErrorMsg)
    else
      showPlaybackErrorDialog(translate(translationKeys.ErrorThereWasAnErrorRetrievingThe))
    end if
    return
  end if


  m.top.observeField("state", "onState")

  m.top.content = videoContent[0].content
  m.top.PlaySessionId = videoContent[0].PlaySessionId
  m.top.videoId = videoContent[0].id
  m.top.container = videoContent[0].container
  m.top.isTranscoded = isValid(videoContent[0].isTranscoded) and videoContent[0].isTranscoded
  m.top.fullSubtitleData = videoContent[0].fullSubtitleData
  m.top.fullAudioData = videoContent[0].fullAudioData
  m.top.unobserveField("audioIndex")
  m.top.audioIndex = videoContent[0].audioIndex
  m.top.observeField("audioIndex", "onAudioIndexChange")
  m.top.transcodeParams = videoContent[0].transcodeparams
  m.chapters = videoContent[0].chapters
  m.top.showID = videoContent[0].showID
  m.top.cachedPlaybackInfo = videoContent[0].playbackInfo
  m.top.isDoviDirectPlayFallbackAvailable = isValid(videoContent[0].isDoviDirectPlayFallbackAvailable) and videoContent[0].isDoviDirectPlayFallbackAvailable
  m.top.isTranscodeAvailable = isValid(videoContent[0].isTranscodeAvailable) and videoContent[0].isTranscodeAvailable

  ' Unobserve mediaSourceId before setting to prevent observer loop
  m.top.unobserveField("mediaSourceId")
  m.top.mediaSourceId = videoContent[0].mediaSourceId
  m.top.observeField("mediaSourceId", "onVideoSourceChange")

  ' Reset retry flags so subsequent reloads (audio/subtitle changes) use fresh evaluation.
  m.LoadMetaDataTask.shouldBypassDoviPreservation = false
  m.LoadMetaDataTask.shouldForceTranscoding = false

  ' Pass JellyfinBaseItem node to OSD for typed field access
  if isValid(videoContent[0].meta)
    ' Populate video source data for OSD source switching.
    ' Only update if the new metadata has more sources than what we already have —
    ' a source-switch reload fetches metadata for a single MediaSource ID,
    ' which loses the parent item's full source list.
    if isValid(videoContent[0].meta.mediaSourcesData) and isValidAndNotEmpty(videoContent[0].meta.mediaSourcesData.mediaSources)
      newSources = videoContent[0].meta.mediaSourcesData.mediaSources
      existingCount = 0
      if isValid(m.top.fullVideoSourceData) then existingCount = m.top.fullVideoSourceData.Count()
      if newSources.Count() >= existingCount
        m.top.fullVideoSourceData = newSources
      end if
    end if
    ' Pre-set video source count on OSD BEFORE itemData triggers onItemDataChanged().
    ' A source-switch reload returns metadata for a single MediaSource, so
    ' hasMultipleVideoSources would be false. Override with the preserved count
    ' so setButtonStates() keeps the video source button.
    if isValid(m.top.fullVideoSourceData) and m.top.fullVideoSourceData.Count() > 1
      m.osd.numVideoSources = m.top.fullVideoSourceData.Count()
      ' Set the active source's name tag for the OSD title (e.g., "[B&W]", "[Colorized]")
      currentSourceId = videoContent[0].mediaSourceId
      m.osd.videoSourceTag = ""
      for each source in m.top.fullVideoSourceData
        if source.Id = currentSourceId and isValidAndNotEmpty(source.Name)
          m.osd.videoSourceTag = source.Name
          exit for
        end if
      end for
    else
      m.osd.videoSourceTag = ""
    end if
    m.osd.itemData = videoContent[0].meta

    ' Extract media segments from metadata node for segment detection during playback
    if isValid(videoContent[0].meta.mediaSegments)
      m.mediaSegments = videoContent[0].meta.mediaSegments
    else
      m.mediaSegments = []
    end if
    m.skippedSegments = {}
    m.dismissedSegmentId = ""
    m.activeSegmentId = ""

    if videoContent[0].meta.type = "TvChannel"
      m.liveTvProgramEndTime = parseProgramEndTimeEpoch(videoContent[0].meta)
      m.liveTvNextFallbackRefreshAt = 0
    else
      m.liveTvProgramEndTime = 0
    end if
  end if

  ' Attempt to add logo to OSD
  if isValidAndNotEmpty(videoContent[0].logoImage)
    m.osd.videoLogo = videoContent[0].logoImage
  end if

  populateChapterMenu()

  ' Clean up old carousel cache before loading new video's trickplay data
  ' This handles all video change paths: OSD nav, next episode, queue changes, etc.
  if isValid(m.trickplayCarousel)
    m.trickplayCarousel.callFunc("reset")
  end if

  ' Configure trickplay carousel if metadata is available
  if isValidAndNotEmpty(videoContent[0].content.trickplayMetadata)
    m.trickplayCarousel.trickplayConfig = videoContent[0].content.trickplayMetadata
    m.trickplayCarousel.videoDuration = m.top.duration
  else
    m.trickplayCarousel.isVisible = false
  end if

  ' Allow custom captions for all videos including intro videos
  m.top.shouldAllowCaptions = true

  ' Allow default subtitles
  m.top.unobserveField("selectedSubtitle")

  ' Set subtitleTrack property if subs are natively supported by Roku
  selectedSubtitle = invalid
  for each subtitle in m.top.fullSubtitleData
    if subtitle.Index = videoContent[0].selectedSubtitle
      selectedSubtitle = subtitle
      exit for
    end if
  end for

  m.top.selectedSubtitle = videoContent[0].selectedSubtitle

  ' Update custom subtitle behavior based on the selected subtitle
  ' IMPORTANT: This must happen BEFORE setting subtitleTrack to ensure suppressCaptions
  ' is set correctly when the loadCaption() observer fires
  if m.top.shouldAllowCaptions
    shouldUseCustomSubtitles = shouldUseCustomSubtitlesForCurrentSelection()
    m.top.suppressCaptions = shouldUseCustomSubtitles
  end if

  if isValid(selectedSubtitle)
    availableSubtitleTrackIndex = availSubtitleTrackIdx(selectedSubtitle.Track.TrackName)
    if availableSubtitleTrackIndex <> -1
      if not selectedSubtitle.IsEncoded
        if selectedSubtitle.IsForced
          ' If IsForced, make sure to remember the Roku global setting so we
          ' can set it back when the video is done playing.
          m.originalClosedCaptionState = m.top.globalCaptionMode
        end if
        m.top.globalCaptionMode = "On"
        m.top.subtitleTrack = m.top.availableSubtitleTracks[availableSubtitleTrackIndex].TrackName
      end if
    end if
  else
    ' No subtitle selected - turn off Roku's native caption system
    ' Only turn off if we're not restoring to a saved state from forced subs
    if not isValid(m.originalClosedCaptionState)
      m.top.globalCaptionMode = "Off"
    end if
  end if

  m.top.observeField("selectedSubtitle", "onSubtitleChange")

  if isValid(m.top.audioIndex)
    if isValid(videoContent[0].isTranscoded) and videoContent[0].isTranscoded
      ' Transcoded streams contain only the server-selected audio track.
      ' Reset audioTrack so Roku auto-selects the single available track
      ' (avoids stale values from prior direct play selecting a non-existent track).
      m.top.audioTrack = ""
      m.log.debug("Transcoded stream: reset audioTrack for auto-select", "jellyfinIndex", m.top.audioIndex)
    else
      ' Direct play: map Jellyfin index to Roku Track identifier.
      ' Pass availableAudioTracks for language-based matching when available (e.g., audio change reload).
      m.top.audioTrack = getRokuAudioTrackPosition(m.top.audioIndex, m.top.fullAudioData, m.top.availableAudioTracks)
      m.log.debug("Direct play: mapped audioTrack", "jellyfinIndex", m.top.audioIndex, "rokuTrack", m.top.audioTrack)
    end if
  end if

  ' Make video player visible now that content is loaded and ready to play
  m.top.visible = true

  stopLoadingSpinner()
  m.top.setFocus(true)
  m.top.control = "play"
end sub

' populateChapterMenu: ' Parse chapter data from API and appeand to chapter list menu
'
sub populateChapterMenu()
  ' Clear any existing chapter list data
  m.chapterContent.clear()

  if not isValidAndNotEmpty(m.chapters)
    chapterItem = CreateObject("roSGNode", "ContentNode")
    chapterItem.title = translate(translationKeys.LabelNoChapterDataFound)
    chapterItem.playstart = m.playbackEnum.null
    m.chapterContent.appendChild(chapterItem)
    return
  end if

  for each chapter in m.chapters
    chapterItem = CreateObject("roSGNode", "ContentNode")
    chapterItem.title = chapter.Name
    chapterItem.playstart = chapter.StartPositionTicks / 10000000#
    m.chapterContent.appendChild(chapterItem)
  end for
end sub

' Event handler for when video content field changes
sub onContentChange()
  if not isValid(m.top.content) then return

  m.top.observeField("position", "onPositionChanged")
end sub

sub onNextEpisodeDataLoaded()
  m.checkedForNextEpisode = true

  m.top.observeField("position", "onPositionChanged")

  ' If there is no next episode, disable next episode button
  if m.getNextEpisodeTask.nextEpisodeData.Items.count() <> 2
    m.nextupbuttonseconds = 0
  end if
end sub

' Check if a VideoNotification is visually present (including during fade-out animation)
function isNotificationVisible(notification as object) as boolean
  return notification.state = "showing" or notification.state = "dismissing"
end function

' Show the Next Episode notification with countdown text
sub showNextEpisodeButton()
  if m.osd.visible then return
  if m.top.content.contenttype <> 4 then return ' only display when content is type "Episode"
  if m.nextupbuttonseconds = 0 then return ' is the button disabled?
  if m.nextEpisodeNotification.state = "showing" then return

  ' Don't show Next Episode while segment notification is active (including fade-out)
  if isNotificationVisible(m.segmentNotification) then return

  ' Set text before showing
  updateNextEpisodeCount()

  m.nextEpisodeNotification.visible = true
  m.nextEpisodeNotification.callFunc("show")
end sub

' Update Next Episode countdown text
sub updateNextEpisodeCount()
  nextEpisodeCountdown = Int(m.top.duration - m.top.position)
  if nextEpisodeCountdown < 0
    nextEpisodeCountdown = 0
  end if
  m.nextEpisodeNotification.text = translate(translationKeys.LabelNextEpisode) + " " + nextEpisodeCountdown.toStr().trim()
end sub

' Hide the Next Episode notification
sub hideNextEpisodeButton()
  if not isValid(m.nextEpisodeNotification) then return
  m.nextEpisodeNotification.callFunc("dismiss")
end sub

' Handler for Next Episode notification action
sub onNextEpisodeNotificationAction()
  action = m.nextEpisodeNotification.action
  if action = "activated"
    forceFinishPlayback()
    return
  else if action = "dismissed"
    ' Only restore video focus if OSD didn't take focus during the dismiss animation
    if not m.osd.visible
      m.top.setFocus(true)
    end if
  end if
end sub

' Checks if we need to display the Next Episode button
sub checkTimeToDisplayNextEpisode()
  if m.top.content.contenttype <> 4 then return ' only display when content is type "Episode"
  if m.nextupbuttonseconds = 0 then return ' is the button disabled?

  ' Don't show Next Episode button if trickPlayBar is visible
  if m.top.trickPlayBar.visible then return

  ' Don't show while segment notification is active (including fade-out) — it takes priority
  if isNotificationVisible(m.segmentNotification) then return

  if isValid(m.top.duration) and isValid(m.top.position)
    nextEpisodeCountdown = Int(m.top.duration - m.top.position)

    if nextEpisodeCountdown < 0 and isNotificationVisible(m.nextEpisodeNotification)
      hideNextEpisodeButton()
      return
    else if nextEpisodeCountdown > 1 and int(m.top.position) >= (m.top.duration - m.nextupbuttonseconds - 1)
      if m.nextEpisodeNotification.state <> "showing"
        showNextEpisodeButton()
      else
        ' Notification already showing — just update the countdown text
        updateNextEpisodeCount()
      end if
      return
    end if
  end if

  ' If notification is visible but shouldn't be, hide it
  if isNotificationVisible(m.nextEpisodeNotification)
    hideNextEpisodeButton()
  end if
end sub

' Forces playback to finished state for proper watched-status and auto-play.
' Triggers synchronous teardown (onState -> ViewCreator -> popScene -> onDestroy),
' invalidating all m. references. Callers MUST return immediately after calling.
sub forceFinishPlayback()
  ' Explicitly mark as watched via dedicated API endpoint.
  ' Uses the API pool (not SideEffectTask) so it can't be overwritten
  ' by the playstate report that fires during teardown.
  if m.top.id <> ""
    req = GetApi().BuildMarkPlayedRequest(m.top.id)
    if isValid(req)
      resultNode = submitApiRequest(req, "forceFinishMarkPlayed")
      if isValid(resultNode)
        m.log.info("Force-finishing playback — marking as watched", m.top.id)
      else
        m.log.warn("Force-finishing playback — API pool not ready, mark-played dropped", m.top.id)
      end if
    else
      m.log.warn("Force-finishing playback — could not build mark-played request", m.top.id)
    end if
  else
    m.log.info("Force-finishing playback — no item ID, skipping mark-played")
  end if

  ' Tell reportPlayback to report position at end of video so the server
  ' sees a natural completion — matching the mark-played request.
  m.forceFinished = true

  m.top.control = "stop"
  m.top.state = "finished"
end sub

' Check media segments and trigger appropriate actions based on current playback position
sub checkMediaSegments()
  if m.mediaSegments.Count() = 0 then return
  if m.osd.visible then return
  if m.top.trickPlayBar.visible then return

  userSession = m.global.user
  segment = findActiveSegment(m.mediaSegments, m.top.position)

  if isValid(segment)
    segmentId = segment.Id ?? ""
    if segmentId = "" then return

    segmentType = segment.Type ?? ""
    action = resolveSegmentAction(segmentType, userSession.settings, userSession.config)
    m.log.verbose("Active segment found", segmentType, segmentId, "action:", action)

    if action = MediaSegmentAction.SKIP
      ' Auto-skip only fires once per segment
      if m.skippedSegments.DoesExist(segmentId) then return

      ' Auto-skip: seek to end of segment immediately
      seekPosition = segmentTicksToSeconds(segment.EndTicks)

      ' Outro ending near video end: force finish for watched-status and auto-play
      if segmentType = MediaSegmentType.OUTRO and m.top.duration > 0 and seekPosition >= m.top.duration - 5
        forceFinishPlayback()
        return
      end if

      ' Dismiss notification from a prior segment (e.g. back-to-back askToSkip → skip)
      if isNotificationVisible(m.segmentNotification) or m.segmentNotification.state = "paused"
        m.segmentNotification.callFunc("dismiss")
      end if

      m.log.info("Auto-skipping segment", segmentType, "to", seekPosition)
      m.top.seek = seekPosition
      m.skippedSegments[segmentId] = true
      m.activeSegmentId = ""
    else if action = MediaSegmentAction.ASK_TO_SKIP
      ' Dismissed this visit — don't re-show until user leaves and re-enters the segment
      if segmentId = m.dismissedSegmentId then return

      ' Re-show notification if it was paused (hidden for OSD)
      if m.segmentNotification.state = "paused" and m.activeSegmentId = segmentId
        m.log.verbose("Re-showing paused segment notification", segmentId)
        m.segmentNotification.visible = true
        m.segmentNotification.callFunc("show")
      else if m.activeSegmentId <> segmentId
        ' Show notification for a new segment
        m.activeSegmentId = segmentId

        ' Suppress Next Episode notification while segment notification is active
        if isNotificationVisible(m.nextEpisodeNotification)
          hideNextEpisodeButton()
        end if

        ' Commercial and Recap segments stay visible for the entire segment.
        ' Other types auto-dismiss after 10 seconds.
        if segmentType = MediaSegmentType.COMMERCIAL or segmentType = MediaSegmentType.RECAP
          m.segmentNotification.autoDismissSeconds = 0
        else
          m.segmentNotification.autoDismissSeconds = 10
        end if

        m.log.info("Showing skip notification", segmentType, segmentId)
        segmentLabel = m.segmentTypeLabels[segmentType]
        if not isValidAndNotEmpty(segmentLabel) then segmentLabel = segmentType
        m.segmentNotification.text = translate(translationKeys.ButtonSkip) + " " + segmentLabel
        m.segmentNotification.visible = true
        m.segmentNotification.callFunc("show")
      end if
    else
      ' MediaSegmentAction.NONE — dismiss stale notification from a prior segment
      if isNotificationVisible(m.segmentNotification) or m.segmentNotification.state = "paused"
        m.segmentNotification.callFunc("dismiss")
      end if
      m.activeSegmentId = ""
    end if
  else
    ' Not inside any segment — dismiss segment notification if visible or paused
    if isNotificationVisible(m.segmentNotification) or m.segmentNotification.state = "paused"
      m.log.verbose("Left segment, dismissing notification")
      m.segmentNotification.callFunc("dismiss")
    end if
    m.activeSegmentId = ""
    m.dismissedSegmentId = ""
  end if
end sub

' Handler for segment notification action
sub onSegmentNotificationAction()
  action = m.segmentNotification.action
  if action = "activated"
    if m.activeSegmentId = ""
      m.log.warn("Segment skip activated with no active segment")
      return
    end if

    m.log.info("Segment skip activated by user", m.activeSegmentId)
    segment = findSegmentById(m.mediaSegments, m.activeSegmentId)
    if isValid(segment)
      seekPosition = segmentTicksToSeconds(segment.EndTicks)
      segmentType = segment.Type ?? ""

      ' Outro ending near video end: force finish for watched-status and auto-play
      if segmentType = MediaSegmentType.OUTRO and m.top.duration > 0 and seekPosition >= m.top.duration - 5
        forceFinishPlayback()
        return
      end if

      m.top.seek = seekPosition
      m.skippedSegments[m.activeSegmentId] = true
    else
      m.log.warn("Active segment not found for skip", m.activeSegmentId)
    end if
    m.activeSegmentId = ""
    m.top.setFocus(true)
  else if action = "dismissed"
    m.log.verbose("Segment notification dismissed", m.activeSegmentId)
    ' Mark as dismissed for this visit — allows re-show if user leaves and re-enters the segment
    if m.activeSegmentId <> ""
      m.dismissedSegmentId = m.activeSegmentId
      m.activeSegmentId = ""
    end if
    ' Only restore video focus if OSD didn't take focus during the dismiss animation
    if not m.osd.visible
      m.top.setFocus(true)
    end if
  end if
end sub

' Safety net: ensure focus isn't orphaned after notification dismissal animation completes
sub onNotificationStateChanged()
  if m.segmentNotification.state = "hidden" or m.nextEpisodeNotification.state = "hidden"
    if not m.osd.visible and not m.top.hasFocus()
      m.top.setFocus(true)
    end if
  end if
end sub

' When Video Player state changes
sub onPositionChanged()
  ' Fall back to content.length (which Jellyfin populates from mediaSource.RunTimeTicks)
  ' when Roku's own duration probe returns 0. This happens with live-transcoded progressive
  ' MKV: ffmpeg can't know the final duration while encoding, so it writes 0 into the
  ' MKV SegmentInfo header, and Roku's probe inherits that. Without this fallback,
  ' progressPercentage stays pinned at 0, and since the OSD only observes progressPercentage,
  ' the progress bar never advances and timestamps never refresh.
  effectiveDuration = m.top.duration
  if effectiveDuration <= 0 and isValid(m.top.content) and m.top.content.length > 0
    effectiveDuration = m.top.content.length
  end if

  if effectiveDuration <= 0
    m.osd.progressPercentage = 0
  else
    m.osd.progressPercentage = m.top.position / effectiveDuration
  end if
  m.osd.positionTime = m.top.position
  m.osd.remainingPositionTime = effectiveDuration - m.top.position

  if isValid(m.captionTask)
    m.captionTask.currentPos = Int(m.top.position * 1000)
  end if

  ' Update trickplay carousel's playback position for proactive tile caching
  ' This runs even when carousel is not visible to keep cache warm
  if isValid(m.trickplayCarousel)
    m.trickplayCarousel.playbackPosition = m.top.position
  end if

  ' Check if dialog is open
  m.dialog = m.top.getScene().findNode("dialogBackground")
  if not isValid(m.dialog)
    ' Do not check segments or next episode for intro videos
    if not m.LoadMetaDataTask.isIntro
      checkMediaSegments()
      checkTimeToDisplayNextEpisode()
    end if
  end if

  ' Carousel visibility state machine:
  ' 1. Normal scrubbing: carousel auto-shows when trickPlayBar visible (not OSD)
  ' 2. Seek confirmation: when user presses OK, carousel is hidden (line 1146) and
  '    m.carouselHiddenForSeek flag prevents auto-show until seek completes
  ' 3. Seek completion: when trickPlayBar closes, flags reset and carousel ready for next scrub
  ' This prevents the carousel from flashing back on between OK press and trickPlayBar closing
  if isValid(m.trickplayCarousel) and not m.carouselHiddenForSeek
    trickPlayBarVisible = m.top.trickPlayBar.visible and not m.osd.visible
    m.trickplayCarousel.isVisible = trickPlayBarVisible

    ' Reset seek tracking when trickPlayBar closes (user confirmed or cancelled seek)
    if not trickPlayBarVisible
      m.isTrackingSeek = false
      m.carouselHiddenForSeek = false
      m.lastSentThumbnailIndex = -1 ' Reset so next scrub session sends initial position
    end if
  end if

  ' Update trickplay carousel position during normal playback only
  ' When trickPlayBar is visible, onTrickPlayBarTextChange is the authoritative source
  if isValid(m.trickplayCarousel) and m.trickplayCarousel.isVisible and not m.top.trickPlayBar.visible
    updateTrickplayCarousel(m.top.position)
  end if
end sub

' Updates trickplay carousel with index calculated from video position
' Snaps to thumbnail boundaries for cleaner UX
' @param {Float} position - Video position in seconds
sub updateTrickplayCarousel(position as float)
  if not isValid(m.trickplayCarousel) then return
  if not isValidAndNotEmpty(m.trickplayCarousel.trickplayConfig) then return

  interval = m.trickplayCarousel.trickplayConfig.interval

  ' Calculate thumbnail index from position
  thumbnailIndex = trickplay.calculateThumbnailIndex(position, interval)

  ' Skip if same index as last sent - prevents double-rendering at tile boundaries
  ' when both onPositionChanged and onTrickPlayBarTextChange fire with similar positions
  if thumbnailIndex = m.lastSentThumbnailIndex then return
  m.lastSentThumbnailIndex = thumbnailIndex

  ' Update carousel with index (triggers shifting logic)
  m.trickplayCarousel.thumbnailIndex = thumbnailIndex
end sub

' Called when Roku's trickPlayBar position text changes
' Parses the time string to get seek position and syncs our carousel
' This ensures carousel stays in sync with Roku's authoritative seek position
sub onTrickPlayBarTextChange()
  if not m.top.trickPlayBar.visible then return
  if not isValid(m.trickPlayBarPositionText) then return

  timeText = m.trickPlayBarPositionText.text
  if not isValidAndNotEmpty(timeText) then return

  ' Parse time string "53:50" (MM:SS) or "1:23:45" (H:MM:SS) to seconds
  seekPosition = timeText.split(":")
  seekTime = 0

  if seekPosition.count() = 3
    ' H:MM:SS format
    seekTime = seekPosition[0].toInt() * 3600 ' hours
    seekTime = seekTime + seekPosition[1].toInt() * 60 ' minutes
    seekTime = seekTime + seekPosition[2].toInt() ' seconds
  else if seekPosition.count() = 2
    ' MM:SS format
    seekTime = seekPosition[0].toInt() * 60 ' minutes
    seekTime = seekTime + seekPosition[1].toInt() ' seconds
  else
    ' Unexpected format - use current position as fallback
    seekTime = m.top.position
  end if

  m.log.debug("TrickPlayBar text changed", timeText, "parsed to", seekTime, "seconds")

  ' Sync carousel with Roku's authoritative seek position
  m.isTrackingSeek = true

  ' Update carousel to match
  if isValid(m.trickplayCarousel) and m.trickplayCarousel.isVisible
    updateTrickplayCarousel(seekTime)
  end if
end sub

' retryPlayback: Re-runs the content loader from the current position with updated task flags.
' Used by error handlers to retry without exiting the player.
' @param {boolean} bypassDovi - skips DoVi container profile so the server can grant direct play
' @param {boolean} forceTranscode - disables direct play so the server returns a transcode URL
sub retryPlayback(bypassDovi as boolean, forceTranscode as boolean)
  m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)
  ' Prevents ViewCreator popping the scene when stop causes Roku to fire "finished".
  m.top.isRetrying = true
  m.top.control = "stop"
  m.LoadMetaDataTask.shouldBypassDoviPreservation = bypassDovi
  m.LoadMetaDataTask.shouldForceTranscoding = forceTranscode
  m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
  m.LoadMetaDataTask.selectedAudioStreamIndex = m.top.audioIndex
  m.LoadMetaDataTask.itemId = m.currentItem.id
  m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
  m.LoadMetaDataTask.control = "RUN"
end sub

'
' When Video Player state changes
sub onState()
  ' On first play, verify audioTrack matches the intended Jellyfin selection using
  ' Roku's availableAudioTracks (now populated). The Index + 1 fallback set during
  ' onVideoContentLoaded may be wrong for non-standard MKV track numbering.
  if m.top.state = "playing" and not m.isPlayReported
    if isValid(m.top.availableAudioTracks) and isValid(m.top.audioIndex) and not m.top.isTranscoded
      correctedTrack = getRokuAudioTrackPosition(m.top.audioIndex, m.top.fullAudioData, m.top.availableAudioTracks)
      if correctedTrack <> m.top.audioTrack
        m.log.info("Correcting audioTrack on first play", "was", m.top.audioTrack, "corrected", correctedTrack, "jellyfinIndex", m.top.audioIndex)
        m.top.audioTrack = correctedTrack
      else
        m.log.debug("audioTrack verified correct on first play", "audioTrack", m.top.audioTrack, "jellyfinIndex", m.top.audioIndex)
      end if
    end if
  end if

  if isValid(m.captionTask)
    m.captionTask.playerState = m.top.state + m.top.globalCaptionMode
  end if

  ' Pass video state into OSD
  m.osd.playbackState = m.top.state

  if m.top.state = "buffering"
    ' When buffering, start timer to monitor buffering process
    if isValid(m.bufferCheckTimer)
      m.bufferCheckTimer.control = "start"
      m.bufferCheckTimer.ObserveField("fire", "bufferCheck")
    end if
    ' Show non-blocking spinner for mid-playback buffering (seek, network stall)
    if m.isPlayReported
      startLoadingSpinner(false)
    end if
  else if m.top.state = "error"
    m.log.error(m.top.errorCode, m.top.errorMsg, m.top.errorStr, m.isPlayReported, m.top.isTranscodeAvailable)

    print m.top.errorInfo

    ' Detect a Roku video buffer overflow (buffer:loop: source) caused by DoVi transcoding.
    ' When DoVi preservation forced a transcode and the HLS segments overflow the device buffer,
    ' retry with direct play by bypassing the DoVi container profile injection.
    isBufferLoopError = isValid(m.top.errorInfo) and isValid(m.top.errorInfo.source) and m.top.errorInfo.source = "buffer:loop:"

    if isBufferLoopError and m.top.isDoviDirectPlayFallbackAvailable
      m.log.warn("Buffer overflow in DoVi transcode; falling back to direct play", m.currentItem.id)
      m.top.isDoviDirectPlayFallbackAvailable = false ' prevent retry loop if direct play also fails
      retryPlayback(true, false)
    else if not m.isPlayReported and m.top.isTranscodeAvailable
      m.log.warn("Direct play failed; falling back to transcode", m.currentItem.id)
      m.top.isTranscodeAvailable = false ' prevent retry loop if transcode also fails
      retryPlayback(false, true)
    else
      ' If an error was encountered, stop timers and display dialog
      m.top.unobserveField("state")
      m.playbackTimer.control = "stop"
      m.bufferCheckTimer.control = "stop"
      m.bufferCheckTimer.unobserveField("fire")
      ' Ensure trickplay carousel is cleaned up on error just like stopped/finished states
      if isValid(m.trickplayCarousel)
        m.trickplayCarousel.isVisible = false
      end if
      stopLoadingSpinner()
      showPlaybackErrorDialog(translate(translationKeys.ErrorDuringPlayback))
    end if
  else if m.top.state = "playing"
    stopLoadingSpinner()
    ' Check if next episode is available
    if isValid(m.top.showID)
      if m.top.showID <> "" and not m.checkedForNextEpisode and m.top.content.contenttype = 4
        m.getNextEpisodeTask.showID = m.top.showID
        m.getNextEpisodeTask.videoID = m.top.id
        m.getNextEpisodeTask.control = "RUN"
      end if
    end if

    if m.isPlayReported = false
      reportPlayback("start")
      m.isPlayReported = true

      ' Load channel list in background for channel up/down navigation
      if isLiveTvPlayback() and not m.channelListLoaded
        loadChannelListForQueue()
      end if
    else
      reportPlayback()
    end if
    m.playbackTimer.control = "start"
  else if m.top.state = "paused"
    stopLoadingSpinner()
    m.playbackTimer.control = "stop"
    reportPlayback()
  else if m.top.state = "stopped"
    ' Skip during channel switch; the incoming VideoPlayerView will hide the spinner
    if not (m.isSwitchingChannel = true)
      stopLoadingSpinner()
    end if
    m.playbackTimer.control = "stop"
    m.playbackTimer.unobserveField("fire")
    m.bufferCheckTimer.control = "stop"
    m.bufferCheckTimer.unobserveField("fire")
    reportPlayback("stop")
    m.isPlayReported = false
    if isValid(m.trickplayCarousel)
      m.trickplayCarousel.isVisible = false
      m.trickplayCarousel.callFunc("reset")
    end if
  else if m.top.state = "finished"
    stopLoadingSpinner()
    m.playbackTimer.control = "stop"
    m.playbackTimer.unobserveField("fire")
    m.bufferCheckTimer.control = "stop"
    m.bufferCheckTimer.unobserveField("fire")
    reportPlayback("finished")
    if isValid(m.trickplayCarousel)
      m.trickplayCarousel.isVisible = false
      m.trickplayCarousel.callFunc("reset")
    end if
  else
    m.log.warning("Unhandled state", m.top.state, m.isPlayReported, m.playFinished)
  end if
  m.log.debug("end onState", m.top.state)
end sub

'
' Report playback to server
sub reportPlayback(state = "update" as string)
  if not isValid(m.top.position) then return

  m.log.debug("start reportPlayback", state, int(m.top.position))

  ' When force-finished, report position at end of video so the server
  ' sees a natural completion — matching the mark-played request.
  positionSeconds = m.top.position
  if m.forceFinished and (state = "stop" or state = "finished")
    positionSeconds = m.top.duration
  end if

  params = {
    "ItemId": m.top.id,
    "PlaySessionId": m.top.PlaySessionId,
    "PositionTicks": int(positionSeconds) * 10000000&, 'Ensure a LongInteger is used
    "IsPaused": (m.top.state = "paused")
  }
  ' Live TV requires MediaSourceId/LiveStreamId for server-side stream tracking
  if isLiveTvPlayback() and isValid(m.top.transcodeParams)
    params.append({
      "MediaSourceId": m.top.transcodeParams.MediaSourceId,
      "LiveStreamId": m.top.transcodeParams.LiveStreamId
    })
    m.bufferCheckTimer.duration = 30
  end if

  if (state = "stop" or state = "finished") and isValid(m.originalClosedCaptionState)
    m.log.debug("reportPlayback setting", m.top.globalCaptionMode, "back to", m.originalClosedCaptionState)
    m.top.globalCaptionMode = m.originalClosedCaptionState
    m.originalClosedCaptionState = invalid
  end if

  ' Report playstate via side-effect task
  req = GetApi().BuildPlaystateRequest(state, params)
  SubmitSideEffect(req)

  ' Refresh live TV metadata at the program boundary, or on 60 s wall-clock fallback
  ' when endDate is missing. Wall-clock rather than a tick counter so the interval
  ' stays at 60 s regardless of playbackTimer duration.
  if state = "update" and isLiveTvPlayback()
    now = CreateObject("roDateTime").AsSeconds()
    if m.liveTvProgramEndTime > 0 and now >= m.liveTvProgramEndTime
      triggerLiveTvMetadataRefresh()
    else if m.liveTvProgramEndTime = 0
      if m.liveTvNextFallbackRefreshAt = 0
        m.liveTvNextFallbackRefreshAt = now + 60
      else if now >= m.liveTvNextFallbackRefreshAt
        m.liveTvNextFallbackRefreshAt = now + 60
        triggerLiveTvMetadataRefresh()
      end if
    end if
  end if

  m.log.debug("end reportPlayback", state, int(m.top.position))
end sub

'
' Check the the buffering has not hung
sub bufferCheck()

  if m.top.state <> "buffering"
    ' If video is not buffering, stop timer
    m.bufferCheckTimer.control = "stop"
    m.bufferCheckTimer.unobserveField("fire")
    return
  end if
  if isValid(m.top.bufferingStatus)

    ' Check that the buffering percentage is increasing
    if m.top.bufferingStatus["percentage"] > m.bufferPercentage
      m.bufferPercentage = m.top.bufferingStatus["percentage"]
    else if isLiveTvPlayback()
      m.top.callFunc("refresh")
    else
      ' If buffering has stopped Display dialog
      showPlaybackErrorDialog(translate(translationKeys.ErrorThereWasAnErrorRetrievingThe))

      ' Stop playback and exit player
      m.top.control = "stop"
    end if
  end if

end sub

function isLiveTvPlayback() as boolean
  return isValid(m.currentItem) and LCase(m.currentItem.type) = "tvchannel"
end function

sub triggerLiveTvMetadataRefresh()
  ' Zero the end time first to prevent duplicate refresh triggers
  m.liveTvProgramEndTime = 0

  if not isValid(m.liveTvRefreshTask)
    m.liveTvRefreshTask = CreateObject("roSGNode", "RefreshLiveTvMetadataTask")
    m.liveTvRefreshTask.observeField("refreshedItem", "onLiveTvMetadataRefreshed")
  end if

  m.liveTvRefreshTask.channelId = m.top.id
  m.liveTvRefreshTask.control = "RUN"
end sub

sub onLiveTvMetadataRefreshed()
  m.liveTvRefreshTask.control = "STOP"
  meta = m.liveTvRefreshTask.refreshedItem
  if not isValid(meta) then return

  m.osd.itemData = meta

  ' Logo priority: program logo → program primary → channel icon. Always write the result
  ' (empty string included) so the prior program's logo doesn't linger when the new one
  ' has no artwork. Mirrors the initial-load chain in LoadVideoContentTask.
  newLogoUrl as string = ""
  if meta.type = "TvChannel"
    osdLogoSize = imageSize.LOGO_OSD
    currentProgram = meta.currentProgram
    if isValid(currentProgram) and isValidAndNotEmpty(currentProgram.logoImageTag)
      newLogoUrl = ImageURL(currentProgram.id, "Logo", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: currentProgram.logoImageTag })
    else if isValid(currentProgram) and isValidAndNotEmpty(currentProgram.primaryImageTag)
      newLogoUrl = ImageURL(currentProgram.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: currentProgram.primaryImageTag })
    else if isValidAndNotEmpty(meta.primaryImageTag)
      newLogoUrl = ImageURL(meta.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.primaryImageTag })
    end if
  end if
  m.osd.videoLogo = newLogoUrl

  m.liveTvProgramEndTime = parseProgramEndTimeEpoch(meta)
  m.liveTvNextFallbackRefreshAt = 0
end sub

sub loadChannelListForQueue()
  if not isValid(m.loadChannelListTask)
    m.loadChannelListTask = CreateObject("roSGNode", "LoadChannelListForQueueTask")
    m.loadChannelListTask.observeField("channelList", "onChannelListLoaded")
  end if

  m.loadChannelListTask.currentChannelId = m.top.id
  m.loadChannelListTask.control = "RUN"
  m.log.info("Loading channel list for navigation", m.top.id)
end sub

sub onChannelListLoaded()
  m.loadChannelListTask.control = "STOP"

  channelList = m.loadChannelListTask.channelList
  currentIndex = m.loadChannelListTask.currentChannelIndex

  if not isValidAndNotEmpty(channelList) or currentIndex < 0
    m.log.warn("Channel list empty or current channel not found, skipping queue update")
    return
  end if

  queueManager = m.global.queueManager
  queueManager.callFunc("set", channelList)
  queueManager.callFunc("setPosition", currentIndex)
  m.channelListLoaded = true

  m.log.info("Channel list loaded into queue", "count", channelList.count(), "position", currentIndex)
end sub

' stateAllowsOSD: Check if current video state allows showing the OSD
'
' @return {boolean} indicating if video state allows the OSD to show
function stateAllowsOSD() as boolean
  validStates = ["playing", "paused", "stopped"]
  return inArray(validStates, m.top.state)
end function


' availSubtitleTrackIdx: Returns Roku's index for requested subtitle track
'
' @param {string} tracknameToFind - TrackName for subtitle we're looking to match
' @return {integer} indicating Roku's index for requested subtitle track. Returns SubtitleSelection.NONE if not found
function availSubtitleTrackIdx(tracknameToFind as string) as integer
  idx = 0
  for each availTrack in m.top.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

function onKeyEvent(key as string, press as boolean) as boolean

  ' Keypress handler while user is inside the chapter menu
  if m.chapterMenu.hasFocus()
    if not press then return false

    if key = "OK"
      focusedChapter = m.chapterMenu.itemFocused
      selectedChapter = m.chapterMenu.content.getChild(focusedChapter)
      seekTime = selectedChapter.playstart

      ' Don't seek if user clicked on No Chapter Data
      if seekTime = m.playbackEnum.null then return true

      m.top.seek = seekTime
      return true
    end if

    if key = "back" or key = "replay"
      m.chapterList.visible = false
      m.osd.shouldShowChapterList = false
      m.chapterMenu.setFocus(false)
      m.osd.hasFocus = true
      m.osd.setFocus(true)
      return true
    end if

    if key = "play"
      handleVideoPlayPauseAction()
    end if

    return true
  end if

  ' VideoNotification components handle OK key internally via their onKeyEvent.
  ' For non-OK keys, hide notifications (paused state) so they can re-show after OSD closes.
  ' Next Episode is dismissed but will re-appear on subsequent position updates if still in the time window.
  if isNotificationVisible(m.nextEpisodeNotification) or isNotificationVisible(m.segmentNotification)
    ' OK is handled by the notification's onKeyEvent — don't intercept here
    if key <> "OK"
      if m.segmentNotification.state = "showing"
        m.segmentNotification.callFunc("hide")
      end if
      if m.nextEpisodeNotification.state = "showing"
        m.nextEpisodeNotification.callFunc("dismiss")
      end if
      m.top.setFocus(true)
    end if
  end if

  if not press then return false

  if key = "down" and not m.top.trickPlayBar.visible
    ' Don't allow user to open menu prior to video loading
    if not stateAllowsOSD() then return true

    m.osd.visible = true
    m.osd.hasFocus = true
    m.osd.setFocus(true)
    return true

  else if key = "up" and not m.top.trickPlayBar.visible
    ' Don't allow user to open menu prior to video loading
    if not stateAllowsOSD() then return true

    m.osd.visible = true
    m.osd.hasFocus = true
    m.osd.setFocus(true)
    return true

  else if key = "OK" and not m.top.trickPlayBar.visible
    ' Don't allow user to open menu prior to video loading
    if not stateAllowsOSD() then return true

    ' Show OSD, but don't pause video
    m.osd.visible = true
    m.osd.hasFocus = true
    m.osd.setFocus(true)
    return true
  end if

  ' Disable OSD for intro videos
  if key = "play" and not m.top.trickPlayBar.visible

    ' Don't allow user to open menu prior to video loading
    if not stateAllowsOSD() then return true

    ' If video is paused, resume it and don't show OSD
    if m.top.state = "paused"
      m.top.control = "resume"
      return true
    end if

    ' Pause video and show OSD
    m.top.control = "pause"
    m.osd.playbackState = "paused"
    m.osd.visible = true
    m.osd.hasFocus = true
    m.osd.setFocus(true)
    return true
  end if

  if key = "back"
    m.top.control = "stop"
  end if

  ' Handle OK/Play key during seek - hide carousel and let Roku seek to its position
  ' Since we're now synced to Roku's position via the trickPlayBar text observer,
  ' we don't need to do our own seek - Roku's native seek will match our displayed thumbnail
  if (key = "OK" or key = "play") and m.top.trickPlayBar.visible

    m.isTrackingSeek = false
    if isValid(m.trickplayCarousel)
      ' Hide carousel immediately on seek confirmation
      m.trickplayCarousel.isVisible = false
      ' Set flag to prevent auto-show (line 790 guard) until trickPlayBar closes (line 797 reset)
      ' This prevents carousel flashing back on between OK press and trickPlayBar closing
      m.carouselHiddenForSeek = true
    end if
    ' Return false to let Roku handle the seek and close the trickPlayBar
    return false
  end if

  ' Handle left/right/FF/RW keys - show carousel, observer syncs position from trickPlayBar text
  if not m.osd.visible and (key = "left" or key = "right" or key = "fastforward" or key = "rewind")
    if isValid(m.trickplayCarousel)
      m.trickplayCarousel.isVisible = true
    end if
    return false
  end if

  return false
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called by SceneManager when popping this scene
sub onDestroy()
  ' Unobserve all fields first to prevent callbacks during teardown
  m.top.unobserveField("state")
  m.top.unobserveField("content")
  m.top.unobserveField("selectedSubtitle")
  m.top.unobserveField("audioIndex")
  m.top.unobserveField("mediaSourceId")
  m.top.unobserveField("shouldAllowCaptions")
  m.top.unobserveField("subtitleTrack")
  m.top.unobserveField("globalCaptionMode")
  m.top.unobserveField("position")

  if isValid(m.osd)
    m.osd.unobserveField("action")
    m.osd.callFunc("onDestroy")
  end if

  ' Stop and release timers
  if isValid(m.playbackTimer)
    m.playbackTimer.control = "stop"
    m.playbackTimer.unobserveField("fire")
    m.playbackTimer = invalid
  end if

  if isValid(m.bufferCheckTimer)
    m.bufferCheckTimer.control = "stop"
    m.bufferCheckTimer.unobserveField("fire")
    m.bufferCheckTimer = invalid
  end if

  ' Stop and release task nodes
  if isValid(m.LoadMetaDataTask)
    m.LoadMetaDataTask.control = "STOP"
    m.LoadMetaDataTask.unobserveField("content")
    m.LoadMetaDataTask = invalid
  end if

  if isValid(m.getNextEpisodeTask)
    m.getNextEpisodeTask.control = "STOP"
    m.getNextEpisodeTask.unobserveField("nextEpisodeData")
    m.getNextEpisodeTask = invalid
  end if

  if isValid(m.liveTvRefreshTask)
    m.liveTvRefreshTask.control = "STOP"
    m.liveTvRefreshTask.unobserveField("refreshedItem")
    m.liveTvRefreshTask = invalid
  end if
  m.liveTvProgramEndTime = 0

  if isValid(m.loadChannelListTask)
    m.loadChannelListTask.control = "STOP"
    m.loadChannelListTask.unobserveField("channelList")
    m.loadChannelListTask = invalid
  end if

  if isValid(m.captionTask)
    m.captionTask.control = "STOP"
    m.captionTask.unobserveField("currentCaption")
    m.captionTask.unobserveField("useThis")
    m.captionTask.callFunc("onDestroy")
    m.captionTask = invalid
  end if

  ' Unobserve trickPlayBar position text
  if isValid(m.trickPlayBarPositionText)
    m.trickPlayBarPositionText.unobserveField("text")
    m.trickPlayBarPositionText = invalid
  end if

  ' Destroy child components that have onDestroy()
  if isValid(m.trickplayCarousel)
    m.trickplayCarousel.callFunc("onDestroy")
    m.trickplayCarousel = invalid
  end if

  ' Destroy notification components
  if isValid(m.segmentNotification)
    m.segmentNotification.unobserveField("action")
    m.segmentNotification.unobserveField("state")
    m.segmentNotification.callFunc("onDestroy")
    m.segmentNotification = invalid
  end if

  if isValid(m.nextEpisodeNotification)
    m.nextEpisodeNotification.unobserveField("action")
    m.nextEpisodeNotification.unobserveField("state")
    m.nextEpisodeNotification.callFunc("onDestroy")
    m.nextEpisodeNotification = invalid
  end if

  ' Clear segment state
  m.mediaSegments = []
  m.skippedSegments = {}
  m.dismissedSegmentId = ""
  m.activeSegmentId = ""

  ' Clear remaining node references
  m.osd = invalid
  m.chapterList = invalid
  m.chapterMenu = invalid
  m.chapterContent = invalid
  m.captionGroup = invalid
end sub

' handleTransport: Roku voice transport handler. Called from source/main.bs when an
' roInputEvent with info.type = "transport" arrives and the active scene is this player.
'
' Returns { status: "<code>" } per roInput.EventResponse() — Roku OS uses the status
' to render the appropriate HUD message. Codes used here:
'   - success / success.seek-start / success.seek-end — op completed (with bounds note)
'   - error.redundant — state already matches request (e.g. pause-while-paused)
'   - error.generic — op semantically unsupported in this context
'   - unhandled — command we don't recognize; lets Roku show "Command not available"
'
' See https://github.com/rokudev/dev-doc — docs/DEVELOPER/media-playback/voice-controls/transport-controls.md
function handleTransport(evt as object) as object
  if not isValid(evt) or not isValid(evt.command) then return { status: "unhandled" }
  cmd = lcase(evt.command)
  m.log.info("voice transport", cmd)

  if cmd = "play" or cmd = "resume"
    if m.top.state = "paused"
      m.top.control = "resume"
      return { status: "success" }
    end if
    if m.top.state = "playing" then return { status: "error.redundant" }
    m.top.control = "play"
    return { status: "success" }
  end if

  if cmd = "pause"
    if m.top.state = "paused" then return { status: "error.redundant" }
    m.top.control = "pause"
    return { status: "success" }
  end if

  if cmd = "stop"
    ' Mirror back-key behavior: stop playback AND pop the scene. ViewCreator's
    ' onStateChange only pops on state="finished", not "stopped"; the physical
    ' back key gets the pop for free via Roku's OS-default back handling, but
    ' voice transport doesn't, so we pop explicitly.
    m.top.control = "stop"
    m.global.sceneManager.callFunc("popScene")
    return { status: "success" }
  end if

  if cmd = "ok"
    ' Mirror physical OK in idle video state — open the OSD. If already visible,
    ' physical OK activates the focused button; voice can't replicate that without
    ' synthesizing a key event, so treat the second voice-OK as a redundant noop.
    if not stateAllowsOSD() then return { status: "error.generic" }
    if isValid(m.osd) and m.osd.visible then return { status: "error.redundant" }
    m.osd.visible = true
    m.osd.hasFocus = true
    m.osd.setFocus(true)
    return { status: "success" }
  end if

  if cmd = "forward"
    m.top.control = "fastforward"
    return { status: "success" }
  end if

  if cmd = "rewind"
    m.top.control = "rewind"
    return { status: "success" }
  end if

  if cmd = "replay"
    return seekRelativeWithBounds(-getInstantReplaySeconds())
  end if

  if cmd = "startover"
    return seekToWithBounds(0)
  end if

  if cmd = "seek"
    return handleVoiceSeekCommand(evt)
  end if

  if cmd = "next"
    return handleVoiceNextItem()
  end if

  if cmd = "skip"
    ' Try to skip an active media segment first; fall through to next-item if none.
    if tryActiveSegmentSkip() then return { status: "success" }
    return handleVoiceNextItem()
  end if

  if cmd = "nowplaying"
    return announceNowPlaying()
  end if

  if cmd = "shuffle" or cmd = "loop"
    ' Video player has no shuffle/loop UI. Audio player handles these.
    return { status: "error.generic" }
  end if

  if cmd = "like"
    return setFavorite(true)
  end if

  if cmd = "dislike"
    return setFavorite(false)
  end if

  return { status: "unhandled" }
end function

function getInstantReplaySeconds() as integer
  return voiceTransport.resolveInstantReplaySeconds(m.global.user.settings.playbackInstantReplaySeconds)
end function

function seekRelativeWithBounds(deltaSeconds as integer) as object
  if not isValid(m.top.position) then return { status: "error.generic" }
  return seekToWithBounds(m.top.position + deltaSeconds)
end function

' 30s end-slack matches the Roku transport-controls reference example for video —
' clamping shy of duration prevents the player from firing end-of-content immediately.
function seekToWithBounds(targetSeconds as float) as object
  result = voiceTransport.computeSeekStatus(targetSeconds, m.top.duration, 30)
  m.top.seek = result.clamped
  return { status: result.status }
end function

function handleVoiceSeekCommand(evt as object) as object
  parsed = voiceTransport.parseSeekDelta(evt)
  if not parsed.valid then return { status: "error.generic" }
  return seekRelativeWithBounds(parsed.delta)
end function

' Skip an active media segment (intro/recap/etc.) if one is currently in-window.
' Returns true if a segment was skipped, false if no active segment.
function tryActiveSegmentSkip() as boolean
  if not isValid(m.activeSegmentId) or m.activeSegmentId = "" then return false
  if not isValid(m.mediaSegments) or m.mediaSegments.count() = 0 then return false
  segment = findSegmentById(m.mediaSegments, m.activeSegmentId)
  if not isValid(segment) then return false

  seekPosition = segmentTicksToSeconds(segment.EndTicks)
  segmentType = segment.Type ?? ""

  ' Outro near end-of-content: force-finish so watched-state and auto-play fire.
  if segmentType = MediaSegmentType.OUTRO and m.top.duration > 0 and seekPosition >= m.top.duration - 5
    forceFinishPlayback()
  else
    m.top.seek = seekPosition
    m.skippedSegments[m.activeSegmentId] = true
  end if

  m.activeSegmentId = ""
  if isValid(m.segmentNotification) and isNotificationVisible(m.segmentNotification)
    m.segmentNotification.callFunc("dismiss")
  end if
  return true
end function

' Reuse the OSD itemNext code path so Live-TV channel advance, queue advance, and
' Episode→next-Episode all behave identically to pressing the on-screen Next button.
function handleVoiceNextItem() as object
  queueManager = m.global.queueManager
  if not isValid(queueManager) then return { status: "error.generic" }
  queueCount = queueManager.callFunc("getCount")
  queuePosition = queueManager.callFunc("getPosition")
  isTvChannel = isLiveTvPlayback() and m.channelListLoaded and queueCount > 1

  targetPosition = -1
  if queuePosition < queueCount - 1
    targetPosition = queuePosition + 1
  else if isTvChannel
    targetPosition = 0
  end if

  if targetPosition < 0 then return { status: "error.generic" }
  switchToQueueItem(queueManager, targetPosition)
  return { status: "success" }
end function

' Returns the metadata in the response — main.bs's transport dispatcher calls
' roAppManager.SetNowPlayingContentMetaData() on the MAIN thread (roAppManager
' can't be created on the render thread, where this callFunc executes).
function announceNowPlaying() as object
  if not isValid(m.osd) or not isValid(m.osd.itemData) then return { status: "error.generic" }
  item = m.osd.itemData
  title = voiceTransport.formatNowPlayingTitle(item)
  if title = "" then return { status: "error.generic" }
  return {
    status: "success",
    nowPlaying: {
      title: title,
      contentType: item.type ?? ""
    }
  }
end function

function setFavorite(isLike as boolean) as object
  if not isValid(m.osd) or not isValid(m.osd.itemData) or not isValid(m.osd.itemData.id)
    return { status: "error.generic" }
  end if
  itemId = m.osd.itemData.id
  if itemId = "" then return { status: "error.generic" }
  if isLike
    SubmitSideEffect(GetApi().BuildMarkFavoriteRequest(itemId))
  else
    SubmitSideEffect(GetApi().BuildUnmarkFavoriteRequest(itemId))
  end if
  return { status: "success" }
end function