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