components_video_OSD.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"

sub init()
  m.log = log.Logger("OSD")
  m.inactivityTimer = m.top.findNode("inactivityTimer")
  m.endsAtTime = m.top.findNode("endsAtTime")
  m.videoLogo = m.top.findNode("videoLogo")
  m.videoTitle = m.top.findNode("videoTitle")
  m.videoSubtitleGroup = m.top.findNode("videoSubtitleGroup")
  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")

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

  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

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

  itemType = item.type

  ' 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

  ' 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")

  ' Disable these buttons as needed

  ' Item Previous
  if queueCount = 1 or queueIndex = 0
    itemPrevious = m.buttonMenuLeft.findNode("itemBack")
    itemPrevious.enabled = false
  end if
  ' Item Next
  if queueIndex + 1 >= queueCount
    itemNext = m.buttonMenuLeft.findNode("itemNext")
    itemNext.enabled = false
  end if

  ' Remove these buttons as needed

  ' 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
  if not m.top.hasChapters
    m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("chapterList"))
  end if
end sub

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

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

  ' For live TV channels, use the currently-airing program's EndDate for end time.
  ' remainingPositionTime ≈ 0 for live streams so the default calculation would show current 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)
      endDt.toLocalTime()
      m.endsAtTime.text = formatTime(endDt)
      endsAtText.visible = true
    else
      endsAtText.visible = false
      m.endsAtTime.text = ""
    end if
    return
  end if

  ' Calculate endsAtTime based on remainingPositionTime
  date = CreateObject("roDateTime")
  endTime = int(m.top.remainingPositionTime)
  date.fromSeconds(date.asSeconds() + endTime)
  date.toLocalTime()

  m.endsAtTime.text = formatTime(date)
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
    m.videoTitle.text = item.name
  end if
end sub

sub setVideoSubTitle()
  ' start fresh by removing all subtitle nodes
  m.videoSubtitleGroup.removeChildrenIndex(m.videoSubtitleGroup.getChildCount(), 0)

  airDateNodeCreated = 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
    episodeInfoText = ""
    '
    ' Season number
    if item.parentIndexNumber > 0
      episodeInfoText = episodeInfoText + `${tr("S")}${item.parentIndexNumber}`
    else
      episodeInfoText = episodeInfoText + `${tr("S")}?`
    end if
    ' Episode number
    if item.indexNumber > 0
      episodeInfoText = episodeInfoText + `${tr("E")}${item.indexNumber}`
    else
      episodeInfoText = episodeInfoText + `${tr("E")}??`
    end if
    ' Episode number end
    if item.indexNumberEnd > 0 and item.indexNumberEnd > item.indexNumber
      ' add entry for every episode eg. S6:E1E2
      for i = item.indexNumber + 1 to item.indexNumberEnd
        episodeInfoText = episodeInfoText + `${tr("E")}${item.indexNumberEnd}`
      end for
    end if
    ' Episode name
    if isValidAndNotEmpty(item.name)
      episodeInfoText = episodeInfoText + ` - ${item.name}`
    end if

    if episodeInfoText <> ""
      episodeInfoNode = createSubtitleLabelNode("episodeInfo")
      episodeInfoNode.text = episodeInfoText
      displaySubtitleNode(episodeInfoNode)
    end if
  else if itemType = "Movie"
    ' videoAirDate
    if item.productionYear > 0
      airDateNodeCreated = 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
        episodeInfoText = ""
        if currentProgram.parentIndexNumber > 0
          episodeInfoText = episodeInfoText + `${tr("S")}${currentProgram.parentIndexNumber}`
        end if
        if currentProgram.indexNumber > 0
          episodeInfoText = episodeInfoText + `${tr("E")}${currentProgram.indexNumber}`
        end if
        if isValidAndNotEmpty(currentProgram.name)
          if isValidAndNotEmpty(episodeInfoText)
            episodeInfoText = episodeInfoText + ` - ${currentProgram.name}`
          else
            episodeInfoText = currentProgram.name
          end if
        end if
        if isValidAndNotEmpty(episodeInfoText)
          episodeInfoNode = createSubtitleLabelNode("episodeInfo")
          episodeInfoNode.text = episodeInfoText
          displaySubtitleNode(episodeInfoNode)
        end if
      else if currentProgram.isMovie and currentProgram.productionYear > 0
        airDateNodeCreated = true
        productionYearNode = createSubtitleLabelNode("productionYear")
        productionYearNode.text = currentProgram.productionYear.toStr().trim()
        displaySubtitleNode(productionYearNode)
      end if
    end if
    ' Channel number (e.g. "CH 4") — always shown when available
    if isValidAndNotEmpty(item.channelNumber)
      channelNumberNode = createSubtitleLabelNode("channelNumber")
      channelNumberNode.text = `${tr("CH")} ${item.channelNumber}`
      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
  showRatings = false
  if m.top.isMovie
    ' Movie content - respect uiMoviesShowRatings setting
    showRatings = userSettings.uiMoviesShowRatings
  else if m.top.isSeries
    ' Series content - respect uiTvShowsDisableCommunityRating setting
    showRatings = not userSettings.uiTvShowsDisableCommunityRating
  else
    ' Unknown/other content types - show if metadata exists
    showRatings = true
  end if

  if showRatings
    ' 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 airDateNodeCreated 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} ` + tr("min")
    else
      runTimeText = `${m.top.runTimeMinutes} ` + tr("mins")
    end if

    runTimeNode.text = runTimeText
    displaySubtitleNode(runTimeNode)
  end if

end sub

sub onProgressPercentageChanged()
  ' change progress bar for live tv
  itemType = ""
  if isValid(m.itemData)
    itemType = m.itemData.type
  end if

  if itemType = "TvChannel"
    m.videoPositionTime.text = secondsToTimestamp(m.top.positionTime, true)
    m.videoRemainingTime.text = tr("LIVE")
    m.progressBar.width = m.progressBarBackground.width ' set to full width
  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

sub onPlaybackStateChanged()
  if LCase(m.top.playbackState) = "playing"
    m.videoPlayPause.icon = "pkg:/images/icons/pause.png"
    return
  end if

  m.videoPlayPause.icon = "pkg:/images/icons/play.png"
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()

    if m.top.playbackState <> "paused"
      m.inactivityTimer.observeField("fire", "inactiveCheck")
      m.inactivityTimer.control = "start"
    end if
  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

' inactiveCheck: Checks if the time since last keypress is greater than or equal to the allowed inactive time of the menu.
sub inactiveCheck()
  ' If user is currently seeing a dialog box, ignore inactive check
  if m.global.sceneManager.callFunc("isDialogOpen")
    return
  end if

  deviceInfo = CreateObject("roDeviceInfo")
  if deviceInfo.timeSinceLastKeypress() >= m.top.inactiveTimeout
    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.showChapterList = not m.top.showChapterList
  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.bold = 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.bold = 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

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

  ' 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")

  ' 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