components_subtitles_SubtitleResultRow.bs

' SubtitleResultRow: one remote subtitle search result inside SubtitlePanel.
'
' See SubtitleResultRow.xml for the layout rationale and the measurements the
' row shape is derived from. This file owns the content -> visuals mapping and
' the focus chrome; it holds no API or selection logic.
import "pkg:/source/constants/subtitleLayout.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"


' Badge icons render at the badge line's cap height so they optically match the
' label beside them rather than towering over it.
const BADGE_ICON_SIZE = 22

' Which icon a badge draws. A slot can show either, so both Posters exist in
' every slot with their uri assigned ONCE — see buildBadgePool.
const BADGE_ICON_NONE = ""
const BADGE_ICON_STAR = "star"
const BADGE_ICON_CC = "cc"

' Badge slots pre-allocated per row, sized to the most badges a single
' row of this type can carry so the pool never grows mid-scroll.
const BADGE_POOL_SIZE = 4

sub init()
  m.badgeRow = m.top.findNode("badgeRow")
  m.nameLabel = m.top.findNode("nameLabel")
  m.metaLabel = m.top.findNode("metaLabel")

  m.nameLabel.color = m.global.constants.colorTextPrimary
  m.nameLabel.repeatCount = 0

  ' Badges this row's content asks for, each { text, color, iconKind, width },
  ' and the width of the ones actually shown.
  m.wantedBadges = []
  m.badgesWidth = 0

  m.badgeRow.itemSpacings = [subtitleLayout.BADGE_SPACING]

  ' Measures badge text with the badge labels' own font. Never drawn; kept out of
  ' badgeRow so it takes no slot in the layout.
  m.badgeMeasure = m.top.createChild("LabelSecondarySmallest")
  m.badgeMeasure.visible = false

  buildBadgePool()
  onSizeChanged()
end sub

' buildBadgePool: Create the badge nodes ONCE.
'
' MarkupList recycles a small set of row components as the list scrolls, writing
' a new itemContent into each. The first draft tore down and rebuilt every badge
' child on each of those writes, so a single scroll step cost a burst of node
' create/destroy per visible row -- which is what made scrolling feel heavy.
' Nodes are now created once; only their text, colour and visibility change.
sub buildBadgePool()
  m.badges = []

  for i = 0 to BADGE_POOL_SIZE - 1
    badge = m.badgeRow.createChild("LayoutGroup")
    ' Named so the slot is addressable from RTA/odc. Pooled nodes are otherwise
    ' anonymous, which makes a layout question like "did this badge move after the
    ' row was populated?" unanswerable without a screenshot.
    badge.id = "resultBadgeSlot" + i.toStr()
    badge.layoutDirection = "horiz"
    badge.vertAlignment = "center"
    badge.visible = false

    ' ONE holder, TWO overlapping Posters, and a uri that is never reassigned.
    '
    ' A slot shows whichever icon its badge needs, so the free-pool version just
    ' wrote the uri it wanted onto the slot's single Poster. That churned: a
    ' result WITH a perfect match put the star in slot 0 and one without put the
    ' CC icon there, so every recycle that changed which kind came first rebound
    ' slot 0's texture — and a Poster draws nothing until the new bitmap is
    ' bound, which is the icon visibly popping in mid-scroll even though the
    ' asset was already in Roku's texture cache. Keeping both Posters resident
    ' and switching which one is SHOWN costs two nodes and rebinds nothing.
    '
    ' They overlap inside a holder rather than sitting side by side because
    ' LayoutGroup would otherwise lay them out in sequence.
    holder = badge.createChild("Group")
    icons = {}
    icons[BADGE_ICON_STAR] = newBadgeIcon(holder, "pkg:/images/icons/star_$$RES$$.png")
    icons[BADGE_ICON_CC] = newBadgeIcon(holder, "pkg:/images/icons/closedCaptions_$$RES$$.png")

    label = badge.createChild("LabelSecondarySmallest")

    m.badges.push({ group: badge, holder: holder, icons: icons, label: label })
  end for
end sub

' newBadgeIcon: one resident, pre-bound badge Poster, collapsed until used.
'
' Starts at zero SIZE, not merely hidden, and that distinction is load-bearing:
' measured on device, an invisible LayoutGroup child still occupies its slot in
' the layout — hiding a 22px icon left a 22px hole plus its spacing, which is
' how a badge with no icon ends up indented from one that has it. Width is
' therefore what this row toggles; `visible` just follows it.
function newBadgeIcon(holder as object, uri as string) as object
  icon = holder.createChild("Poster")
  ' Downscale into TEXTURE MEMORY, not just on the way to the screen.
  '
  ' All THREE fields are required and none is optional: loadWidth/loadHeight
  ' state the cap, and loadDisplayMode is what makes the cap apply at load time
  ' -- the Poster docs are explicit that the scaling capability is used "by
  ' setting the loadWidth and loadHeight field values" and then "select the
  ' scaling option you want as the value of the loadDisplayMode field", whose
  ' default noScale loads "at the image's original resolution". Set the first
  ' two alone and nothing happens at all.
  '
  ' It matters here because the badge assets are not uniform:
  ' closedCaptions_fhd.png is 64x64 and star_fhd.png is 36x36, both drawn at
  ' 22px, so the CC icon was holding ~3.2x the texture of the star for the same
  ' pixels. limitSize (the mode CommunityRating already uses for that same star)
  ' preserves aspect ratio and leaves an already-small image alone.
  icon.loadWidth = BADGE_ICON_SIZE
  icon.loadHeight = BADGE_ICON_SIZE
  icon.loadDisplayMode = "limitSize"
  ' Load the bitmap SYNCHRONOUSLY, and set this BEFORE uri so it governs the load
  ' that assigning uri kicks off.
  '
  ' This is the pop-in, and it is documented rather than inferred. Poster.loadSync
  ' defaults to FALSE, which the Roku docs define as "loaded asynchronously in a
  ' background thread, and may not appear immediately"; with true "and the uri
  ' field specifies a local file (in the pkg:/images directory), the image is
  ' loaded synchronously, and appears immediately". Every icon here is a local
  ' pkg:/images file, so the async default was buying nothing and costing a frame.
  '
  ' It also explains the SHIFT that came with it, which is the more telling half:
  ' until the bitmap arrives the Poster has no content to contribute, so the
  ' LayoutGroup lays the row out as though the icon were not there and re-flows
  ' when it lands — measured as a badge after it jumping right by ~28px, which is
  ' exactly this icon (22) plus its gutter (6). A label measuring late would have
  ' moved it by a label's width; 28px names the icon specifically.
  '
  ' The docs' caveat ("might cause brief glitches in the rendering while the image
  ' is being fetched and loaded") is about paying the fetch on the render thread.
  ' That is the right trade for a handful of tiny local PNGs already in the
  ' package, and the load happens once per uri at row construction rather than
  ' per scroll — NOT the trade to copy for a remote poster.
  icon.loadSync = true
  ' Assigned once, here, for the row component's whole life.
  icon.uri = uri
  icon.width = 0
  icon.height = BADGE_ICON_SIZE
  icon.visible = false
  return icon
end function

sub onItemContentChanged()
  content = m.top.itemContent
  if not isValid(content) then return

  if isValid(content.rowWidth) and content.rowWidth > 0 then m.top.rowWidth = content.rowWidth

  m.nameLabel.text = titleFor(content)
  m.metaLabel.text = metaLineFor(content)

  m.wantedBadges = badgesFor(content)
  onSizeChanged()
end sub

' titleFor: The release name, which is what a user actually matches against
' their file. Falls back to the language when a provider returns no Name.
function titleFor(content as object) as string
  if isValidAndNotEmpty(content.name) then return content.name
  if isValidAndNotEmpty(content.threeLetterIsoLanguageName) then return content.threeLetterIsoLanguageName
  return translate(translationKeys.LabelSubtitles)
end function

' metaLineFor: "561,277 downloads . pirosasz . 2012", dropping any segment whose
' value is missing rather than rendering an empty gap.
'
' Provider and format join the line only when the panel flagged them as varying
' across the result set.
function metaLineFor(content as object) as string
  parts = []

  downloads = 0
  if isValid(content.downloadCount) then downloads = content.downloadCount
  parts.push(translatePlural(translationKeys.LabelDownloadsCount, downloads, [groupThousands(downloads)]))

  if isValidAndNotEmpty(content.author) then parts.push(content.author)

  year = yearFrom(content.dateCreated)
  if year <> "" then parts.push(year)

  if content.showFormat = true and isValidAndNotEmpty(content.format) then parts.push(UCase(content.format))
  if content.showProvider = true and isValidAndNotEmpty(content.providerName) then parts.push(content.providerName)

  line = ""
  for each part in parts
    if line <> "" then line = line + "  ·  "
    line = line + part
  end for
  return line
end function

' yearFrom: The year out of an ISO-8601 timestamp, or "" when absent/malformed.
' Upload year is a useful staleness cue (sampled results ranged 2012..2015) and
' is cheaper to read at a glance than a full date.
function yearFrom(dateCreated as dynamic) as string
  if not isValidAndNotEmpty(dateCreated) then return ""
  if Len(dateCreated) < 4 then return ""

  year = Left(dateCreated, 4)
  if year.toInt() <= 0 then return ""
  return year
end function

' groupThousands: 561277 -> "561,277". Download counts span four orders of
' magnitude in real result sets, and unpunctuated six-digit numbers are hard to
' size up at 10 feet.
function groupThousands(value as integer) as string
  digits = stri(value).trim()
  if Len(digits) <= 3 then return digits

  grouped = ""
  count = 0
  for i = Len(digits) to 1 step -1
    grouped = Mid(digits, i, 1) + grouped
    count++
    if count = 3 and i > 1
      grouped = "," + grouped
      count = 0
    end if
  end for
  return grouped
end function

' badgesFor: The flags that sit at the right of the meta line, most significant
' first.
'
' Order is significance order, and that is load-bearing rather than cosmetic:
' when a row cannot fit every badge, the ones dropped are the last in this list
' (see placeBadges).
'
' Only truths worth acting on get a badge. "Perfect match" leads because a
' hash-matched subtitle is the one signal that reliably predicts sync; the
' translation/impairment flags follow because they change whether the file is
' the right KIND of subtitle, not just whether it fits.
'
' Each badge is an icon plus its label. The icons carry the meaning at a glance
' and the words disambiguate them, which matters because "perfect match" is not
' self-evident from a star alone. Colour does the ranking: the star takes
' colorPrimary because it is the signal worth acting on, everything else takes
' colorTextSecondary so it reads as a qualifier rather than a recommendation.
function badgesFor(content as object) as object
  wanted = []
  constants = m.global.constants

  if content.isHashMatch = true
    ' colorSecondary, not colorPrimary. The theme encodes interaction state: primary
    ' is for what you can focus or act on (it is the focus ring on this very row),
    ' secondary is for non-focusable emphasis. A perfect-match mark is emphasis on a
    ' property of the result, not something you can move focus to — and tinting it
    ' primary put the same colour on the badge and the focus ring in one frame.
    wanted.push(newBadge(translate(translationKeys.LabelPerfectMatch), constants.colorSecondary, BADGE_ICON_STAR))
  end if
  if content.hearingImpaired = true
    ' Closed-captioning mark for hearing-impaired subtitles: broadcast CC has
    ' always meant SDH, so it is the icon viewers already know for this.
    wanted.push(newBadge(translate(translationKeys.LabelHearingImpaired), constants.colorTextSecondary, BADGE_ICON_CC))
  end if
  if content.forced = true
    wanted.push(newBadge(translate(translationKeys.LabelForcedSubtitle), constants.colorTextSecondary, BADGE_ICON_NONE))
  end if
  if content.aiTranslated = true
    wanted.push(newBadge(translate(translationKeys.LabelAiTranslated), constants.colorTextSecondary, BADGE_ICON_NONE))
  else if content.machineTranslated = true
    wanted.push(newBadge(translate(translationKeys.LabelMachineTranslated), constants.colorTextSecondary, BADGE_ICON_NONE))
  end if

  return wanted
end function

' newBadge: A wanted badge with its rendered width, measured once per content.
function newBadge(text as string, color as string, iconKind as string) as object
  m.badgeMeasure.text = text
  iconWidth = 0
  if iconKind <> BADGE_ICON_NONE then iconWidth = BADGE_ICON_SIZE
  width = subtitleLayout.badgeWidth(iconWidth, m.badgeMeasure.localBoundingRect().width)
  return { text: text, color: color, iconKind: iconKind, width: width }
end function

' placeBadges: Show as many wanted badges as the row allows, right-aligned.
'
' Filled into the LAST slots of the pool, in order, and the unused slots are
' hidden. badgeRow is right-aligned, so the last slot sits on the row's right
' edge; a hidden slot still takes its place in a LayoutGroup, so leaving the
' unused ones at the FRONT keeps them out of the way instead of pushing the
' visible badges in from the edge.
sub placeBadges()
  widths = []
  for each badge in m.wantedBadges
    widths.push(badge.width)
  end for

  count = subtitleLayout.badgesThatFit(widths, subtitleLayout.maxBadgeRunWidth(m.top.rowWidth))
  if count > m.badges.count() then count = m.badges.count()
  m.badgesWidth = subtitleLayout.badgeRunWidth(widths, count)

  first = m.badges.count() - count
  for i = 0 to m.badges.count() - 1
    if i < first
      m.badges[i].group.visible = false
    else
      fillBadgeSlot(m.badges[i], m.wantedBadges[i - first])
    end if
  end for
end sub

' fillBadgeSlot: Draw one badge in a slot. Chooses WHICH resident icon shows;
' never assigns a uri, which is what keeps a recycled row from rebinding a texture.
sub fillBadgeSlot(slot as object, badge as object)
  slot.label.text = badge.text
  slot.label.color = badge.color

  ' Collapse every icon in this slot, then open the one asked for. Width rather
  ' than visibility is what takes it out of the layout — see newBadgeIcon.
  for each kind in slot.icons
    icon = slot.icons[kind]
    wanted = (kind = badge.iconKind)
    icon.visible = wanted
    if wanted
      icon.width = BADGE_ICON_SIZE
      icon.blendColor = badge.color
    else
      icon.width = 0
    end if
  end for

  ' The icon gap only exists beside an icon. Applied beside an empty holder it
  ' indents the label.
  if badge.iconKind = BADGE_ICON_NONE
    slot.group.itemSpacings = [0]
  else
    slot.group.itemSpacings = [subtitleLayout.BADGE_ICON_GAP]
  end if

  slot.group.visible = true
end sub

sub onSizeChanged()
  rowWidth = m.top.rowWidth
  constants = m.global.constants

  ' Before any geometry below: the meta line's width depends on what fits.
  placeBadges()

  ' Line 1: the release name, across the row's full text width.
  ' ScrollingLabel viewports are driven by maxWidth, not width.
  m.nameLabel.maxWidth = rowWidth - (subtitleLayout.ROW_H_PADDING * 2)
  m.nameLabel.translation = [subtitleLayout.ROW_H_PADDING, subtitleLayout.ROW_V_PADDING]

  ' Line 2: meta text on the left, badges right-aligned.
  secondLineY = subtitleLayout.ROW_V_PADDING + constants.fontSizeSmaller + subtitleLayout.ROW_LINE_GAP

  ' A Label with wrap off and a width ellipsizes what does not fit, so a long
  ' meta line degrades to "…" instead of running under the badges.
  m.metaLabel.width = subtitleLayout.detailWidthBeside(rowWidth, m.badgesWidth)
  m.metaLabel.translation = [subtitleLayout.ROW_H_PADDING, secondLineY]

  m.badgeRow.translation = [rowWidth - subtitleLayout.ROW_H_PADDING, secondLineY]
end sub


' onFocusChanged: the marquee, and nothing else.
'
' The focus BORDER and surface are the list's now (see the XML), so all that is
' left here is the one thing a shared indicator cannot do: start and stop this
' row's own scrolling label. The colour used to be re-applied in both branches to
' the same value it is already given in init(), which is a write per focus change
' that could never change anything.
sub onFocusChanged()
  if m.top.itemHasFocus
    m.nameLabel.repeatCount = -1
  else
    m.nameLabel.repeatCount = 0
  end if
end sub