components_extras_ExtrasRowList.bs

import "pkg:/source/constants/extrasLayout.bs"
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/extras/extrasRows.bs"
import "pkg:/source/utils/chapterItems.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowListWrap.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"

' Default RowList translation.y: places the focused row (TOP) 18px below the panel top edge.
const EXTRAS_ROWLIST_DEFAULT_Y = 18
' Extra clearance so the focused row's bottom edge sits comfortably above the 5% action-safe zone.
' Adjusting this shifts BOTTOM and MIDDLE positions equally relative to the action zone.
const EXTRAS_BOTTOM_SAFE_PADDING = 18

sub init()
  m.top.visible = true
  m.top.numRows = 1
  m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
  m.top.targetTranslationY = EXTRAS_ROWLIST_DEFAULT_Y
  updateSize()
  ' Cache row spacing from JRRowList so updatePanelPosition stays in sync with the rendered spacing.
  m.rowSpacing = m.top.itemSpacing[1]
  m.top.observeField("rowItemSelected", "onRowItemSelected")
  m.top.observeField("rowItemFocused", "onRowItemFocused")

  ' The orchestrator Task of the run in flight, a new node per run (see startRun); invalid
  ' until the first run.
  m.extrasTask = invalid

  ' Permanent root content node, never replaced.
  m.top.content = CreateObject("roSGNode", "ContentNode")
  initTextureManager(m.top.content, m.top.itemSize, m.top.focusXOffset, m.top.rowItemSpacing)

  ' The item's full row plan (extrasRows.plan), each row carrying `slot`, its index. Rows on
  ' screen are always a subsequence of it, in plan order.
  m.plan = []
  ' kind -> the row ContentNode on screen, and kind -> { title, shape, digest, contentKind } it
  ' was committed with (the digest identifies its source content, see extrasRows.digestOf). Rows
  ' are reused across refreshes so the RowList never loses focus position.
  m.rowsByKind = {}
  m.rowInfoByKind = {}

  ' The run in flight. Slots are indexed like m.plan: { status, count }: "pending", "ok",
  ' "failed", or "unchanged" for a plan row this run does not load. Results hold each resolved
  ' slot's finished { items, shape, digest, contentKind } until it is committed.
  m.isRunning = false
  m.slots = []
  m.results = []
  m.resultCursor = 0

  ' Row heights of the rows on screen, in order, read by updatePanelPosition.
  m.rowHeights = []

  ' Track the last focused row index so horizontal item scrolling within a row
  ' does not trigger an unnecessary panel position recalculation.
  m.lastFocusedRowIndex = -1

  ' Refetch the Channel Programs row (TvChannel "Up Next" / Program "More on this Channel")
  ' when the base class's progress tick detects an expired broadcast.
  m.top.observeField("programsExpired", "onProgramsExpired")
end sub

sub updateSize()
  ' Use maximum row height to accommodate PORTRAIT slots (351px slot + 90px text = 441px)
  ' WIDE rows (264px slot + 90px text = 354px) will have extra padding, which is acceptable
  m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]

  ' floatingFocus: when navigating to a new row, the previous row remains visible above the
  ' focused row within the RowList's visible area. fixedFocus always scrolls the previous row
  ' off-screen (focus snaps to RowList Y=0), so the peek effect is impossible with fixedFocus.
  m.top.vertFocusAnimationStyle = "floatingFocus"
end sub

' loadParts: Load the extras rows for an item. m.top.type must be set first (ItemDetails does).
' Rows per type are decided by extrasRows.plan; the server version decides whether a
' Collections row is one of them.
sub loadParts(data as object)
  itemType = m.top.type
  m.top.parentId = data.id

  setPlan(extrasRows.plan(itemType, data, m.global.server.version))
  resetForLoad()
  startRun(m.plan, data.people, data)
end sub

sub loadPersonVideos(personId)
  m.top.personHasMedia = false ' Reset before the run: prevents a stale true from a prior run
  setPlan(extrasRows.plan("Person", { id: personId }, m.global.server.version))
  resetForLoad()
  startRun(m.plan, invalid, invalid)
end sub

' setPlan: Adopt a plan, stamping each row with `slot`, its index: the key results come back on.
sub setPlan(plan as object)
  for i = 0 to plan.count() - 1
    plan[i].slot = i
  end for
  m.plan = plan
end sub

' resetForLoad: State a full load starts from. A targeted refetch (onProgramsExpired) skips
' this: it must not move the panel or reopen the readiness signal.
sub resetForLoad()
  cancelRun()
  m.top.contentReady = false
  m.lastFocusedRowIndex = -1
  m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
end sub

' startRun: Load `runRows` (all of m.plan, or a subset of it for a targeted refetch).
'
' Nothing reaches the RowList until the WHOLE run has resolved; then commitRows applies every
' change in one pass. That is the point of this component's shape, not an incidental: RowList
' redraws its "X of Y" row counter on any write to rows already on screen (a row appended below
' them, a live row's items replaced), even when the count it shows does not change, and that
' redraw read as a flicker. Measured on device: committing rows as each resolved blinked once
' per commit after the first, and one commit that rewrote unchanged rows still blinked once.
'
' @param runRows - plan rows to load this run
' @param people - the item's People array, for a Cast row (invalid when there is none)
' @param data - the item node, for rows built on this thread (Chapters)
sub startRun(runRows as object, people as dynamic, data as dynamic)
  cancelRun()

  m.isRunning = true

  m.slots = []
  m.results = []
  for each row in m.plan
    m.slots.push({ status: "unchanged", count: 0 })
    m.results.push(invalid)
  end for

  taskRows = []
  for each row in runRows
    if row.kind = "chapters"
      ' Built from the item on this thread, as it always was.
      items = []
      digest = ""
      if isValid(data)
        items = buildChapterItems(data)
        ' The inputs buildChapterItems reads. runTimeTicks goes in as text: synthetic chapters
        ' are derived from it, and FormatJSON's annotate flag would reduce a type it cannot
        ' format to a placeholder that no longer changes with the value.
        digest = extrasRows.digestOf(FormatJSON({ id: data.id, type: data.type, runTimeTicks: data.runTimeTicks.toStr(), chapters: data.chapters }, &h0200))
      end if
      resolveSlot(row.slot, "ok", { items: items, shape: row.shape, digest: digest, contentKind: "" })
    else
      m.slots[row.slot] = { status: "pending", count: 0 }
      taskRows.push(row)
    end if
  end for

  if taskRows.count() > 0
    ' A NEW node every run, never a relaunch of the previous one: whether a Task stopped while its
    ' function is still running honors a launch made in the same callback is a race, measured on
    ' device, pinned in tests/source/unit/platform/TaskRelaunch.spec.bs. Reusing the node left a
    ' Refresh pressed mid-run, at least some of the time, with a run that never started and extras
    ' that never settled. The stopped
    ' node's function makes no further progress, so this does not stack threads.
    m.extrasTask = CreateObject("roSGNode", "LoadExtrasRowsTask")
    m.extrasTask.setFields({ rows: taskRows, people: people ?? [] })
    m.extrasTask.observeField("rowReady", "onExtrasRowReady")
    ' bsc-disable-next-line no-task-fanout one run at a time: cancelRun() above stops the previous run first
    launchTask(m.extrasTask)
  end if

  finishRunIfResolved()
end sub

' cancelRun: Drop the run in flight, if any. Its Task node is released with it, so nothing it
' delivers later can reach onExtrasRowReady, which only ever drains the current node.
sub cancelRun()
  if isValid(m.extrasTask)
    m.extrasTask.unobserveField("rowReady")
    m.extrasTask.control = "STOP"
    m.extrasTask = invalid
  end if
  m.resultCursor = 0
  m.isRunning = false
end sub

' onExtrasRowReady: Drain every result the orchestrator has appended since the last wake:
' wake events coalesce, appended children don't.
sub onExtrasRowReady()
  ' A wake already queued when its run was cancelled can arrive after the node was released.
  if not isValid(m.extrasTask) then return

  childCount = m.extrasTask.getChildCount()
  while m.resultCursor < childCount
    child = m.extrasTask.getChild(m.resultCursor)
    m.resultCursor++
    if isValid(child)
      resolveSlot(child.slot, child.status, { items: child.items, shape: child.shape, digest: child.digest, contentKind: child.contentKind })
    end if
    ' This component holds the items now, so drop the carrier's reference so an ItemDetails
    ' session doesn't retain every batch it was delivered.
    if isValid(child) then child.items = []
  end while

  finishRunIfResolved()
end sub

' resolveSlot: Record one row's outcome for the commit.
' @param result - the finished row: { items, shape, digest, contentKind }
sub resolveSlot(slot as integer, status as string, result as object)
  items = result.items
  m.slots[slot] = { status: status, count: items.count() }
  m.results[slot] = result

  kind = m.plan[slot].kind
  if items.count() > 0 and not m.top.personHasMedia and (kind = "personMovies" or kind = "personEpisodes" or kind = "personSeries")
    ' Signal early: there is media, no need to wait for the whole run.
    m.top.personHasMedia = true
  end if
end sub

' finishRunIfResolved: Once every slot of the run has resolved, commit and close the run.
sub finishRunIfResolved()
  if not m.isRunning then return
  if not extrasRows.isRunResolved(m.slots) then return

  commitRows()

  ' Invalid when the run needed no Task thread at all.
  if isValid(m.extrasTask) then m.extrasTask.unobserveField("rowReady")
  m.isRunning = false
  m.results = []

  if m.plan.count() > 0 and m.plan[0].kind = "personMovies"
    ' Authoritative final answer: covers "no media at all". ItemDetails guards against
    ' adding a duplicate button when the early signal already said true.
    m.top.personHasMedia = m.top.content.getChildCount() > 0
  end if

  ' AFTER personHasMedia, so a caller treating "extras done" as permission to read the
  ' shuffle answer finds it already written. ItemDetails' readiness ledger closes on this.
  m.top.contentReady = true
end sub

' commitRows: Bring the rows on screen in line with the run's results in one pass, and write
' NOTHING that has not changed, because every write to an on-screen row redraws the counter
' (see startRun).
'
'   - A row whose title, slot shape and source-content digest match what is on screen is left
'     untouched (extrasRows.isRowUnchanged).
'   - A changed row that is already on screen is reused so focus position survives; its new
'     items are appended BEFORE the old ones are removed, so it is never momentarily empty
'     (the same rule HomeRows.populateRowFromData follows).
'   - A row whose fetch failed is KEPT: no answer says nothing about what the item has, and
'     removing it would delete good content on a flaky refresh. See extrasRows.rowDisposition.
'   - The layout is written only when a row was added, removed or re-shaped.
'
' Which of those applies to each row is decided by extrasRows.commitActions, which is unit-tested;
' this applies its answer to the nodes.
sub commitRows()
  titles = []
  for each planRow in m.plan
    titles.push(translate(planRow.titleKey, planRow.titleArgs))
  end for
  commit = extrasRows.commitActions(m.plan, m.slots, m.results, titles, m.rowInfoByKind)

  content = m.top.content
  desired = []
  for slot = 0 to m.plan.count() - 1
    kind = m.plan[slot].kind
    action = commit.actions[slot]
    if action = "write"
      desired.push(fillRow(m.rowsByKind[kind], kind, titles[slot], m.results[slot]))
    else if action = "leave"
      desired.push(m.rowsByKind[kind])
    end if
  end for

  ' Drop rows that are no longer wanted (including any whose kind the current plan no longer
  ' has), then insert new rows at their positions. The rows that stay are already in plan order,
  ' so only new rows ever need inserting.
  for i = content.getChildCount() - 1 to 0 step -1
    if not containsNode(desired, content.getChild(i)) then content.removeChildIndex(i)
  end for
  for i = 0 to desired.count() - 1
    child = content.getChild(i)
    if not isValid(child) or not child.isSameNode(desired[i]) then content.insertChild(desired[i], i)
  end for

  ' Forget rows that left the screen. Collected first: deleting from an AA while iterating it
  ' is not safe.
  dropped = []
  for each kind in m.rowsByKind
    if not containsNode(desired, m.rowsByKind[kind]) then dropped.push(kind)
  end for
  for each kind in dropped
    m.rowsByKind.delete(kind)
    m.rowInfoByKind.delete(kind)
  end for

  if commit.isStructureChanged then applyRowLayout()

  ' Taken from the row as committed, so the Watched button and "Tracks" label always describe the
  ' playlist row on screen, including one a failed refresh kept.
  if isValid(commit.playlistContentKind) then m.top.playlistContentKind = commit.playlistContentKind
end sub

' fillRow: The row node for `kind`, holding `result.items`: the row already on screen when there
' is one, a new node otherwise.
function fillRow(existing as dynamic, kind as string, title as string, result as object) as object
  row = existing
  if isValid(row)
    ' Append first, then remove the old items: the row is never momentarily empty.
    oldCount = row.getChildCount()
    row.appendChildren(result.items)
    row.removeChildrenIndex(oldCount, 0)
    if row.title <> title then row.title = title
  else
    row = CreateObject("roSGNode", "ContentNode")
    row.title = title
    row.appendChildren(result.items)
  end if

  m.rowsByKind[kind] = row
  m.rowInfoByKind[kind] = { title: title, shape: result.shape, digest: result.digest, contentKind: result.contentKind }
  return row
end function

' applyRowLayout: Write the layout for the rows on screen in one setFields.
'
' numRows must always equal the actual row count so floatingFocus never needs to scroll: if
' numRows < rowCount, navigation scrolls internally, which moves the focused row's RowList Y
' unpredictably and breaks updatePanelPosition's translation formula.
sub applyRowLayout()
  shapes = []
  for each planRow in m.plan
    info = m.rowInfoByKind[planRow.kind]
    if isValid(info) then shapes.push(info.shape)
  end for

  sizes = extrasRows.rowSizes(shapes)
  m.rowHeights = sizes.rowHeights
  rowCount = m.top.content.getChildCount()
  m.top.setFields({ rowItemSize: sizes.rowItemSize, rowHeights: sizes.rowHeights, numRows: rowCount })

  ' Activate texture management once rows exist (layout is stable enough). activateTextureManager
  ' only moves "init" to "active" once; later calls are skipped by the state check.
  content = m.top.content
  if rowCount > 0 and content.hasField("textureManagerState") and content.textureManagerState = "init"
    updateTextureBufferRange(content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], rowCount)
    activateTextureManager(content)
  end if
end sub

function containsNode(nodes as object, node as dynamic) as boolean
  if not isValid(node) then return false
  for each candidate in nodes
    if candidate.isSameNode(node) then return true
  end for
  return false
end function

' Fires when JRRowList's progress tick detects at least one expired Program among this list's
' rows. Refetches only the Channel Programs row (TvChannel "Up Next" / Program "More on this
' Channel") so finished broadcasts roll off; related content is stable over a viewing session
' and is not refetched.
'
' Skipped while any run is in flight: starting a run cancels the one in progress, which would
' throw away its other rows. The tick raises the flag again on its next pass while a program is
' still expired, so nothing is lost by waiting.
sub onProgramsExpired()
  if m.isRunning then return
  if not isValid(m.rowsByKind["channelPrograms"]) then return

  for each row in m.plan
    if row.kind = "channelPrograms"
      startRun([row], invalid, invalid)
      return
    end if
  end for
end sub

sub onRowItemSelected()
  m.top.selectedItem = m.top.content.getChild(m.top.rowItemSelected[0]).getChild(m.top.rowItemSelected[1])
  ' Clear immediately: prevents a stale value from re-firing the parent ItemDetails'
  ' observer when that screen is restored on view resume.
  m.top.selectedItem = invalid
end sub

sub onRowItemFocused()
  rowIndex = m.top.rowItemFocused[0]
  m.top.focusedItem = m.top.content.getChild(rowIndex).getChild(m.top.rowItemFocused[1])
  updateTextureBufferRange(m.top.content, rowIndex, m.top.rowItemFocused[1], m.top.numRows)

  ' Skip panel position recalculation when horizontal item scrolling fires rowItemFocused
  ' without an actual row change (avoids redundant translation/targetTranslationY updates).
  if rowIndex = m.lastFocusedRowIndex then return
  m.lastFocusedRowIndex = rowIndex

  updatePanelPosition(rowIndex)
end sub

' updatePanelPosition: Set targetTranslationY so ItemDetails.bs can animate the RowList's
' translation within extrasGrp. extrasGrp stays fixed at extrasLayout.PANEL_OPEN_Y (306).
'
' numRows always equals the total row count (set in applyRowLayout), so floatingFocus never scrolls
' internally. All rows stay at fixed RowList-relative Y positions:
'   row 0: RowList Y = 0
'   row N: RowList Y = sum(heights[0..N-1]) + N * m.rowSpacing  (= focusedRowY)
'
' Visible screen height between panel top and action zone:
'   extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y = 720px.
'
' Three position classes:
'   TOP:    row 0 (or only row): focused row at RowList Y=0, default translation (18px).
'   BOTTOM: last row: focused row bottom edge at action zone - EXTRAS_BOTTOM_SAFE_PADDING.
'              extrasLayout.PANEL_OPEN_Y + translationY + focusedRowY + rowHeight = extrasLayout.ACTION_SAFE_BOTTOM_Y - EXTRAS_BOTTOM_SAFE_PADDING
'              translationY = (extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - EXTRAS_BOTTOM_SAFE_PADDING) - rowHeight - focusedRowY
'   MIDDLE: rows 1..N-2: focused row centered in the visible area.
'              translationY = int((extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - rowHeight) / 2) - focusedRowY
'
' Negative translationY moves the RowList above extrasGrp Y=0. The clippingRect Group in
' ExtrasSlider.xml clips that overflow so the previous row only peeks within the panel bounds.
' targetTranslationY is observed by ItemDetails.bs which runs the gridAnime Animation.
' Called early from onKeyEvent (before RowList focuses the row) and confirmed by onRowItemFocused.
sub updatePanelPosition(rowIndex as integer)
  rowCount = m.top.content.getChildCount()

  ' Safe row height: fall back to portrait if rowHeights not yet populated for this index.
  rowHeight = rowSlotSize.ROW_HEIGHT_PORTRAIT
  if isValid(m.rowHeights) and m.rowHeights.count() > rowIndex
    rowHeight = m.rowHeights[rowIndex]
  end if

  if rowIndex = 0 or rowCount <= 1
    ' TOP: focused row at RowList Y=0, default translation
    m.top.targetTranslationY = EXTRAS_ROWLIST_DEFAULT_Y
  else
    ' focusedRowY: Y of the focused row within the RowList's rendered area.
    ' Sum all preceding row heights plus one spacing per preceding row.
    focusedRowY = 0
    for i = 0 to rowIndex - 1
      rowH = rowSlotSize.ROW_HEIGHT_PORTRAIT
      if isValid(m.rowHeights) and m.rowHeights.count() > i
        rowH = m.rowHeights[i]
      end if
      focusedRowY += rowH + m.rowSpacing
    end for

    if rowIndex = rowCount - 1
      ' BOTTOM: focused row bottom edge at action zone minus safe padding
      translationY = (extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - EXTRAS_BOTTOM_SAFE_PADDING) - rowHeight - focusedRowY
    else
      ' MIDDLE: center focused row within the visible area (extrasLayout.PANEL_OPEN_Y..extrasLayout.ACTION_SAFE_BOTTOM_Y)
      translationY = int((extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - rowHeight) / 2) - focusedRowY
    end if
    m.top.targetTranslationY = translationY
  end if
end sub

' onDestroy: Full teardown releasing all resources before component removal.
' Called by ItemDetails.onDestroy() before nulling the extrasGrid reference.
sub onDestroy()
  ' Detach every observer before releasing anything a handler could dereference.
  if isValid(m.extrasTask)
    m.extrasTask.unobserveField("rowReady")
    m.extrasTask.control = "STOP"
  end if

  ' Stop and release the program progress timer (inherited from JRRowList)
  stopProgramProgressTicking(true)

  ' Unobserve m.top fields set in init()
  m.top.unobserveField("rowItemSelected")
  m.top.unobserveField("rowItemFocused")
  m.top.unobserveField("programsExpired")

  m.isRunning = false
  m.extrasTask = invalid
  m.plan = []
  m.slots = []
  m.results = []
  m.rowsByKind = {}
  m.rowInfoByKind = {}
end sub

' onKeyEvent: Anticipate the next row on UP/DOWN so the panel translation animation starts
' in sync with the RowList floatingFocus animation rather than after it completes.
' Returns false so the RowList handles the actual navigation.
function onKeyEvent(key as string, press as boolean) as boolean
  if wrapRowFocus(key, press) then return true

  if press
    rowCount = m.top.content.getChildCount()
    if key = "down" and m.lastFocusedRowIndex < rowCount - 1
      updatePanelPosition(m.lastFocusedRowIndex + 1)
    else if key = "up" and m.lastFocusedRowIndex > 0
      updatePanelPosition(m.lastFocusedRowIndex - 1)
    end if
  end if
  return false
end function