components_ui_rowlist_JRRowList.bs
' bsc-disable-file print-locations — legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowListWrap.bs"
sub init()
constants = m.global.constants
m.top.showRowLabel = [true]
m.top.showRowCounter = [true]
m.top.rowFocusAnimationStyle = "fixedFocusWrap"
m.top.vertFocusAnimationStyle = "fixedFocusWrap"
m.top.focusBitmapBlendColor = constants.colorPrimary ' color of the item selector
m.top.itemSpacing = [0, 60] ' spacing between rows
m.top.rowItemSpacing = [30, 0] ' spacing between items in a row
m.top.focusXOffset = [96] ' align item selector with edge of "action" safe zone
m.top.rowLabelOffset = [96, 18] ' align row label with edge of "action" safe zone
m.top.rowLabelColor = constants.colorTextPrimary
m.top.rowLabelFont = "font:MediumSystemFont"
m.top.rowLabelFont.size = constants.fontSizeMedium
if m.global.user.settings.uiFontFallback = true
m.top.rowLabelFont.uri = "tmp:/font"
applyFallbackFontScale()
end if
' ── Program progress bar tick ───────────────────────────────────────────
' JRRowItem (and its BrowseRowItem subclass) draws a progress bar on
' Program cells by reading item.playedPercentage. Live broadcasts advance
' in real time so the percentage must be refreshed periodically. This
' base-class tick propagates the feature to every JRRowList descendant
' (HomeRows, FavoritesRows, SearchRow, ExtrasRowList) with no per-screen
' plumbing — it gates itself on textureManagerState, which every row-list
' screen already sets via activateTextureManager/hideTextureManager.
m.programProgressTimer = invalid
m.observedContentRoot = invalid
m.isProgramProgressTicking = false
m.suppressNextExpiry = false
m.top.observeField("content", "onProgramProgressContentChanged")
end sub
' ============================================
' PROGRAM PROGRESS BAR TICK
' ============================================
' Fires when m.top.content is assigned or reassigned by a subclass.
' Rebinds the textureManagerState observer to the new content root so the
' tick timer tracks visibility transitions for the current data set.
sub onProgramProgressContentChanged()
if isValid(m.observedContentRoot)
m.observedContentRoot.unobserveField("textureManagerState")
m.observedContentRoot = invalid
end if
content = m.top.content
if not isValid(content) then return
' Pre-add textureManagerState so observeField has a real field to attach to.
' Subclasses assign m.top.content BEFORE calling initTextureManager, which
' means the field would not yet exist at the time this handler runs and the
' observer would silently fail to attach. textureManager.bs handles the
' pre-added-field case via a hasField check, so both call sites coexist.
if not content.hasField("textureManagerState")
content.addField("textureManagerState", "string", false)
end if
m.observedContentRoot = content
content.observeField("textureManagerState", "onProgramProgressVisibilityChanged")
' Honour the current state immediately — content may already be "active"
' by the time we subscribe (e.g. reassignment after a refresh).
syncProgramProgressTicking(content.textureManagerState)
end sub
sub onProgramProgressVisibilityChanged(msg as object)
syncProgramProgressTicking(msg.getData())
end sub
' Starts or stops the 60s Program progress tick based on the visibility state
' broadcast by the texture manager. Idempotent; safe on every state change.
' State arrives as dynamic because textureManagerState is unset (invalid) on
' freshly-created content roots — setupTextureManager only assigns "init"
' after the subclass first populates the content tree.
sub syncProgramProgressTicking(state as dynamic)
isString = isValid(state) and (type(state) = "roString" or type(state) = "String")
if isString and state = "active"
startProgramProgressTicking()
else
' Any non-"active" value (invalid, "init", "hidden", "destroyed") stops
' ticking. On "destroyed" we also release the Timer node so the screen
' tears down cleanly.
stopProgramProgressTicking(isString and state = "destroyed")
end if
end sub
sub startProgramProgressTicking()
if m.isProgramProgressTicking then return
if not isValid(m.programProgressTimer)
m.programProgressTimer = CreateObject("roSGNode", "Timer")
m.programProgressTimer.duration = 60
m.programProgressTimer.repeat = true
m.top.appendChild(m.programProgressTimer)
end if
m.programProgressTimer.observeField("fire", "onProgramProgressTick")
m.programProgressTimer.control = "start"
m.isProgramProgressTicking = true
' Immediate refresh so returning from Settings/another screen does not
' leave stale percentages on screen for up to 60 seconds. Suppress expiry
' detection on this first tick — the data is stale (from before the user
' navigated away) and almost always contains expired programs, which would
' trigger unnecessary refetches before the screen's own refresh has landed.
m.suppressNextExpiry = true
onProgramProgressTick()
m.suppressNextExpiry = false
end sub
sub stopProgramProgressTicking(releaseTimer = false as boolean)
if isValid(m.programProgressTimer)
m.programProgressTimer.control = "stop"
m.programProgressTimer.unobserveField("fire")
if releaseTimer
m.top.removeChild(m.programProgressTimer)
m.programProgressTimer = invalid
end if
end if
m.isProgramProgressTicking = false
end sub
' Walks the texture manager's loaded row range and rewrites playedPercentage on
' every Program child to its current broadcast-elapsed percentage. JRRowItem's
' bridge observer forwards these writes to any currently-mounted poster cells,
' refreshing visible bars without triggering a cell re-render. Non-Program
' items are skipped.
'
' Iteration is scoped to loadedRowRange [bufferStart..bufferEnd] — the vertical
' range maintained by textureManager.bs — so large datasets (SearchRow,
' FavoritesRows) don't pay the cost of walking hundreds of offscreen items
' every minute on the render thread. Offscreen rows whose percentage goes
' stale during scroll-away will update at the next tick after re-entering the
' buffer range (up to ~60s stale window, accepted trade-off).
'
' Also detects expired broadcasts (now >= PlayStart + PlayDuration) within the
' same buffer range and raises the programsExpired field. Subclasses that can
' refetch (HomeRows, ExtrasRowList) observe that field and trigger a fresh
' load. Expiry detection is scoped to the buffer range rather than the full
' content tree because Live TV rows in HomeRows/ExtrasRowList are always in
' the vertical buffer in practice — full-scan expiry on every tick would be
' unnecessary work for zero real-world benefit.
'
' Hard-guards rather than full-scan fallback when loadedRowRange is missing or
' invalid: the tick only runs while textureManagerState = "active", which the
' state machine guarantees implies loadedRowRange is populated. A missing
' field surfaces an ordering regression loudly rather than masking it.
sub onProgramProgressTick()
content = m.top.content
if not isValid(content) then return
if not content.hasField("loadedRowRange") then return
range = content.loadedRowRange
if not isValid(range) or range.count() < 4 then return
bufferStart = range[0]
bufferEnd = range[3]
' Sentinel [-1,-1,-1,-1] means no rows are currently managed (empty content
' or no focus). Nothing is mounted, so nothing to update.
if bufferStart < 0 or bufferEnd < 0 then return
totalRows = content.getChildCount()
if bufferEnd >= totalRows then bufferEnd = totalRows - 1
if bufferStart > bufferEnd then return
nowSeconds = CreateObject("roDateTime").AsSeconds()
hasExpiredProgram = false
for i = bufferStart to bufferEnd
row = content.getChild(i)
if isValid(row)
for j = 0 to row.getChildCount() - 1
item = row.getChild(j)
if isValid(item) and (item.type = "Program" or item.type = "Recording")
item.playedPercentage = computeProgramBroadcastProgress(item.PlayStart, item.PlayDuration, nowSeconds)
' Expiry check uses the same window math as the progress computation.
' PlayStart/PlayDuration default to 0 for items missing broadcast data —
' the > 0 guards prevent those from being flagged as expired.
if item.PlayStart > 0 and item.PlayDuration > 0 and nowSeconds >= (item.PlayStart + item.PlayDuration)
hasExpiredProgram = true
end if
end if
end for
end if
end for
if hasExpiredProgram
if m.suppressNextExpiry
m.suppressNextExpiry = false
else
m.top.programsExpired = true
end if
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
return wrapRowFocus(key, press)
end function
sub applyFallbackFontScale()
' Get the current font size if one is set, or use a default
currentSize = m.top.rowLabelFont.size
if currentSize <= 0
print "ERROR - JRRowList applyFallbackFontScale: Invalid font size"
return
end if
' Apply the global scale factor
fontScaleFactor = m.global.user.fontScaleFactor
if isValid(fontScaleFactor) and fontScaleFactor > 0
scaledSize = currentSize * fontScaleFactor
m.top.rowLabelFont.size = scaledSize
else
print "ERROR - JRRowList applyFallbackFontScale: Invalid font scale factor"
return
end if
end sub