source_utils_loadingStages.bs

import "pkg:/source/enums/LoadingKind.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"

' What the scene's loading spinner shows and tells the viewer as a wait goes on. Pure apart
' from `nowMs()`: JRScene passes what is waiting and the time, so the rule is the same on any
' screen and in a test.
'
' Three things can be waiting: the scene's own spinner (`startLoadingSpinner()`, a cast), the
' app waits the whole app is in (`appWaits`, a playback start), and the named waits the active
' screen has open (`screenWaits`). `showing()` decides between them.
'
' Nothing is said before STILL_LOADING_MS: a wait that short is normal, and even a 512 MB
' Stick shows a large library's first page in about 3.4 s from a server that keeps up
' (measured 2026-09-24, 8,643-movie library). Past it the viewer is told the app is still
' working, which is the usual ~10 s point where a wait needs feedback; past SERVER_SLOW_MS,
' that the wait is the server's. That is true of every kind that gets text, because each waits
' on a server answer. Kinds whose wait is not a server answer get no text at all.
namespace loadingStages

  const STILL_LOADING_MS = 8000
  const SERVER_SLOW_MS = 30000
  ' Past every stage; ageMs() stops counting here.
  const MAX_AGE_MS = 86400000

  ' Whether `kind` tells the viewer anything as its wait goes on.
  function hasStages(kind as string) as boolean
    return kind = LoadingKind.CONTENT or kind = LoadingKind.PLAYBACK_START
  end function

  ' The translation key for the text under the spinner after `elapsedMs`, or "" for none.
  function textKeyAt(kind as string, elapsedMs as integer) as string
    if not hasStages(kind) then return ""
    if elapsedMs >= SERVER_SLOW_MS then return translationKeys.MessageServerTakingAWhileToAnswer
    if elapsedMs >= STILL_LOADING_MS then return translationKeys.LabelStillLoading
    return ""
  end function

  ' Milliseconds from `elapsedMs` until the text for `kind` next changes, or -1 when it never
  ' will: the kind has no stages, or the last one is already showing.
  function msUntilNextChange(kind as string, elapsedMs as integer) as integer
    if not hasStages(kind) then return -1
    if elapsedMs < STILL_LOADING_MS then return STILL_LOADING_MS - elapsedMs
    if elapsedMs < SERVER_SLOW_MS then return SERVER_SLOW_MS - elapsedMs
    return -1
  end function

  ' What the spinner shows now: { visible, kind, elapsedMs, label }.
  '
  ' Waits the whole app is in (the scene's own spinner and its app waits) win while any is
  ' open, and the screen's waits show again, still counting from when they started, once the
  ' last of those ends. Among the waits that show, the text counts from the OLDEST one with
  ' stages, since that is how long the viewer has been waiting. `label` is the newest app
  ' wait's label (a channel's name), "" when none has one; the scene's own spinner carries its
  ' text in `loadingText` instead, so the scene chooses between the two.
  '
  ' @param sceneWait - { isLoading, kind, startedMs } of the scene's own spinner
  ' @param appWaits - the scene's open app waits (key -> { kind, label, startedMs }), or invalid
  ' @param waits - the active screen's `loadingWaits` (name -> { kind, startedMs }), or invalid
  function showing(sceneWait as object, appWaits as dynamic, waits as dynamic, now as dynamic) as object
    appLevel = []
    if sceneWait.isLoading then appLevel.push(sceneWait)
    newest = invalid
    if isValid(appWaits)
      for each key in appWaits
        entry = appWaits[key]
        appLevel.push(entry)
        if not isValid(newest)
          newest = entry
        else if entry.startedMs >= newest.startedMs
          newest = entry
        end if
      end for
    end if
    label = ""
    if isValid(newest) then label = newest.label

    chosen = longestWait(appLevel)
    if not isValid(chosen) and isValid(waits)
      screenLevel = []
      for each name in waits
        screenLevel.push(waits[name])
      end for
      chosen = longestWait(screenLevel)
    end if

    if not isValid(chosen) then return { visible: false, kind: LoadingKind.PLAIN, elapsedMs: 0, label: "" }
    return { visible: true, kind: chosen.kind, elapsedMs: ageMs(chosen.startedMs, now), label: label }
  end function

  ' The wait whose text shows among `entries` ({ kind, startedMs }): the oldest one with
  ' stages, or the oldest one when none has any. invalid when `entries` is empty.
  function longestWait(entries as object) as dynamic
    chosen = invalid
    for each entry in entries
      if not isValid(chosen)
        chosen = entry
      else if hasStages(entry.kind) <> hasStages(chosen.kind)
        if hasStages(entry.kind) then chosen = entry
      else if entry.startedMs < chosen.startedMs
        chosen = entry
      end if
    end for
    return chosen
  end function

  ' Milliseconds from `startedMs` to `now`, both from `nowMs()`, as an Integer the stage
  ' rules take. Clamped: a wall clock can step backwards (a time sync), and a wait a day old
  ' is past every stage anyway.
  function ageMs(startedMs as dynamic, now as dynamic) as integer
    age = now - startedMs
    if age < 0 then return 0
    if age > MAX_AGE_MS then return MAX_AGE_MS
    return age
  end function

  ' Wall-clock milliseconds as a LongInteger: the one clock a screen's waits and the scene both
  ' read, so a wait's age means the same on either side. Not an roTimespan, which belongs to
  ' one component and whose Integer milliseconds overflow after ~24.8 days of the app running;
  ' not UpTime(), a 32-bit Float, too coarse on a device that has been up for months. A wall
  ' clock can step (a time sync); the only effect is stage text shown early or late once.
  function nowMs() as dynamic
    now = CreateObject("roDateTime")
    return now.AsSecondsLong() * 1000 + now.GetMilliseconds()
  end function

end namespace