source_utils_appWaits.bs

import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/enums/LoadingKind.bs"
import "pkg:/source/roku_modules/promises/promises.brs"
import "pkg:/source/utils/misc.bs"

' Waits the whole app is in, which outlive the screen that started them: a playback start
' runs from a press on one screen to a player on another. The scene's AppWaitHost keeps them,
' and the scene shows them as its loading spinner over the active screen's own waits
' (screenWaits).
'
' Each wait is a Promise, one per wait, and it ends when the promise settles:
'
'   wait = appWaits.begin(LoadingKind.PLAYBACK_START, channelName)
'   appWaits.done(wait)             ' the player holds a stream
'   appWaits.fail(wait, "failed")   ' it failed, or the viewer left
'
' The first settle wins and the rest do nothing, so every exit path can end the wait without
' checking whether another already has, and code holding one wait can never end another,
' which is what a wait ended by name could not promise (a player being replaced by the next
' channel's would end the next channel's). A wait nobody ends is ended by its kind's time
' limit and logged as an error.
namespace appWaits

  ' Opens a wait of `kind`, labeled `label` (shown above the spinner; "" for none), and returns
  ' its Promise, or invalid when there is no host to keep it.
  function begin(kind as string, label = "" as string) as dynamic
    scene = appScene()
    if not isValid(scene) then return invalid
    host = scene.findNode("appWaitHost")
    if not isValid(host) then return invalid
    return host.callFunc("begin", kind, label)
  end function

  ' Ends `appWait` because what it waited for arrived. Does nothing once it has ended.
  sub done(appWait as dynamic)
    if promises.isPromise(appWait) then promises.resolve(true, appWait)
  end sub

  ' Ends `appWait` because what it waited for will not arrive (`reason`, for the log). Does
  ' nothing once it has ended.
  sub fail(appWait as dynamic, reason as string)
    if promises.isPromise(appWait) then promises.reject({ message: reason }, appWait)
  end sub

  ' Whether `appWait` is a wait that has not ended yet.
  function isOpen(appWait as dynamic) as boolean
    return promises.isPromise(appWait) and not promises.isComplete(appWait)
  end function

  ' ---- AppWaitHost's rules for its open waits. Pure, so they are the same in a test. ----
  ' `entries` is the host's registry: key -> { kind, label, startedMs, deadlineMs, launchedFrom,
  ' hidden }, `hidden` being the screen a wait hides, or invalid.

  ' How long a wait of `kind` may stay open before its time limit ends it. 0 for a kind that is
  ' not an app wait, which the scene ends at once: an app-wait kind without a limit is a bug.
  function limitMs(kind as string) as dynamic
    if kind = LoadingKind.PLAYBACK_START then return timeouts.PLAYBACK_START_LIMIT_MS
    return 0
  end function

  ' Whether a wait of `kind` hides the screen it was started from while it is open. A playback
  ' start does, so the viewer sees the item's backdrop and the spinner rather than the screen
  ' they pressed Play on, unless that screen is itself a player (a channel switch).
  function hidesLaunchingScreen(kind as string) as boolean
    return kind = LoadingKind.PLAYBACK_START
  end function

  ' Whether `view` is a screen that plays: reaching it is where a playback start is meant to go.
  function isPlaybackScreen(view as dynamic) as boolean
    return isValid(view) and view.hasField("isPlaybackScreen") and view.isPlaybackScreen = true
  end function

  ' The keys of the open waits whose time limit has passed at `now`.
  function overdue(entries as object, now as dynamic) as object
    keys = []
    for each key in entries
      if entries[key].deadlineMs <= now then keys.push(key)
    end for
    return keys
  end function

  ' Milliseconds from `now` until the next time limit, or -1 when no wait is open.
  function msUntilNextDeadline(entries as object, now as dynamic) as integer
    nextMs = -1
    for each key in entries
      remaining = entries[key].deadlineMs - now
      if remaining < 0 then remaining = 0
      if nextMs < 0 or remaining < nextMs then nextMs = remaining
    end for
    return nextMs
  end function

  ' The keys of the playback starts the viewer has left, now that `newView` is the active
  ' screen: it is neither the screen the start was pressed on nor a player. The start has
  ' nowhere to go any more: Back during a Play all must not start playback on whatever
  ' screen the viewer went back to.
  function leftBehind(entries as object, newView as dynamic) as object
    keys = []
    if isPlaybackScreen(newView) then return keys
    for each key in entries
      entry = entries[key]
      if entry.kind <> LoadingKind.PLAYBACK_START then continue for
      launchedFrom = entry.launchedFrom
      if isValid(launchedFrom) and isValid(newView) and launchedFrom.isSameNode(newView) then continue for
      keys.push(key)
    end for
    return keys
  end function

  ' The keys of the open waits that hide the screen they were started from.
  function hiding(entries as object) as object
    keys = []
    for each key in entries
      if isValid(entries[key].hidden) then keys.push(key)
    end for
    return keys
  end function

  ' Whether an open wait hides `view`. A start that replaced another from the same screen
  ' keeps it hidden when the replaced one ends.
  function hides(entries as object, view as object) as boolean
    for each key in entries
      hidden = entries[key].hidden
      if isValid(hidden) and hidden.isSameNode(view) then return true
    end for
    return false
  end function

end namespace