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