components_subtitles_CurrentSubtitleRow.bs

' CurrentSubtitleRow: one subtitle already attached to the item.
'
' See CurrentSubtitleRow.xml for why this is separate from SubtitleResultRow.
import "pkg:/source/constants/subtitleLayout.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"

const BADGE_ICON_SIZE = 22

' Which icon a badge draws. Both Posters live in every slot with their uri
' assigned once — see SubtitleResultRow.buildBadgePool for the full reasoning.
const BADGE_ICON_NONE = ""
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 = 2

sub init()
  m.titleLabel = m.top.findNode("titleLabel")
  m.sourceLabel = m.top.findNode("sourceLabel")
  m.badgeRow = m.top.findNode("badgeRow")
  m.deleteIcon = m.top.findNode("deleteIcon")

  m.titleLabel.color = m.global.constants.colorTextPrimary
  m.titleLabel.repeatCount = 0
  ' Until a content node says otherwise, assume the compact shape — a row sized
  ' for two lines that only ever draws one leaves a visible gap.
  m.isExternal = false
  ' Likewise assume no action: a row that reserves space for an affordance it
  ' never draws is indistinguishable from one that is simply misaligned.
  m.canDelete = false
  ' Badges this row's content asks for, each { text, color, iconKind, width },
  ' and the width of the ones actually shown. Empty until content arrives, so a
  ' row measured before then gives the title the full width.
  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

  m.deleteIcon.width = subtitleLayout.ROW_ACTION_ICON_SIZE
  m.deleteIcon.height = subtitleLayout.ROW_ACTION_ICON_SIZE
  ' delete_fhd.png is 64x64 and drawn at 24 -- same load-time scaling gap as the
  ' badge icons in buildBadgePool, and the same three fields close it.
  m.deleteIcon.loadWidth = subtitleLayout.ROW_ACTION_ICON_SIZE
  m.deleteIcon.loadHeight = subtitleLayout.ROW_ACTION_ICON_SIZE
  m.deleteIcon.loadDisplayMode = "limitSize"
  ' Same synchronous local load as the badge icons — this one appears on focus,
  ' where a late bitmap reads as the affordance lagging the focus ring.
  m.deleteIcon.loadSync = true
  ' Tracks the row's text rather than an error colour. colorError is this app's
  ' signal for an outcome that already went wrong (Toast), not for an action
  ' that is merely available — the weight of the action belongs in the
  ' confirmation, not in the affordance.
  m.deleteIcon.blendColor = m.global.constants.colorTextPrimary

  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 = "existingBadgeSlot" + i.toStr()
    badge.layoutDirection = "horiz"
    badge.vertAlignment = "center"
    badge.visible = false

    ' Resident, pre-bound icon in a holder — the uri is assigned once and never
    ' rebound, so a recycled row cannot make the icon pop in. This row type only
    ' ever draws the CC mark, but it keeps the holder so both row types collapse
    ' an unused icon the same way (by WIDTH, because an invisible LayoutGroup
    ' child still occupies its slot — measured on device).
    holder = badge.createChild("Group")
    icons = {}
    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 WIDTH rather than merely hidden — an invisible LayoutGroup
' child still takes its place in the layout, which is what leaves a badge with
' no icon indented from one that has it.
function newBadgeIcon(holder as object, uri as string) as object
  icon = holder.createChild("Poster")
  ' loadWidth/loadHeight state the cap; loadDisplayMode is what makes it apply at
  ' load time (its noScale default loads at the asset's full resolution, so the
  ' first two do nothing alone). closedCaptions_fhd.png is 64x64 drawn at 22.
  icon.loadWidth = BADGE_ICON_SIZE
  icon.loadHeight = BADGE_ICON_SIZE
  icon.loadDisplayMode = "limitSize"
  ' Synchronous load, set BEFORE uri — Poster.loadSync defaults to false, i.e.
  ' async, "may not appear immediately". That async default is the icon pop-in AND
  ' the ~28px rightward jump of whatever badge follows, because a Poster with no
  ' bitmap yet contributes nothing to the LayoutGroup. Full reasoning in
  ' SubtitleResultRow.newBadgeIcon.
  icon.loadSync = true
  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.titleLabel.text = titleLineFor(content)

  ' The filename line belongs to downloaded files only. Computed by the panel,
  ' which is the only scope holding BOTH the subtitle path and the media path the
  ' redundant leading part is stripped against.
  m.isExternal = content.isExternal = true
  m.sourceLabel.text = textOrEmpty(content.source)
  m.sourceLabel.visible = m.isExternal and m.sourceLabel.text <> ""

  ' Whether OK does anything on this row. Resolved by the panel, which is the
  ' only scope that knows the user's delete permission; the row just draws it.
  ' Explicitly `= true` so an absent field reads as "no action", never as one.
  m.canDelete = content.canDelete = true

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

' titleLineFor: "English  ·  SRT".
'
' Language first because that is what this column is scanned for. The codec
' follows because it is the one remaining property that changes how the track
' behaves (an image-based format forces transcoding where a text one does not).
function titleLineFor(content as object) as string
  parts = []

  if isValidAndNotEmpty(content.language)
    parts.push(content.language)
  else
    parts.push(translate(translationKeys.LabelUnknown))
  end if

  if isValidAndNotEmpty(content.codec) then parts.push(UCase(content.codec))

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

' badgesFor: Only the flags that change what the track IS, most significant first.
'
' Order is load-bearing: when a row cannot fit every badge, the ones dropped are
' the last in this list (see placeBadges).
'
' No "perfect match" here — that is a property of a provider search result, not
' of a file already on disk — and no download count, which an installed subtitle
' does not have. Rendering those was what made the first draft's right-hand
' column read as broken.
function badgesFor(content as object) as object
  wanted = []
  constants = m.global.constants

  if content.hearingImpaired = true
    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

  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

  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, which is what clipped "Forced".
  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 title's width depends on what fits.
  placeBadges()

  ' Line 1: language and codec on the left, badges right-aligned. Badges live on
  ' the FIRST line for both shapes so they align down the column regardless of
  ' whether a row has a second line.
  '
  ' The action icon takes NOTHING from this line — it lives on line 2. Reserving
  ' here instead ellipsized the title on a real row (see actionZoneWidth).
  firstLineY = subtitleLayout.ROW_V_PADDING

  ' ScrollingLabel viewports are driven by maxWidth, not width.
  m.titleLabel.maxWidth = subtitleLayout.detailWidthBeside(rowWidth, m.badgesWidth)
  m.titleLabel.translation = [subtitleLayout.ROW_H_PADDING, firstLineY]

  m.badgeRow.translation = [rowWidth - subtitleLayout.ROW_H_PADDING, firstLineY]

  ' Line 2: the filename, and the action icon at the far right. This line has no
  ' badges, so the icon is the only thing competing with the text here — and the
  ' filename tail it holds is short enough that giving up the icon's strip costs
  ' nothing visible. A Label with wrap off and a width ellipsizes any overflow.
  secondLineY = firstLineY + constants.fontSizeSmaller + subtitleLayout.ROW_LINE_GAP
  m.sourceLabel.width = rowWidth - (subtitleLayout.ROW_H_PADDING * 2) - subtitleLayout.actionZoneWidth(m.canDelete)
  m.sourceLabel.translation = [subtitleLayout.ROW_H_PADDING, secondLineY]

  ' Centred on line 2 rather than on the row, which is what keeps it clear of the
  ' right-aligned badges above: both are pinned to the same right edge, so they
  ' would collide on any row that carries a badge.
  m.deleteIcon.translation = [
    rowWidth - subtitleLayout.ROW_H_PADDING - subtitleLayout.ROW_ACTION_ICON_SIZE,
    secondLineY + (constants.fontSizeSmallest - subtitleLayout.ROW_ACTION_ICON_SIZE) / 2
  ]
end sub

sub onFocusChanged()
  hasFocus = m.top.itemHasFocus

  ' Drawn only while focused — an icon on every row would read as a column of
  ' delete buttons rather than as what OK does to the row you are on. Its space
  ' is reserved regardless (see onSizeChanged), so this cannot shift the layout.
  m.deleteIcon.visible = hasFocus and m.canDelete

  if hasFocus
    m.titleLabel.repeatCount = -1
  else
    m.titleLabel.repeatCount = 0
  end if
end sub