components_video_OSD.bs

import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  m.log = new log.Logger("OSD")
  m.inactivityTimer = m.top.findNode("inactivityTimer")
  m.endsAtTime = m.top.findNode("endsAtTime")
  m.videoLogo = m.top.findNode("videoLogo")
  ' loadWidth/loadHeight bound texture memory to imageSize.LOGO_OSD with AR preserved
  ' (Roku treats them as a bounding box, not independent axes). noScale renders at the loaded
  ' size so the Poster auto-sizes to its content, keeping top-left alignment in the safe zone.
  ' Do NOT set width/height on the Poster node — that forces a fixed layout box and centers the image.
  osdLogoSize = imageSize.LOGO_OSD
  m.videoLogo.loadWidth = osdLogoSize.width
  m.videoLogo.loadHeight = osdLogoSize.height
  m.videoTitle = m.top.findNode("videoTitle")
  m.videoSubtitleGroup = m.top.findNode("videoSubtitleGroup")
  m.top.findNode("endsAtText").text = translate(translationKeys.LabelEndsAt)
  m.videoPlayPause = m.top.findNode("videoPlayPause")
  m.videoPositionTime = m.top.findNode("videoPositionTime")
  m.videoRemainingTime = m.top.findNode("videoRemainingTime")
  m.progressBar = m.top.findNode("progressBar")
  m.progressBarBackground = m.top.findNode("progressBarBackground")
  m.clock = m.top.findNode("clock")

  if isValid(m.clock)
    m.clock.observeField("minutes", "setEndsAtText")
  end if
  m.top.observeField("itemData", "onItemDataChanged")
  m.top.observeField("visible", "onVisibleChanged")
  m.top.observeField("hasFocus", "onFocusChanged")
  m.top.observeField("progressPercentage", "onProgressPercentageChanged")
  m.top.observeField("playbackState", "onPlaybackStateChanged")
  ' positionTime drives live-TV calibration so the epoch anchor is captured the first time
  ' VideoPlayerView pushes a position during playback — even if the user pauses immediately
  ' afterward (e.g. by pressing play to open the OSD).
  m.top.observeField("positionTime", "onPositionTimeChanged")

  m.isFirstRun = true
  m.defaultButtonIndex = 1
  m.focusedButtonIndex = 1
  m.subtitleDividerCount = 0

  ' Hybrid behind-live tracking. Roku's m.top.position for live HLS DVR streams advances
  ' even during pause (it tracks the live edge, not the user's playhead), so we use:
  '   - position-math during play (handles scrubs while playing)
  '   - wall-clock math during pause (Roku's auto-advance is invisible to us)
  ' On pause→play the epoch reference is re-anchored so play-math picks up where
  ' wall-clock math left off. See source/utils/liveTv.bs::computeLiveTvBehindLive.
  m.liveTvCalibrated = false
  m.liveTvEpochAtPosition0 = 0 ' play-math reference (epoch for video position=0)
  m.liveTvPausedAtEpoch = 0 ' >0 while paused, snapshotted at pause start
  m.liveTvAccumulatedBehind = 0 ' snapshot of behind-live at pause start (+ scrub adjustments)
  m.liveTvLastPosition = 0 ' baseline for scrub-during-pause delta detection
  m.liveTvIsAtLiveEdge = true

  ' Go to Live button — cached and attached/detached dynamically by setButtonStates()
  ' and attach/detachGoToLiveButton() so it doesn't reserve a layout slot when hidden.
  m.goToLiveButton = invalid
  m.goToLiveAttached = false

  m.buttonMenuRight = m.top.findNode("buttonMenuRight")
  m.buttonMenuLeft = m.top.findNode("buttonMenuLeft")
  m.buttonMenuLeft.buttonFocused = m.defaultButtonIndex
end sub

' onItemDataChanged: Reads typed fields from JellyfinBaseItem node and populates OSD display.
'
' Replaces the old JSON-parsing setFields(). The item node is immutable - all metadata
' is read directly from typed fields (item.type, item.name, item.communityRating, etc.)
'
' Content Type Detection (isMovie/isSeries):
' These flags determine which user settings control ratings display:
' - isMovie=true: Uses uiMoviesShowRatings setting
' - isSeries=true: Uses uiTvShowsDisableCommunityRating setting
'
' Detection Priority (applied in order):
' 1. API flags: Uses isMovie/isSeries from JellyfinBaseItem if provided
' 2. Flag validation: Ensures mutual exclusivity (both cannot be true)
' 3. Type-based: Recording types always classified as series content
' 4. Metadata heuristic: Presence of seriesName/parentIndexNumber indicates series
' 5. Default fallback: Assumes movie content if no other indicators
'
' @return {void}
sub onItemDataChanged()
  item = m.top.itemData
  if not isValid(item) or not isValidAndNotEmpty(item.id) then return

  ' Reset behind-live state only on channel change; program refreshes keep it.
  ' New stream → new position=0 origin → must recalibrate and clear any pause snapshot.
  previousItemId = isValid(m.itemData) ? m.itemData.id : ""
  if item.id <> previousItemId
    m.liveTvCalibrated = false
    m.liveTvEpochAtPosition0 = 0
    m.liveTvPausedAtEpoch = 0
    m.liveTvAccumulatedBehind = 0
    m.liveTvLastPosition = 0
    m.liveTvIsAtLiveEdge = true
  end if

  ' Cache item reference for use in display methods
  m.itemData = item

  itemType = item.type

  ' Preserve the original item name from the first load.
  ' Source-switch reloads fetch metadata for an alternate version whose name
  ' may include year/edition suffixes (e.g., "Dracula (1931) [Colorized]").
  ' TvChannel titles always come from currentProgram.name, so caching is unnecessary.
  if itemType <> "TvChannel" and not isValidAndNotEmpty(m.originalItemName)
    m.originalItemName = item.name
  end if

  ' Determine isMovie/isSeries flags for ratings display settings.
  ' For TvChannel, use the currently-airing program's flags if available.
  if itemType = "TvChannel" and isValid(item.currentProgram)
    isMovieFlag = item.currentProgram.isMovie
    isSeriesFlag = item.currentProgram.isSeries
  else
    isMovieFlag = item.isMovie
    isSeriesFlag = item.isSeries
  end if

  ' Validate mutual exclusivity: both flags cannot be true
  if isMovieFlag and isSeriesFlag
    ' API provided conflicting flags - prioritize isSeries as it's more specific
    isMovieFlag = false
  end if

  ' Fallback heuristic: if no flags provided, infer from metadata
  if not isMovieFlag and not isSeriesFlag
    ' Recording types are episode-like content (same as Episode handling elsewhere)
    if itemType = "Recording"
      isSeriesFlag = true
    else if isValidAndNotEmpty(item.seriesName) or item.parentIndexNumber > 0
      isSeriesFlag = true
    else
      isMovieFlag = true
    end if
  end if

  m.top.isMovie = isMovieFlag
  m.top.isSeries = isSeriesFlag

  ' Chapters
  m.top.hasChapters = item.chapterCount > 0

  ' Stream counts - read pre-computed values from item node
  m.top.numAudioStreams = item.audioStreamCount
  ' Only update video source count from meta if it's higher than the current value.
  ' During a source-switch reload, the reloaded meta represents a single MediaSource
  ' and hasMultipleVideoSources will be false, but the original source list is still valid.
  metaVideoSources = item.hasMultipleVideoSources ? 2 : 1
  if metaVideoSources > m.top.numVideoSources
    m.top.numVideoSources = metaVideoSources
  end if

  ' Runtime — for TvChannel, use the currently-airing program's runtime if available
  runTimeTicks = item.runTimeTicks
  if itemType = "TvChannel" and isValid(item.currentProgram) and item.currentProgram.runTimeTicks > 0
    runTimeTicks = item.currentProgram.runTimeTicks
  end if
  if runTimeTicks > 0
    m.top.runTimeMinutes = ticksToMinutes(runTimeTicks)
  else
    m.top.runTimeMinutes = 0
  end if

  setButtonStates()
  populateData()
end sub

sub populateData()
  setVideoLogoGroup()
  setVideoTitle()
  setVideoSubTitle()
end sub

' setButtonStates: Disable previous/next buttons if needed and remove any other unneeded buttons
sub setButtonStates()
  queueCount = m.global.queueManager.callFunc("getCount")
  queueIndex = m.global.queueManager.callFunc("getPosition")
  isTvChannel = m.itemData.type = "TvChannel"

  ' Disable these buttons as needed

  ' Keep prev/next enabled for TvChannel — channel list loads async after init
  if not isTvChannel and (queueCount = 1 or queueIndex = 0)
    itemPrevious = m.buttonMenuLeft.findNode("itemBack")
    itemPrevious.isEnabled = false
  end if
  if not isTvChannel and (queueIndex + 1 >= queueCount)
    itemNext = m.buttonMenuLeft.findNode("itemNext")
    itemNext.isEnabled = false
  end if

  ' Remove these buttons as needed

  ' Video Sources - hide for single source or live TV
  if m.top.numVideoSources < 2 or isTvChannel
    m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("showVideoSourceMenu"))
  end if
  ' Audio Track
  if m.top.numAudioStreams < 2
    m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("showAudioMenu"))
  end if
  ' Subtitles
  if not m.itemData.hasSubtitles
    m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("showSubtitleMenu"))
  end if
  ' Chapters — TvChannel has none
  if not m.top.hasChapters or isTvChannel
    m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("chapterList"))
  end if
  ' Go to Live — detach from layout until the user falls behind the live edge, then
  ' re-insert via attachGoToLiveButton(). Toggling visible/isEnabled isn't enough:
  ' LayoutGroup reserves a slot for invisible children, so a hidden button would
  ' leave a gap in the button row.
  if not isTvChannel
    existing = m.buttonMenuLeft.findNode("goToLive")
    if isValid(existing)
      m.buttonMenuLeft.removeChild(existing)
    end if
    m.goToLiveButton = invalid
    m.goToLiveAttached = false
  else
    if not isValid(m.goToLiveButton)
      m.goToLiveButton = m.buttonMenuLeft.findNode("goToLive")
      if isValid(m.goToLiveButton)
        ' XML attaches the button at load time
        m.goToLiveAttached = true
      end if
    end if
    if isValid(m.goToLiveButton)
      m.goToLiveButton.text = UCase(translate(translationKeys.LabelLive))
      detachGoToLiveButton()
    end if
  end if
end sub

' Insert after itemNext (index 3 in the XML-declared button order) so prev/next stay
' contiguous and the live-edge button appears where channel up/down users look first.
sub attachGoToLiveButton()
  if not isValid(m.goToLiveButton) or m.goToLiveAttached then return
  m.goToLiveButton.visible = true
  m.buttonMenuLeft.insertChild(m.goToLiveButton, 3)
  m.goToLiveAttached = true
end sub

sub detachGoToLiveButton()
  if not isValid(m.goToLiveButton) or not m.goToLiveAttached then return
  m.buttonMenuLeft.removeChild(m.goToLiveButton)
  m.goToLiveAttached = false
end sub

sub setEndsAtText()
  endsAtText = m.top.findNode("endsAtText")

  if m.global.user.settings.uiDesignHideClock
    endsAtText.visible = false
    m.endsAtTime.text = ""
    return
  end if

  ' Live TV: anchor on currentProgram.endDate (broadcast-schedule wall-clock end), then
  ' shift forward by the user's behind-live offset so the displayed "Ends at" reflects
  ' when the user will actually finish watching the program. At live edge behindLive=0
  ' so this collapses to the broadcast end time.
  if isValid(m.itemData) and m.itemData.type = "TvChannel"
    currentProgram = m.itemData.currentProgram
    if isValid(currentProgram) and isValidAndNotEmpty(currentProgram.endDate)
      endDt = CreateObject("roDateTime")
      endDt.FromISO8601String(currentProgram.endDate)
      behindLive = getLiveTvBehindLiveSeconds()
      if behindLive > 0
        endDt.fromSeconds(endDt.asSeconds() + behindLive)
      end if
      endDt.toLocalTime()
      m.endsAtTime.text = formatTime(endDt)
      endsAtText.visible = true
    else
      endsAtText.visible = false
      m.endsAtTime.text = ""
    end if
    return
  end if

  ' Hide when remainingPositionTime isn't meaningful yet (pre-load or zero duration),
  ' otherwise "Ends at" would show the current clock time
  endTime = int(m.top.remainingPositionTime)
  if endTime <= 0
    endsAtText.visible = false
    m.endsAtTime.text = ""
    return
  end if

  date = CreateObject("roDateTime")
  date.fromSeconds(date.asSeconds() + endTime)
  date.toLocalTime()

  m.endsAtTime.text = formatTime(date)
  endsAtText.visible = true
end sub

sub setVideoLogoGroup()
  m.videoLogo.uri = m.top.videoLogo
end sub

sub setVideoTitle()
  item = m.itemData
  ' For TvChannel, display the currently-airing program name rather than the channel name
  if item.type = "TvChannel" and isValid(item.currentProgram) and isValidAndNotEmpty(item.currentProgram.name)
    m.videoTitle.text = item.currentProgram.name
  else
    ' Use preserved original name (m.originalItemName) to avoid alternate version names
    ' that may include year/edition suffixes (e.g., "Dracula (1931) [Colorized]").
    ' Falls back to item.name on first load before the original is cached.
    title = m.originalItemName ?? item.name
    ' Append active video source tag when multiple sources exist (e.g., "[B&W]")
    if isValidAndNotEmpty(m.top.videoSourceTag)
      title += " " + m.top.videoSourceTag
    end if
    m.videoTitle.text = title
  end if
end sub

sub setVideoSubTitle()
  ' start fresh by removing all subtitle nodes
  m.videoSubtitleGroup.removeChildrenIndex(m.videoSubtitleGroup.getChildCount(), 0)
  ' Reset divider id counter so ids stay bounded across program transitions on live TV.
  m.subtitleDividerCount = 0

  isAirDateNodeCreated = false
  item = m.itemData
  itemType = item.type

  ' For TvChannel, ratings and dates are sourced from the currently-airing program when available
  metaItem = item
  if itemType = "TvChannel" and isValid(item.currentProgram)
    metaItem = item.currentProgram
  end if

  ' EPISODE
  if itemType = "Episode" or itemType = "Recording"
    ' Title
    if isValidAndNotEmpty(item.seriesName)
      m.videoTitle.text = item.seriesName
    end if

    ' episodeInfo: "S1E2 - Name". Omit S/E segments entirely when the numbers aren't
    ' known — common for live TV recordings classified as series episodes with no EPG
    ' numbering — so we don't display placeholder strings like "S?E?? - X".
    episodeInfoText = ""
    if item.parentIndexNumber > 0
      episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelS)}${item.parentIndexNumber}`
    end if
    if item.indexNumber > 0
      episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelE)}${item.indexNumber}`
    end if
    ' Episode number end — additional E entries for multi-episode specials (e.g. S6:E1E2)
    if item.indexNumberEnd > 0 and item.indexNumberEnd > item.indexNumber
      for i = item.indexNumber + 1 to item.indexNumberEnd
        episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelE)}${i}`
      end for
    end if
    ' Append episode name when we have S/E context. When we don't, fall back to the bare
    ' name only if it differs from what's already on the title line — otherwise we'd
    ' duplicate the title.
    if isValidAndNotEmpty(item.name)
      if isValidAndNotEmpty(episodeInfoText)
        episodeInfoText = episodeInfoText + ` - ${item.name}`
      else if m.videoTitle.text <> item.name
        episodeInfoText = item.name
      end if
    end if

    if episodeInfoText <> ""
      episodeInfoNode = createSubtitleLabelNode("episodeInfo")
      episodeInfoNode.text = episodeInfoText
      displaySubtitleNode(episodeInfoNode)
    end if
  else if itemType = "Movie"
    ' videoAirDate
    if item.productionYear > 0
      isAirDateNodeCreated = true

      productionYearNode = createSubtitleLabelNode("productionYear")
      productionYearNode.text = item.productionYear.toStr().trim()

      displaySubtitleNode(productionYearNode)
    end if
  else if itemType = "TvChannel"
    ' Display currently-airing program metadata (episode info or movie year)
    currentProgram = item.currentProgram
    if isValid(currentProgram)
      if currentProgram.isSeries
        ' Episode info: S1E2 - Episode Name. Skip entirely when we have no season/episode
        ' numbers — the program name is already shown as the video title and we don't want
        ' to duplicate it (e.g. news bulletins named "INFO" with no S/E numbering).
        episodeInfoText = ""
        if currentProgram.parentIndexNumber > 0
          episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelS)}${currentProgram.parentIndexNumber}`
        end if
        if currentProgram.indexNumber > 0
          episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelE)}${currentProgram.indexNumber}`
        end if
        if isValidAndNotEmpty(episodeInfoText) and isValidAndNotEmpty(currentProgram.name)
          episodeInfoText = episodeInfoText + ` - ${currentProgram.name}`
        end if
        if isValidAndNotEmpty(episodeInfoText)
          episodeInfoNode = createSubtitleLabelNode("episodeInfo")
          episodeInfoNode.text = episodeInfoText
          displaySubtitleNode(episodeInfoNode)
        end if
      else if currentProgram.isMovie and currentProgram.productionYear > 0
        isAirDateNodeCreated = true
        productionYearNode = createSubtitleLabelNode("productionYear")
        productionYearNode.text = currentProgram.productionYear.toStr().trim()
        displaySubtitleNode(productionYearNode)
      end if
    end if
    ' Channel identity (e.g. "CH 4 - NHK World") — mirrors the ItemDetails program info row.
    ' For TvChannel items, item.name is the channel name.
    channelParts = []
    if isValidAndNotEmpty(item.channelNumber)
      channelParts.push(`${translate(translationKeys.LabelCh)} ${item.channelNumber}`)
    end if
    if isValidAndNotEmpty(item.name)
      channelParts.push(item.name)
    end if
    if channelParts.count() > 0
      channelNumberNode = createSubtitleLabelNode("channelNumber")
      channelNumberNode.text = channelParts.join(" - ")
      displaySubtitleNode(channelNumberNode)
    end if
  end if

  ' append these to all video types
  '
  userSettings = m.global.user.settings

  ' Official Rating
  if isValidAndNotEmpty(metaItem.officialRating)
    officialRatingNode = createSubtitleLabelNode("officialRating")
    officialRatingNode.text = metaItem.officialRating
    displaySubtitleNode(officialRatingNode)
  end if

  ' Determine if ratings should be shown based on content type and user settings
  shouldShowRatings = false
  if m.top.isMovie
    ' Movie content - respect uiMoviesShowRatings setting
    shouldShowRatings = userSettings.uiMoviesShowRatings
  else if m.top.isSeries
    ' Series content - respect uiTvShowsDisableCommunityRating setting
    shouldShowRatings = not userSettings.uiTvShowsDisableCommunityRating
  else
    ' Unknown/other content types - show if metadata exists
    shouldShowRatings = true
  end if

  if shouldShowRatings
    ' communityRating (star + rating)
    if metaItem.communityRating <> 0
      communityRatingNode = CreateObject("roSGNode", "CommunityRating")
      communityRatingNode.id = "communityRating"
      communityRatingNode.rating = metaItem.communityRating
      communityRatingNode.iconSize = 30
      displaySubtitleNode(communityRatingNode)
    end if

    ' criticRating (tomato + rating)
    if metaItem.criticRating <> 0
      criticRatingNode = CreateObject("roSGNode", "CriticRating")
      criticRatingNode.id = "criticRating"
      criticRatingNode.rating = metaItem.criticRating
      criticRatingNode.iconSize = 30
      displaySubtitleNode(criticRatingNode)
    end if
  end if

  ' videoAirDate if needed
  if not isAirDateNodeCreated and isValidAndNotEmpty(metaItem.premiereDate)
    premiereDateNode = createSubtitleLabelNode("videoAirDate")
    premiereDateNode.text = formatIsoDateVideo(metaItem.premiereDate)
    displaySubtitleNode(premiereDateNode)
  end if

  ' videoRunTime
  if m.top.runTimeMinutes <> 0
    runTimeNode = createSubtitleLabelNode("videoRunTime")

    if m.top.runTimeMinutes < 2
      runTimeText = `${m.top.runTimeMinutes} ` + translate(translationKeys.LabelMin)
    else
      runTimeText = `${m.top.runTimeMinutes} ` + translate(translationKeys.LabelMins)
    end if

    runTimeNode.text = runTimeText
    displaySubtitleNode(runTimeNode)
  end if

end sub

sub onProgressPercentageChanged()
  if isLiveTvItem()
    updateLiveTvDisplay()
  else
    m.videoPositionTime.text = secondsToTimestamp(m.top.positionTime, true)
    m.videoRemainingTime.text = "-" + secondsToTimestamp(m.top.remainingPositionTime, true)
    m.progressBar.width = m.progressBarBackground.width * m.top.progressPercentage
  end if

  setEndsAtText()
end sub

' Called from onProgressPercentageChanged() (position updates) and inactiveCheck()
' (1s timer). The timer ensures behind-live derived state ticks while paused.
sub updateLiveTvDisplay()
  programStart = parseProgramStartTimeEpoch(m.itemData)
  programEnd = parseProgramEndTimeEpoch(m.itemData)
  now = CreateObject("roDateTime").AsSeconds()
  position = int(m.top.positionTime)

  ' Backstop: positionTime/playbackState observers normally calibrate this earlier,
  ' but cover the path where neither has fired yet (e.g. fallback inactivity tick).
  calibrateLiveTvIfNeeded()
  behindLive = computeLiveTvBehindLive(m.liveTvEpochAtPosition0, m.liveTvCalibrated, position, now, m.liveTvPausedAtEpoch, m.liveTvAccumulatedBehind)
  ' Pause is an explicit user action — flip to "behind live" UI immediately rather than
  ' waiting for behindLive math to grow past the threshold. The threshold itself only
  ' suppresses sub-second rounding flicker during normal play.
  m.liveTvIsAtLiveEdge = LCase(m.top.playbackState) = "playing" and behindLive < LIVE_EDGE_THRESHOLD_SECONDS

  if m.liveTvIsAtLiveEdge
    detachGoToLiveButton()
  else
    attachGoToLiveButton()
  end if

  if programStart > 0 and programEnd > programStart
    programDuration = programEnd - programStart

    if m.liveTvIsAtLiveEdge
      elapsed = now - programStart
      if elapsed < 0 then elapsed = 0
      if elapsed > programDuration then elapsed = programDuration
      programProgress = elapsed / programDuration

      m.videoPositionTime.text = secondsToTimestamp(elapsed, true)
      m.progressBar.width = m.progressBarBackground.width * programProgress
      m.videoRemainingTime.text = UCase(translate(translationKeys.LabelLive))
    else
      userElapsed = (now - behindLive) - programStart
      if userElapsed < 0 then userElapsed = 0
      if userElapsed > programDuration then userElapsed = programDuration
      userProgress = userElapsed / programDuration

      m.videoPositionTime.text = secondsToTimestamp(userElapsed, true)
      m.progressBar.width = m.progressBarBackground.width * userProgress
      m.videoRemainingTime.text = "-" + secondsToTimestamp(behindLive, true)
    end if
  else
    ' Fallback when program timing data is missing
    m.videoPositionTime.text = secondsToTimestamp(m.top.positionTime, true)
    if m.liveTvIsAtLiveEdge
      m.progressBar.width = m.progressBarBackground.width
      m.videoRemainingTime.text = UCase(translate(translationKeys.LabelLive))
    else
      m.progressBar.width = m.progressBarBackground.width * m.top.progressPercentage
      m.videoRemainingTime.text = "-" + secondsToTimestamp(behindLive, true)
    end if
  end if
end sub

sub onPlaybackStateChanged()
  state = LCase(m.top.playbackState)
  if state = "playing"
    m.videoPlayPause.icon = "pkg:/images/icons/pause_$$RES$$.png"
    calibrateLiveTvIfNeeded()
    onLiveTvResumeIfNeeded()
  else
    m.videoPlayPause.icon = "pkg:/images/icons/play_$$RES$$.png"
    if state = "paused" then onLiveTvPauseIfNeeded()
  end if
end sub

' Anchor m.liveTvEpochAtPosition0 on first play tick. Idempotent.
sub calibrateLiveTvIfNeeded()
  if not isLiveTvItem() then return
  if m.liveTvCalibrated then return
  if LCase(m.top.playbackState) <> "playing" then return
  position = int(m.top.positionTime)
  m.liveTvEpochAtPosition0 = CreateObject("roDateTime").AsSeconds() - position
  m.liveTvLastPosition = position
  m.liveTvCalibrated = true
end sub

' Snapshot the current behindLive into accumulated and start the wall-clock pause counter.
' Wall-clock math takes over from here because Roku's m.top.position keeps advancing
' during pause for live HLS DVR streams (it tracks the live edge, not the user's playhead),
' which would silently zero out position-math.
sub onLiveTvPauseIfNeeded()
  if not isLiveTvItem() then return
  if not m.liveTvCalibrated then return
  if m.liveTvPausedAtEpoch > 0 then return
  now = CreateObject("roDateTime").AsSeconds()
  position = int(m.top.positionTime)
  currentBehind = (now - m.liveTvEpochAtPosition0) - position
  if currentBehind < 0 then currentBehind = 0
  m.liveTvAccumulatedBehind = currentBehind
  m.liveTvPausedAtEpoch = now
  m.liveTvLastPosition = position
end sub

' Re-anchor epochAtPosition0 so play-math continues from the same offset where
' wall-clock math left off, then clear pause state.
sub onLiveTvResumeIfNeeded()
  if not isLiveTvItem() then return
  if m.liveTvPausedAtEpoch = 0 then return
  now = CreateObject("roDateTime").AsSeconds()
  position = int(m.top.positionTime)
  pauseDuration = now - m.liveTvPausedAtEpoch
  totalBehind = m.liveTvAccumulatedBehind + pauseDuration
  m.liveTvEpochAtPosition0 = now - position - totalBehind
  m.liveTvPausedAtEpoch = 0
  m.liveTvAccumulatedBehind = 0
  m.liveTvLastPosition = position
end sub

sub onPositionTimeChanged()
  if not isLiveTvItem() then return
  calibrateLiveTvIfNeeded()
  ' Detect user scrubs during pause via large positionTime deltas. Roku's auto-advance
  ' during pause arrives in small per-fire increments (<1s) and is filtered out by the
  ' threshold; user-initiated seeks land as a single large delta.
  if m.liveTvPausedAtEpoch > 0
    newPos = int(m.top.positionTime)
    delta = newPos - m.liveTvLastPosition
    m.liveTvLastPosition = newPos
    if delta < -2 or delta > 2
      ' Repartition the pause-state. Without folding the wall-clock pause counter into
      ' accumulated before applying the scrub, a forward scrub to live edge would zero
      ' accumulated but the pause counter would keep growing — total behindLive wouldn't
      ' actually reach 0.
      now = CreateObject("roDateTime").AsSeconds()
      m.liveTvAccumulatedBehind = repartitionPauseStateOnScrub(m.liveTvAccumulatedBehind, now - m.liveTvPausedAtEpoch, delta)
      m.liveTvPausedAtEpoch = now
    end if
  end if
end sub

sub resetFocusToDefaultButton()
  ' Remove focus from previously selected button
  for each child in m.buttonMenuLeft.getChildren(-1, 0)
    if isValid(child)
      child.setFocus(false)
    end if
  end for

  for each child in m.buttonMenuRight.getChildren(-1, 0)
    if isValid(child)
      child.setFocus(false)
    end if
  end for

  ' Set focus back to the default button
  m.buttonMenuLeft.setFocus(true)
  m.focusedButtonIndex = m.defaultButtonIndex
  m.buttonMenuLeft.getChild(m.defaultButtonIndex).setFocus(true)
  m.buttonMenuLeft.buttonFocused = m.defaultButtonIndex
end sub

sub onVisibleChanged()
  if m.top.visible
    resetFocusToDefaultButton()

    ' Timer runs even while paused so the live TV behind-live counter keeps ticking
    m.inactivityTimer.observeField("fire", "inactiveCheck")
    m.inactivityTimer.control = "start"
  else
    m.inactivityTimer.control = "stop"
    m.inactivityTimer.unobserveField("fire")
  end if
end sub

sub onFocusChanged()
  if m.top.hasfocus
    m.buttonMenuLeft.setFocus(true)
  end if
end sub

' 1s timer: auto-hide on inactivity AND drive the behind-live countdown while paused
sub inactiveCheck()
  if m.global.sceneManager.callFunc("isDialogOpen")
    return
  end if

  ' onProgressPercentageChanged doesn't fire while paused, so update from the timer instead
  if isLiveTvItem()
    updateLiveTvDisplay()
    setEndsAtText()
  end if

  deviceInfo = CreateObject("roDeviceInfo")
  if deviceInfo.timeSinceLastKeypress() >= m.top.inactiveTimeout
    if LCase(m.top.playbackState) = "paused"
      return
    end if
    m.top.action = "hide"
  end if
end sub

sub onButtonSelected()
  if m.buttonMenuLeft.isInFocusChain()
    selectedButton = m.buttonMenuLeft.getChild(m.buttonMenuLeft.buttonFocused)
  else if m.buttonMenuRight.isInFocusChain()
    selectedButton = m.buttonMenuRight.getChild(m.buttonMenuRight.buttonFocused)
  else
    return
  end if

  if LCase(selectedButton.id) = "chapterlist"
    m.top.shouldShowChapterList = not m.top.shouldShowChapterList
  end if

  if LCase(selectedButton.id) = "gotolive"
    ' VideoPlayerView seeks to LIVE_EDGE_SEEK; clear all behind-live state so the next
    ' position tick re-anchors epochAtPosition0 to the new live-edge position.
    m.liveTvCalibrated = false
    m.liveTvEpochAtPosition0 = 0
    m.liveTvPausedAtEpoch = 0
    m.liveTvAccumulatedBehind = 0
    m.liveTvLastPosition = 0
    m.liveTvIsAtLiveEdge = true
  end if

  m.top.action = selectedButton.id
end sub

function createSubtitleLabelNode(labelId as string) as object
  labelNode = CreateObject("roSGNode", "LabelPrimaryMedium")
  labelNode.id = labelId
  labelNode.horizAlign = "left"
  labelNode.vertAlign = "center"
  labelNode.width = 0
  labelNode.height = 0
  labelNode.isBold = true

  return labelNode
end function

function createSubtitleDividerNode() as object
  m.subtitleDividerCount++

  labelNode = CreateObject("roSGNode", "LabelPrimarySmall")
  labelNode.id = "divider" + m.subtitleDividerCount.toStr()
  labelNode.horizAlign = "left"
  labelNode.vertAlign = "center"
  labelNode.width = 0
  labelNode.height = 40
  labelNode.text = "•"
  labelNode.isBold = true

  return labelNode
end function

sub displaySubtitleNode(node as object)
  if not isValid(node) then return

  subtitleChildrenCount = m.videoSubtitleGroup.getChildCount()
  if subtitleChildrenCount > 0
    ' add a divider
    dividerNode = createSubtitleDividerNode()
    m.videoSubtitleGroup.appendChild(dividerNode)
  end if

  m.videoSubtitleGroup.appendChild(node)
end sub

sub onScreenShown()
  if m.isFirstRun
    m.isFirstRun = false
  else
    m.clock.callFunc("resetTime")
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  if key = "play"
    m.top.action = "videoplaypause"
    return true
  end if

  if key = "OK"
    onButtonSelected()
    return true
  end if

  if key = "back" and m.top.visible
    m.top.action = "hide"

    return true
  end if

  if (key = "rewind" or key = "fastforward") and m.top.visible
    m.top.action = "hide"

    return false
  end if

  return false
end function

function isLiveTvItem() as boolean
  return isValid(m.itemData) and m.itemData.type = "TvChannel"
end function

function getLiveTvBehindLiveSeconds() as integer
  return computeLiveTvBehindLive(m.liveTvEpochAtPosition0, m.liveTvCalibrated, int(m.top.positionTime), CreateObject("roDateTime").AsSeconds(), m.liveTvPausedAtEpoch, m.liveTvAccumulatedBehind)
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called by VideoPlayerView.onDestroy() since OSD is a child component, not a SceneManager scene
sub onDestroy()
  m.log.verbose("onDestroy")

  ' Unobserve all m.top observers
  m.top.unobserveField("itemData")
  m.top.unobserveField("visible")
  m.top.unobserveField("hasFocus")
  m.top.unobserveField("progressPercentage")
  m.top.unobserveField("playbackState")
  m.top.unobserveField("positionTime")

  ' Unobserve clock (guarded — may be invalid if clock node not found in init)
  if isValid(m.clock)
    m.clock.unobserveField("minutes")
    m.clock = invalid
  end if

  ' Stop inactivity timer (may already be stopped by onVisibleChanged)
  m.inactivityTimer.unobserveField("fire")
  m.inactivityTimer.control = "stop"
  m.inactivityTimer = invalid

  ' Clear node references
  m.endsAtTime = invalid
  m.videoLogo = invalid
  m.videoTitle = invalid
  m.videoSubtitleGroup = invalid
  m.videoPlayPause = invalid
  m.videoPositionTime = invalid
  m.videoRemainingTime = invalid
  m.progressBar = invalid
  m.progressBarBackground = invalid
  m.buttonMenuRight = invalid
  m.buttonMenuLeft = invalid

  ' Release cached item data reference
  m.itemData = invalid
end sub