source_utils_skeleton.bs

import "pkg:/source/utils/misc.bs"

' The SKELETON-cell convention: a row that exists before its items have landed.
'
' Both cell components render skeletons — `JRRowItem` for Home's rows and `GridItem` for
' the item grid's genre rows — and several views build and guard against them. The whole
' convention lives here: the placeholder a producer appends, the predicate a consumer
' guards with, and the spinner the cells draw. A hand-typed `"Loading"` literal at any
' site is drift waiting to happen — a misspelled construction site would send a bare
' ContentNode into the media render path, where reading absent fields crashes the
' render thread.
namespace skeleton

  ' The `type` field value that marks a placeholder. Compare via isPlaceholder(),
  ' construct via createPlaceholder() — never hand-type this string elsewhere.
  const PLACEHOLDER_TYPE = "Loading"

  ' Builds the placeholder child a skeleton row carries so the RowList renders the row
  ' (a childless row is not laid out at all) at its correct slot size.
  function createPlaceholder() as object
    placeholder = CreateObject("roSGNode", "ContentNode")
    placeholder.addFields({ type: skeleton.PLACEHOLDER_TYPE })
    return placeholder
  end function

  ' True when `node` is a skeleton placeholder. Safe on invalid and on nodes with no
  ' `type` field (a bare ContentNode reads invalid there).
  function isPlaceholder(node as dynamic) as boolean
    if not isValid(node) then return false
    if not isValid(node.type) then return false
    return node.type = skeleton.PLACEHOLDER_TYPE
  end function

  ' Diameter of the spinner graphic, in px. Small enough to read as "this cell is
  ' waiting" rather than as content in its own right.
  const SPINNER_SIZE = 48

  ' Lazy-creates, centers and starts the spinner over `owner`'s poster area.
  '
  ' The node is passed in and handed back rather than kept in an `m` here: these run
  ' inside a component's scope, where `m` is that component instance. The caller owns
  ' the reference (one spinner per recycled cell); this only knows how to size, place
  ' and start it.
  '
  ' Creation work (sizing the graphic, parenting to `owner`) happens ONCE, in the
  ' create branch — cells are recycled and re-bound often, an appendChild of an
  ' already-parented child is a detach+reattach scene-graph mutation, and
  ' `control = "start"` on a running spinner visibly resets the animation. The
  ' steady-state path only re-places (slot sizes differ across rows) and re-shows.
  '
  ' @param owner - the cell node the spinner is parented to (constant per caller)
  ' @param spinner - the caller's existing spinner, or invalid on first use
  ' @param slotWidth - the cell's width
  ' @param posterHeight - the poster area's height (the spinner centers within it, NOT
  '        within the full cell, whose height includes the title area below the poster)
  ' @param posterTop - the poster area's y offset within the cell (0 when the poster
  '        sits at the cell origin, as in JRRowItem; GridItem offsets it)
  ' @return the spinner node, for the caller to hold onto across recycles
  function showSpinner(owner as object, spinner as dynamic, slotWidth as float, posterHeight as float, posterTop = 0 as float) as object
    justCreated = not isValid(spinner)
    if justCreated
      spinner = CreateObject("roSGNode", "Spinner")
      spinner.poster.width = skeleton.SPINNER_SIZE
      spinner.poster.height = skeleton.SPINNER_SIZE
      owner.appendChild(spinner)
    end if

    spinner.translation = [
      (slotWidth - skeleton.SPINNER_SIZE) / 2,
      posterTop + (posterHeight - skeleton.SPINNER_SIZE) / 2
    ]
    if justCreated or not spinner.visible
      spinner.visible = true
      spinner.control = "start"
    end if
    return spinner
  end function

  ' Hides and stops `spinner` if the caller ever created one.
  '
  ' Call this on every non-skeleton render, not just when leaving a skeleton: cells are
  ' RECYCLED, so the cell now rendering a real item may be the same node that showed a
  ' spinner a moment ago. A spinner left running would keep animating over real content.
  sub hideSpinner(spinner as dynamic)
    if not isValid(spinner) then return
    spinner.visible = false
    spinner.control = "stop"
  end sub

end namespace