components_ui_rowitem_JRRowItem.bs

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

' Vertical gap between the bottom of the poster (= bottom of the RowList focus ring)
' and the first text label. Prevents the focus border from overlapping the title.
const FOCUS_PADDING = 18

sub init()
  m.log = log.Logger("JRRowItem")
  m.backdrop = m.top.findNode("backdrop")
  m.poster = m.top.findNode("poster")
  m.itemIcon = m.top.findNode("itemIcon")
  m.title = m.top.findNode("title")
  m.staticTitle = m.top.findNode("staticTitle")
  m.subtitle = m.top.findNode("subtitle")

  m.isLibraryTile = false

  m.poster.observeField("loadStatus", "onPosterLoadStatusChanged")
  m.itemIcon.observeField("loadStatus", "onIconLoadStatusChanged")
end sub

sub onItemContentChanged()
  if not isValid(m.top.itemContent) then return
  if m.top.width <= 0 or m.top.height <= 0 then return
  renderItem()
end sub

sub onSizeChanged()
  if not isValid(m.top.itemContent) then return
  if m.top.width <= 0 or m.top.height <= 0 then return
  renderItem()
end sub

sub renderItem()
  item = m.top.itemContent
  if not isValid(item) then return

  m.poster.callFunc("resetBadge")

  slotWidth = m.top.width
  posterHeight = int(m.top.height)

  updateLayout(slotWidth, posterHeight)

  m.isLibraryTile = (item.type = "CollectionFolder" or item.type = "UserView" or item.type = "Channel")

  if m.isLibraryTile
    renderLibraryTile(item, slotWidth, posterHeight)
  else
    renderStandardItem(item, slotWidth, posterHeight)
  end if
end sub

' Renders a My Media library tile: colored backdrop + type icon + library name.
' The poster loads the library's Primary image; when it loads successfully the
' backdrop (and overlaid text) hide to reveal the image.
sub renderLibraryTile(item as object, slotWidth as float, posterHeight as float)
  m.backdrop.visible = true
  m.title.visible = false
  m.staticTitle.visible = false
  m.subtitle.visible = false

  ' Lazy-create the backdrop label the first time a library tile is rendered
  initBackdropText(slotWidth, posterHeight)
  backdropText = m.top.findNode("backdropText")
  if isValid(backdropText)
    backdropText.text = item.name
    backdropText.visible = true
  end if

  ' Icon based on collection type (music note, TV icon, etc.)
  ' Explicitly manage visibility so a stale icon from a previously-rendered tile
  ' in the same row doesn't persist when this cell is recycled.
  iconPath = getLibraryIconPath(item.collectionType)
  if iconPath <> ""
    m.itemIcon.uri = iconPath
    ' onIconLoadStatusChanged shows it once the image loads
  else
    m.itemIcon.visible = false
    m.itemIcon.uri = ""
  end if

  m.poster.uri = getRowItemImageUrl(item, int(slotWidth), int(posterHeight), invalid)
end sub

' Renders a standard item: poster image with title/subtitle text below.
sub renderStandardItem(item as object, slotWidth as float, posterHeight as float)
  globalUser = m.global.user
  userSettings = globalUser.settings

  ' Text — static title shown by default, scrolling title shown on focus
  m.staticTitle.text = getRowItemTitle(item)
  m.title.text = m.staticTitle.text
  m.subtitle.text = getRowItemSubtitle(item)

  m.staticTitle.visible = true
  m.title.visible = false
  m.subtitle.visible = true

  ' Hide any backdropText left over from a previous library tile render on this recycled instance.
  ' onPosterLoadStatusChanged is also gated, but this closes the window between render and first callback.
  backdropText = m.top.findNode("backdropText")
  if isValid(backdropText)
    backdropText.visible = false
  end if

  ' Backdrop shows while poster is loading, hidden once image is ready
  m.backdrop.visible = true

  ' Pre-compute webclient episode image flag to keep the utility function pure
  useEpisodeImages = false
  if isValid(globalUser.config)
    useEpisodeImages = (globalUser.config.useEpisodeImagesInNextUpAndResume = true)
  end if

  posterUri = getRowItemImageUrl(item, int(slotWidth), int(posterHeight), userSettings, useEpisodeImages, m.top.applyEpisodeImageSetting)

  ' Blur unwatched episodes when the user setting is enabled.
  ' Guard posterUri <> "" to avoid appending to an empty string, which would produce
  ' the invalid URI "&blur=15" and trigger a bogus network request.
  if isValid(userSettings) and userSettings.uiTvShowsBlurUnwatched and item.type = "Episode" and not item.isWatched and posterUri <> ""
    posterUri = posterUri + "&blur=15"
  end if

  ' TvChannel logos are stored as Primary images and may be any aspect ratio —
  ' use scaleToFit (letterbox) to show the full logo without cropping.
  ' All other types use scaleToZoom (fill) since their images match the slot shape.
  m.poster.loadDisplayMode = (item.type = "TvChannel") ? "scaleToFit" : "scaleToZoom"

  m.poster.uri = posterUri

  ' Watch badges
  if item.isWatched
    m.poster.isWatched = true
  else if item.type = "Series" and item.unplayedItemCount > 0
    m.poster.unplayedCount = item.unplayedItemCount
  end if

  m.poster.playedPercentage = item.playedPercentage
end sub

' Updates positions and sizes of all child nodes to match the current slot dimensions.
' Called at the start of every renderItem() before branching into tile/standard mode.
sub updateLayout(slotWidth as float, posterHeight as float)
  m.backdrop.width = slotWidth
  m.backdrop.height = posterHeight

  m.poster.width = slotWidth
  m.poster.height = posterHeight
  m.poster.loadWidth = int(slotWidth)
  m.poster.loadHeight = int(posterHeight)

  ' Text sits below the poster, outside the slot bounds (which = the focus ring).
  ' FOCUS_PADDING clears the focus border so it doesn't overlap the title.
  titleY = posterHeight + FOCUS_PADDING

  ' title and staticTitle overlap at the same position; only one is visible at a time.
  ' Height is intentionally tighter than a standard line height to reduce dead space
  ' between the title and subtitle.
  m.title.translation = [0, titleY]
  m.title.maxWidth = slotWidth
  m.title.height = 0

  m.staticTitle.translation = [0, titleY]
  m.staticTitle.width = slotWidth
  m.staticTitle.height = 0

  ' Subtitle sits directly below title
  m.subtitle.translation = [0, titleY + 30]
  m.subtitle.maxWidth = slotWidth
  m.subtitle.height = 0
end sub

' Lazy-creates the backdrop text label for library tiles on first use.
' Deferred allocation avoids creating the node for non-library rows.
sub initBackdropText(slotWidth as float, posterHeight as float)
  if isValid(m.top.findNode("backdropText")) then return

  backdropText = CreateObject("roSGNode", "LabelPrimaryLarger")
  backdropText.id = "backdropText"
  backdropText.bold = true
  backdropText.width = slotWidth - 18
  backdropText.height = posterHeight - 18
  backdropText.translation = [9, 9]
  backdropText.horizAlign = "center"
  backdropText.vertAlign = "center"
  backdropText.ellipsizeOnBoundary = true
  backdropText.wrap = true

  m.top.appendChild(backdropText)
end sub

' Maps a Jellyfin library collection type to a local icon image path.
' Used to display a type-appropriate icon on library tile backdrops.
' @param collectionType - item.collectionType string (e.g., "music", "livetv")
' @returns pkg:/ path to the icon image
function getLibraryIconPath(collectionType as string) as string
  collectionTypeLower = LCase(collectionType)
  if collectionTypeLower = "livetv"
    return "pkg:/images/media_type_icons/live_tv_white.png"
  end if
  ' No icon for this type — return "" so the icon Poster stays hidden
  ' and backdropText remains vertically centered (standard library tile appearance).
  return ""
end function

' Hides the backdrop (and any backdropText) once the poster image loads.
' Shows the backdrop as a fallback when the image is unavailable or still loading.
' backdropText visibility is only toggled for library tiles — standard items must not
' inherit a stale label when this component instance is recycled from a library row.
sub onPosterLoadStatusChanged()
  if m.poster.loadStatus = "ready" and m.poster.uri <> ""
    m.backdrop.visible = false
    if m.isLibraryTile
      backdropText = m.top.findNode("backdropText")
      if isValid(backdropText)
        backdropText.visible = false
      end if
    end if
  else
    m.backdrop.visible = true
    if m.isLibraryTile
      backdropText = m.top.findNode("backdropText")
      if isValid(backdropText)
        backdropText.visible = true
      end if
    end if
  end if
end sub

' Shows the icon and repositions it with the backdrop text once the icon image loads.
' Hides the icon on failure so a stale image from a previous render does not persist.
sub onIconLoadStatusChanged()
  if m.itemIcon.loadStatus = "ready" and m.itemIcon.uri <> ""
    m.itemIcon.visible = true
    arrangeIconAndBackdropText()
  else
    m.itemIcon.visible = false
  end if
end sub

' Centers the icon in the upper portion of the backdrop and positions the library
' name label below it.
sub arrangeIconAndBackdropText()
  backdropText = m.top.findNode("backdropText")
  if not isValid(backdropText) then return

  posterHeight = int(m.top.height)
  slotWidth = m.top.width

  m.itemIcon.translation = [
    (slotWidth - m.itemIcon.width) / 2,
    ((posterHeight - m.itemIcon.height) / 2) / 2
  ]

  backdropText.height = 0
  backdropText.translation = [
    backdropText.translation[0],
    ((posterHeight - m.itemIcon.height) / 2) + m.itemIcon.height
  ]
end sub

' Toggles scrolling vs static title on focus, and speaks the title via audio guide.
sub onFocusChanged()
  ' Library tiles have no scrolling text labels to toggle
  if m.isLibraryTile then return

  if m.top.itemHasFocus
    m.title.repeatCount = -1
    m.subtitle.repeatCount = -1
    m.staticTitle.visible = false
    m.title.visible = true
  else
    m.title.repeatCount = 0
    m.subtitle.repeatCount = 0
    m.staticTitle.visible = true
    m.title.visible = false
  end if

  if m.global.device.isAudioGuideEnabled
    txt2Speech = CreateObject("roTextToSpeech")
    txt2Speech.Flush()
    txt2Speech.Say(m.staticTitle.text)
  end if
end sub