components_AppWaitHost.bs
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/roku_modules/promises/promises.brs"
import "pkg:/source/utils/appWaits.bs"
import "pkg:/source/utils/loadingStages.bs"
import "pkg:/source/utils/misc.bs"
sub init()
m.log = new log.Logger("AppWaitHost")
m.top.focusable = true
' The open waits: key -> { kind, label, startedMs, deadlineMs, promise, launchedFrom, hidden }.
' Each ends when its Promise settles (endWait).
m.waits = {}
m.waitCount = 0
m.limitTimer = m.top.findNode("limitTimer")
m.limitTimer.observeField("fire", "onLimit")
end sub
' While a wait hides the screen it was started from, keys come here instead of to that screen.
' Back goes on to JRScene's Back arbiter, which cancels the wait (cancelOnBack) or leaves a
' navigation in flight alone; every other key is ignored until the wait ends.
function onKeyEvent(key as string, _press as boolean) as boolean
if key = "back" then return false
return true
end function
' Opens a wait the whole app is in (appWaits.begin) and returns its Promise, which ends it when
' it settles. Only through callFunc, so waits opened off the render thread are applied in order
' here. A playback start hides the screen it was pressed on until it ends, the way the plain
' spinner always has, and holds the remote focus meanwhile (holdFocus).
'
' @param {string} kind - a LoadingKind with an app-wait time limit (appWaits.limitMs)
' @param {string} label - the text above the spinner, "" for none
' @return {object} the wait's Promise
function begin(kind as string, label as string) as object
promise = promises.create()
now = loadingStages.nowMs()
m.waitCount++
key = "w" + m.waitCount.toStr()
launchedFrom = m.global.activeRoutedView
entry = { kind: kind, label: label, startedMs: now, deadlineMs: now + appWaits.limitMs(kind), promise: promise, launchedFrom: launchedFrom, hidden: invalid }
if appWaits.hidesLaunchingScreen(kind) and isValid(launchedFrom) and not appWaits.isPlaybackScreen(launchedFrom)
' opacity, not visible: the router owns a screen's `visible` (it hides one it suspends and
' shows it on return), so hiding by opacity can never undo, or be undone by, the router.
launchedFrom.opacity = 0
entry.hidden = launchedFrom
holdFocus(launchedFrom)
' The overhang goes with the screen, in the same render pass: it shows the screen's parts,
' and the player this start goes to hides it anyway.
setOverhangShown(false)
end if
m.waits[key] = entry
promises.onFinally(promise, sub(ctx as object)
endWait(ctx.key)
end sub, { key: key })
armLimit()
publish()
return promise
end function
' A hidden screen must take no keys: a second OK would start a second launch, and arrows would
' move a focus nobody can see. So remember where its focus was, the way the router's suspend
' does (JRScreen.saveLastFocus), and hold the focus here until no wait hides a screen
' (releaseFocus). A screen outside the focus chain (a dialog has it) is left alone.
sub holdFocus(screen as object)
if not screen.isInFocusChain() then return
screen.callFunc("saveLastFocus")
m.top.setFocus(true)
end sub
' No wait hides a screen any more. If the focus is still held here, the active screen takes it
' back and restores its own (the router's handleFocus, which restores lastFocus). A player that mounted, or a
' screen the viewer went to, has taken it already.
sub releaseFocus()
if not m.top.hasFocus() then return
view = m.global.activeRoutedView
if isValid(view) then view.callFunc("handleFocus", {})
end sub
' A wait's Promise settled: drop it, show the screen it hid, give the focus back, and redraw the
' spinner.
sub endWait(key as string)
entry = m.waits[key]
if not isValid(entry) then return
m.waits.delete(key)
if isValid(entry.hidden) and not appWaits.hides(m.waits, entry.hidden) then entry.hidden.opacity = 1
if appWaits.hiding(m.waits).count() = 0
setOverhangShown(true)
releaseFocus()
end if
if entry.promise.promiseState = "rejected"
reason = ""
result = entry.promise.promiseResult
if isValid(result) and isValid(result.message) then reason = result.message
m.log.info("App wait failed", "kind", entry.kind, "reason", reason, "ageMs", loadingStages.ageMs(entry.startedMs, loadingStages.nowMs()))
end if
armLimit()
publish()
end sub
' Shows or hides the scene's overhang while a start hides a screen. By opacity, for the reason
' the screen is: JRScene's overhang controller owns the overhang's `visible` (a player hides it),
' so neither can undo the other. Looked up when first needed: the overhang is declared after
' this host in JRScene.xml.
sub setOverhangShown(shown as boolean)
if not isValid(m.overhang) then m.overhang = m.top.getScene().findNode("overhang")
if not isValid(m.overhang) then return
if shown
m.overhang.opacity = 1
else
m.overhang.opacity = 0
end if
end sub
' Back reached JRScene's arbiter with no navigation in flight: cancel every wait that hides the
' screen it was started from, so the viewer is back where they pressed Play: the same place
' Back from the player returns them to.
'
' @return {boolean} whether there was a wait to cancel
function cancelOnBack() as boolean
keys = appWaits.hiding(m.waits)
for each key in keys
entry = m.waits[key]
if isValid(entry) then appWaits.fail(entry.promise, "cancelled with Back")
end for
return keys.count() > 0
end function
' The viewer reached `newView`: end the playback starts they have left (appWaits.leftBehind).
sub failLeftBehind(newView as dynamic)
for each key in appWaits.leftBehind(m.waits, newView)
entry = m.waits[key]
if isValid(entry) then appWaits.fail(entry.promise, "left the screen it started on")
end for
end sub
' Arms the limit timer for the nearest time limit among the open waits.
sub armLimit()
m.limitTimer.control = "stop"
nextMs = appWaits.msUntilNextDeadline(m.waits, loadingStages.nowMs())
if nextMs < 0 then return
if nextMs < 1 then nextMs = 1
m.limitTimer.duration = nextMs / 1000
m.limitTimer.control = "start"
end sub
' Ends every wait past its time limit. Nothing should reach here: each wait is ended by the code
' that owns it, so one that gets here is a start nobody finished, a bug to find in the log.
sub onLimit()
now = loadingStages.nowMs()
for each key in appWaits.overdue(m.waits, now)
entry = m.waits[key]
m.log.error("App wait nobody ended reached its time limit", "kind", entry.kind, "label", entry.label, "ageMs", loadingStages.ageMs(entry.startedMs, now))
appWaits.fail(entry.promise, "time limit")
end for
armLimit()
end sub
' Publishes the open waits for JRScene's spinner: only what it draws, not the nodes.
sub publish()
shown = {}
for each key in m.waits
entry = m.waits[key]
shown[key] = { kind: entry.kind, label: entry.label, startedMs: entry.startedMs }
end for
m.top.openWaits = shown
end sub