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