source_utils_misc.bs

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

function isNodeEvent(msg, field as string) as boolean
  return type(msg) = "roSGNodeEvent" and msg.getField() = field
end function

function getMsgPicker(msg, subnode = "" as string) as object
  node = msg.getRoSGNode()
  ' Subnode allows for handling alias messages
  if subnode <> ""
    node = node.findNode(subnode)
  end if
  coords = node.rowItemSelected
  target = node.content.getChild(coords[0]).getChild(coords[1])
  return target
end function

function leftPad(base as string, fill as string, length as integer) as string
  while len(base) < length
    base = fill + base
  end while
  return base
end function

function ticksToHuman(ticks as longinteger) as string
  totalSeconds = int(ticks / 10000000)
  hours = stri(int(totalSeconds / 3600)).trim()
  minutes = stri(int((totalSeconds - (val(hours) * 3600)) / 60)).trim()
  seconds = stri(totalSeconds - (val(hours) * 3600) - (val(minutes) * 60)).trim()
  if val(hours) > 0 and val(minutes) < 10 then minutes = "0" + minutes
  if val(seconds) < 10 then seconds = "0" + seconds
  r = ""
  if val(hours) > 0 then r = hours + ":"
  r = r + minutes + ":" + seconds
  return r
end function

' Converts ticks to number of minutes
' eg. 3661 totalSeconds = 61
function ticksToMinutes(ticks as longinteger) as longinteger
  totalSeconds = int(ticks / 10000000)
  return int(totalSeconds / 60)
end function

' Converts seconds to a human readable timestamp. Used for progress bar during playback
' eg. 3661 seconds = "01:01:01"
function secondsToTimestamp(totalSeconds as integer, addLeadingMinuteZero as boolean) as string
  humanTime = ""
  hours = stri(int(totalSeconds / 3600)).trim()
  minutes = stri(int((totalSeconds - (val(hours) * 3600)) / 60)).trim()
  seconds = stri(totalSeconds - (val(hours) * 3600) - (val(minutes) * 60)).trim()

  if val(hours) > 0 or addLeadingMinuteZero
    if val(minutes) < 10
      minutes = "0" + minutes
    end if
  end if

  if val(seconds) < 10
    seconds = "0" + seconds
  end if

  if val(hours) > 0
    hours = hours + ":"
  else
    hours = ""
  end if

  humanTime = hours + minutes + ":" + seconds

  return humanTime
end function

' Computes a broadcast program's elapsed-time percentage.
' Programs are live broadcasts, not streamed media, so "progress" is
' wall-clock elapsed, not UserData.PlayedPercentage.
' Returns 0 (not 100) for finished programs because JRPoster only cleanly
' removes the VideoProgressBar child when playedPercentage = 0.
' @param playStart - epoch seconds when the program started (0 = unknown)
' @param playDuration - program length in seconds (0 = unknown)
' @param nowSeconds - current epoch seconds (injected for testability)
' @return float in (0, 100) while airing, or 0 when not airing / invalid
function computeProgramBroadcastProgress(playStart as integer, playDuration as integer, nowSeconds as integer) as float
  if playStart <= 0 or playDuration <= 0 then return 0.0
  elapsed = nowSeconds - playStart
  if elapsed <= 0 then return 0.0
  if elapsed >= playDuration then return 0.0
  return (elapsed / playDuration) * 100.0
end function

' Format time as 12 or 24 hour format based on system clock setting
' The app's clock (Clock.bs) does not use this; it formats times shown on screens and in dialogs
function formatTime(time) as string
  hours = time.getHours()
  minHourDigits = 1
  if m.global.device.clockFormat = "12h"
    if hours = 0
      hours = 12
    else if hours = 12
      hours = 12
    else if hours > 12
      hours = hours - 12
    end if
  else
    ' For 24hr Clock, pad hours to 2 digits
    minHourDigits = 2
  end if

  return Substitute("{0}:{1}", leftPad(stri(hours).trim(), "0", minHourDigits), leftPad(stri(time.getMinutes()).trim(), "0", 2))

end function

' Returns "am" or "pm" for 12h clock, empty string for 24h.
' Use for times the reader has no on-screen reference for: a broadcast range that may be hours
' or days away. For a time anchored to "now", prefer formatDisambiguatedTime().
function getTimePeriod(time as object) as string
  if m.global.device.clockFormat <> "12h" then return ""
  if time.getHours() >= 12
    return "pm"
  end if
  return "am"
end function

' True when two times fall in opposite halves of the day, that is, one of them is on the far side of
' noon or midnight from the other. This is exactly when a 12h time needs an am/pm suffix to be
' readable against the other: same half means the suffix carries no information.
' Hours are 0-23 local. Injected (rather than read from roDateTime) so this stays unit-testable.
function meridianDiffers(timeHours as integer, nowHours as integer) as boolean
  return (timeHours >= 12) <> (nowHours >= 12)
end function

' How far ahead a reader can still resolve a bare 12h time by comparing it to the clock. Dropping
' the suffix asserts "the nearest clock face showing these digits", which stops being the time we
' meant once the meridian comes back around: at 13:00 a 23-hour runtime ends at 12:00 pm, the same
' half of the day, so the suffix rule alone would omit it and "Ends at 12:00" would read as noon
' today. Past this horizon the suffix always earns its place.
const MERIDIAN_INFERENCE_HORIZON_SECONDS = 43200 ' 12h

' Returns "am"/"pm" only when the suffix disambiguates the time against the current clock,
' empty string otherwise (and always empty on a 24h clock).
'
' Why this exists: the clock the reader compares against renders bare in 12h mode: "10:32",
' never "10:32 pm" (see Clock.bs, used by both the overhang and the playback OSD). A time
' measured against that clock therefore doesn't need a meridian either, unless it crosses noon
' or midnight, or sits beyond the horizon above. Those are the cases the reader can't infer at
' a glance.
' Only for now-anchored times; see getTimePeriod() for absolute ones.
' @param time - a LOCAL roDateTime (caller must have applied toLocalTime())
function getDisambiguatingTimePeriod(time as object) as string
  if m.global.device.clockFormat <> "12h" then return ""

  now = CreateObject("roDateTime")
  now.toLocalTime()

  ' Both sides have had toLocalTime() applied, so the timezone offset cancels out of the delta.
  if time.asSeconds() - now.asSeconds() >= MERIDIAN_INFERENCE_HORIZON_SECONDS then return getTimePeriod(time)

  if not meridianDiffers(time.getHours(), now.getHours()) then return ""
  return getTimePeriod(time)
end function

' The one way to render a time the reader will read off against an on-screen clock: the "Ends at"
' value in ItemDetails and in the playback OSD. Bare when the half of the day is inferable,
' suffixed when it isn't, so the same end time reads identically on both screens.
' @param time - a LOCAL roDateTime (caller must have applied toLocalTime())
function formatDisambiguatedTime(time as object) as string
  periodText = getDisambiguatingTimePeriod(time)
  if periodText = "" then return formatTime(time)
  return formatTime(time) + " " + periodText
end function

' convert iso date string to a human readable date string
' eg. "March 13th, 2014"
function formatIsoDateVideo(isoDateString as string) as string
  ' Parse the ISO date string "2014-03-13T00:00:00.0000000Z"
  dateParts = isoDateString.Split("T")[0].Split("-")
  year = dateParts[0]
  month = dateParts[1].ToInt()
  day = dateParts[2].ToInt()

  ' Month names array
  monthNames = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December"
  ]

  ' Get month name
  monthName = monthNames[month - 1]

  ' Add ordinal suffix to day
  dayWithSuffix = addOrdinalSuffix(day)

  ' Return formatted string
  return monthName + " " + dayWithSuffix + ", " + year
end function

function addOrdinalSuffix(day as integer) as string
  dayStr = day.ToStr().Trim()

  ' Special cases for 11th, 12th, 13th
  if day >= 11 and day <= 13
    return dayStr + "th"
  end if

  ' Check last digit
  lastDigit = day mod 10
  if lastDigit = 1
    return dayStr + "st"
  else if lastDigit = 2
    return dayStr + "nd"
  else if lastDigit = 3
    return dayStr + "rd"
  else
    return dayStr + "th"
  end if
end function

' Walk to the deepest focused descendant. Dialog helpers capture this before
' presenting, so closing can restore focus exactly where the user left it
' (source/utils/dialogs.bs presentOverlayDialog -> returnFocusTo).
function lastFocusedChild(obj as object) as object
  if isValid(obj)
    child = obj
    for i = 0 to obj.getChildCount()
      if isValid(obj.focusedChild)
        child = child.focusedChild
      end if
    end for
    return child
  else
    return invalid
  end if
end function

' Returns the full-screen view currently on top: the router-mounted view tracked on
' m.global.activeRoutedView. The whole app is routed: pre-login, content, and playback are all
' router views in JRScene's outlet. Callers use it to resolve "what's on screen".
function getActiveView() as object
  return m.global.activeRoutedView
end function

' Restore focus to a view's saved descendant (lastFocus) if it's still valid, else focus the
' view root. The canonical focus-restore rule shared by the JRScreen lifecycle bridge
' (onScreenShown / handleFocus) and main.bs's side-panel handlers. No-op on an invalid view.
sub restoreFocus(view as object)
  if not isValid(view) then return
  if isValid(view.lastFocus)
    view.lastFocus.setFocus(true)
  else
    view.setFocus(true)
  end if
end sub

' Maps a selected content item to its route: a named-route arg { name, params } for
' sgRouter.navigateTo. Pure (no router call) so it can live here in commonly-imported misc.bs;
' the routed view that owns the selection calls navigateTo itself (passing the rich node as
' `context` to avoid a re-fetch). Returns invalid for items that are not view navigation
' (a Chapter starts playback); the caller handles those via the playback path (quickPlayNode).
function routeForItem(item as object) as object
  if not isValid(item) or not isValidAndNotEmpty(item.type) or not isValidAndNotEmpty(item.id) then return invalid

  itemType = item.type
  ' Normalize Live TV aliases to their canonical forms
  if itemType = "LiveTvChannel" then itemType = "TvChannel"
  if itemType = "LiveTvProgram" or itemType = "TvProgram" then itemType = "Program"

  ' Chapter is playback (jump to a position in the parent video), not navigation
  if itemType = "Chapter" then return invalid

  ' Library / grid container types open the scoped grid view
  if itemType = "CollectionFolder" or itemType = "UserView" or itemType = "Folder" or itemType = "Channel" or itemType = "Genre" or itemType = "MusicGenre" or itemType = "Studio"
    return { name: "library", params: { id: item.id } }
  end if

  ' Everything else routes to the type-agnostic details screen
  return { name: "details", params: { type: itemType, id: item.id } }
end function

' take an incomplete url string and use it to make educated guesses about
' the complete url. then tests these guesses to see if it can find a jf server
' returns the url of the server it found, or an empty string
function inferServerUrl(url as string) as string
  ' if this server is already stored, use the value directly
  ' the server had to get resolved in the first place to get into the registry
  saved = getSetting("saved_servers")
  if isValid(saved)
    savedServers = ParseJson(saved)
    if isValid(savedServers.lookup(url)) then return url
  end if

  port = CreateObject("roMessagePort")
  hosts = CreateObject("roAssociativeArray")
  reqs = []
  candidates = urlCandidates(url)
  for each endpoint in candidates
    req = CreateObject("roUrlTransfer")
    reqs.push(req) ' keep in scope outside of loop, else -10001
    req.seturl(endpoint + "/system/info/public")
    req.setMessagePort(port)
    hosts.addreplace(req.getidentity().ToStr(), endpoint)
    if endpoint.Left(8) = "https://"
      req.setCertificatesFile("common:/certs/ca-bundle.crt")
    end if
    req.AsyncGetToString()
  end for
  handled = 0
  timeout = CreateObject("roTimespan")
  if hosts.count() > 0
    while timeout.totalseconds() < 15
      resp = wait(0, port)
      if type(resp) = "roUrlEvent"
        ' TODO
        ' if response code is a 300 redirect then we should return the redirect url
        ' Make sure this happens or make it happen
        if resp.GetResponseCode() = 200 and isJellyfinServer(resp.GetString())
          selectedUrl = hosts.lookup(resp.GetSourceIdentity().ToStr())
          print "Successfully inferred server URL: " selectedUrl
          return selectedUrl
        end if
      end if
      handled += 1
      if handled = reqs.count()
        print "inferServerUrl in utils/misc.brs failed to find a server from the string " url " but did not timeout."
        return ""
      end if
    end while
    print "inferServerUrl in utils/misc.brs failed to find a server from the string " url " because it timed out."
  end if
  return ""
end function

' this is the "educated guess" logic for inferServerUrl that generates a list of complete url's as candidates
' for the tests in inferServerUrl. takes an incomplete url as an arg and returns a list of extrapolated
' full urls.
function urlCandidates(input as string)
  if input.endswith("/") then input = input.Left(len(input) - 1)
  url = parseUrl(input)
  if not isValid(url[1])
    ' a proto wasn't declared
    url = parseUrl("none://" + input)
  end if
  ' if the proto is still invalid then the string is not valid
  if not isValid(url[1]) then return []
  proto = url[1]
  host = url[2]
  port = url[3]
  path = url[4]
  protoCandidates = []
  supportedProtos = ["http:", "https:"] ' appending colons because the regex does
  if proto = "none:" ' the user did not declare a protocol
    ' try every supported proto
    for each supportedProto in supportedProtos
      protoCandidates.push(supportedProto + "//" + host)
    end for
  else
    protoCandidates.push(proto + "//" + host) ' but still allow arbitrary protocols if they are declared
  end if
  finalCandidates = []
  if isValid(port) and port <> "" ' if the port is defined, use only that
    for each candidate in protoCandidates
      finalCandidates.push(candidate + port + path)
    end for
  else ' the port wasnt declared so use default jellyfin and proto ports
    for each candidate in protoCandidates:
      ' proto default
      finalCandidates.push(candidate + path)
      ' jellyfin defaults
      if candidate.startswith("https")
        finalCandidates.push(candidate + ":8920" + path)
      else if candidate.startswith("http")
        finalCandidates.push(candidate + ":8096" + path)
      end if
    end for
  end if
  return finalCandidates
end function

sub setFieldTextValue(field, value)
  node = m.top.findNode(field)
  if not isValid(node) or not isValid(value) then return

  ' Handle non strings... Which _shouldn't_ happen, but hey
  if type(value) = "roInt" or type(value) = "Integer"
    value = str(value).trim()
  else if type(value) = "roFloat" or type(value) = "Float"
    value = str(value).trim()
  else if type(value) <> "roString" and type(value) <> "String"
    value = ""
  end if

  node.text = value
end sub

' Returns whether or not passed value is valid
function isValid(input as dynamic) as boolean
  return input <> invalid
end function

' isChainValid: Returns whether or not all the properties in the passed property chain are valid.
' Stops evaluating at first found false value
'
' @param {dynamic} root - high-level object to test property chain against
' @param {string} propertyPath - chain of properties under root object to test
' @return {boolean} indicating if all properties in chain are valid
function isChainValid(root as dynamic, propertyPath as string) as boolean
  rootPath = root
  properties = propertyPath.Split(".")

  if not isValid(rootPath) then return false

  ' Root path is valid, and no properties were passed. Return state of root
  if properties.count() = 0 then return true
  if properties[0] = "" then return true

  if not isValid(rootPath.lookup(properties[0])) then return false

  rootPath = rootPath.lookup(properties[0])

  properties.shift()

  if properties.count() <> 0
    nextPath = properties.join(".")
    return isChainValid(rootPath, nextPath)
  end if

  return true
end function

' Returns whether or not passed value is valid and not empty
' Accepts a string, or any countable type (arrays and lists)
function isValidAndNotEmpty(input as dynamic) as boolean
  if not isValid(input) then return false
  ' Use roAssociativeArray instead of list so we get access to the doesExist() method
  countableTypes = { "array": 1, "list": 1, "roarray": 1, "roassociativearray": 1, "rolist": 1 }
  inputType = LCase(type(input))
  if inputType = "string" or inputType = "rostring"
    trimmedInput = input.trim()
    return trimmedInput <> ""
  else if inputType = "rosgnode"
    inputId = input.id
    return isValid(inputId)
  else if countableTypes.doesExist(inputType)
    return input.count() > 0
  else
    print "Called isValidAndNotEmpty() with invalid type: ", inputType
    return false
  end if
end function

' Returns an array from a url = [ url, proto, host, port, subdir+params ]
' If port or subdir are not found, an empty string will be added to the array
' Proto must be declared or array will be empty
function parseUrl(url as string) as object
  rgx = CreateObject("roRegex", "^(.*:)//([A-Za-z0-9\-\.]+)(:[0-9]+)?(.*)$", "")
  return rgx.Match(url)
end function

' Returns true if the string is a loopback, such as 'localhost' or '127.0.0.1'
function isLocalhost(url as string) as boolean
  ' https://stackoverflow.com/questions/8426171/what-regex-will-match-all-loopback-addresses
  rgx = CreateObject("roRegex", "^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$", "i")
  return rgx.isMatch(url)
end function

'
' Returns whether or not a version number (for example 10.7.7) is greater or equal
' to some minimum version allowed (for example 10.8.0)
function versionChecker(versionToCheck as string, minVersionAccepted as string)
  leftHand = CreateObject("roLongInteger")
  rightHand = CreateObject("roLongInteger")

  regEx = CreateObject("roRegex", "\.", "")
  version = regEx.Split(versionToCheck)
  if version.Count() < 3
    for i = version.Count() to 3 step 1
      version.AddTail("0")
    end for
  end if

  minVersion = regEx.Split(minVersionAccepted)
  if minVersion.Count() < 3
    for i = minVersion.Count() to 3 step 1
      minVersion.AddTail("0")
    end for
  end if

  leftHand = (version[0].ToInt() * 10000) + (version[1].ToInt() * 100) + version[2].ToInt()
  rightHand = (minVersion[0].ToInt() * 10000) + (minVersion[1].ToInt() * 100) + minVersion[2].ToInt()

  return leftHand >= rightHand
end function

'
' Returns the current API version from global state with a safe default of 2.
' Works in both class and non-class contexts (components, namespaces, functions).
' This is the single source of truth for reading m.global.server.apiVersion.
'
' @returns {integer} API version (default: 2 if not set, unset (0), or invalid)
function getApiVersionFromGlobal() as integer
  ' Try m.global first (works in namespaces, functions, components)
  ' Structural guarantee: if m.global exists, server.apiVersion exists
  ' Treat 0 as an unset/initial value (default before server.Populate() and in tests)
  if isValid(m.global) and isValid(m.global.server) and m.global.server.apiVersion <> 0
    return m.global.server.apiVersion
  end if

  ' Fall back to GetGlobalAA() (required for classes where m.global isn't available)
  ' Structural guarantee: if globalRef exists, server.apiVersion exists
  globalRef = GetGlobalAA().global
  if isValid(globalRef) and isValid(globalRef.server) and globalRef.server.apiVersion <> 0
    return globalRef.server.apiVersion
  end if

  ' Default to 2 (modern servers) if not found or still 0 (unset)
  return 2
end function

'
' Maps a Jellyfin server version string to an API version integer used by the SDK dispatcher.
' V1: Covers pre-10.9 servers (10.7.x, 10.8.x): uses /Users/{userId}/ path prefix
' V2: Covers 10.9+ servers: endpoints moved to top-level, userId passed as query param
' Returns 1 if serverVersion is empty/invalid (safe fallback to legacy paths)
function resolveApiVersion(serverVersion as string) as integer
  if not isValidAndNotEmpty(serverVersion)
    return 1
  end if
  if versionChecker(serverVersion, "10.9.0")
    return 2
  end if
  return 1
end function

function findNodeBySubtype(node, subtype)
  foundNodes = []

  for each child in node.getChildren(-1, 0)
    if lcase(child.subtype()) = "group"
      return findNodeBySubtype(child, subtype)
    end if

    if lcase(child.subtype()) = lcase(subtype)
      foundNodes.push({
        node: child,
        parent: node
      })
    end if
  end for

  return foundNodes
end function

function AssocArrayEqual(Array1 as object, Array2 as object) as boolean
  if not isValid(Array1) or not isValid(Array2)
    return false
  end if

  if not Array1.Count() = Array2.Count()
    return false
  end if

  for each key in Array1
    if not Array2.DoesExist(key)
      return false
    end if

    if Array1[key] <> Array2[key]
      return false
    end if
  end for

  return true
end function

' Search string array for search value. Return if it's found
function inArray(haystack, needle) as boolean
  valueToFind = needle

  if LCase(type(valueToFind)) <> "rostring" and LCase(type(valueToFind)) <> "string"
    valueToFind = str(needle)
  end if

  valueToFind = lcase(valueToFind)

  for each item in haystack
    if lcase(item) = valueToFind then return true
  end for

  return false
end function

function toString(input) as string
  if LCase(type(input)) = "rostring" or LCase(type(input)) = "string"
    return input
  end if

  return str(input)
end function

' Text from a field that may not exist.
'
' toString() faults with a Type Mismatch on Invalid, because it falls through to
' Str(), so it can only be used where the value is known to be present. Optional
' fields (most of a Jellyfin MediaStream, every field a newer server added) need
' this instead: absent reads as "", which is what a caller building display text
' out of them wants anyway.
'
' @param {dynamic} input - any value, including Invalid
' @returns {string} - the text, or "" when there is nothing there
function textOrEmpty(input as dynamic) as string
  if not isValid(input) then return ""
  return toString(input)
end function

' True only for a real boolean true.
'
' `value = true` faults &h18 on a non-boolean operand, so the TYPE has to be
' established before the comparison. A field the server omitted dereferences to
' Invalid, which is exactly the case a bare `if stream.IsAnamorphic` would fault on.
' ParseJson yields an unboxed "Boolean" and an AA literal a boxed "roBoolean".
' Both are real booleans, so both have to pass.
'
' Lives here, beside isValid / mediaNumber / textOrEmpty, because it is the same
' kind of thing: a coercion for a field that may not be there. It used to live in
' fallbackFont.bs, which meant reaching it from anywhere else imported a module
' about fonts to get a boolean guard. That was enough friction that the playback report
' grew a byte-identical private copy rather than pay it.
'
' @param {dynamic} value - any value, including Invalid
' @returns {boolean} - true when value is boolean true; false for every other type or value
function isBooleanTrue(value as dynamic) as boolean
  valueType = LCase(type(value))
  return (valueType = "boolean" or valueType = "roboolean") and value = true
end function

' A number from a field that may be a float, an integer, a string, or absent.
'
' The FLOAT-PRESERVING counterpart to mediaNumber, and the distinction matters in
' both directions. mediaNumber truncates to a 32-bit Integer, which is right for
' Height / Channels / SampleRate and wrong for two kinds of field:
'
'   - Fractional ones (frame rates, completion percentages), where truncating
'     destroys the figure outright.
'   - Large ones (a byte count passes 2^31 at ~2.1 GB, and RunTimeTicks for a
'     three-hour film is ~1.08e11), where truncating overflows.
'
' @param {dynamic} value - a numeric field off a media object, a settings node, or a literal
' @returns {float} - the numeric value, or 0.0 for invalid / AA / array / boolean input.
'   Every caller today passes a media field, so this covers what actually reaches
'   it in practice. An unhandled type (roSGNode, roDateTime, a function) would
'   still fault on `return value as float` rather than fall back to 0.0.
function decimalOrZero(value as dynamic) as float
  if not isValid(value) then return 0.0

  valueType = LCase(type(value))
  if valueType = "string" or valueType = "rostring" then return Val(value)
  if valueType = "roassociativearray" or valueType = "roarray" then return 0.0
  if valueType = "boolean" or valueType = "roboolean" then return 0.0

  return value
end function

' The app's Scene node, cached on first use.
'
' Reaching the scene means walking up from m.top, so every caller that needed it
' had grown its own copy of this three-line cache. m is per-component-scope, so
' the cache is per component, which is what we want: each component resolves the
' scene once and reuses it.
function appScene() as object
  if not isValid(m.scene)
    m.scene = m.top.getScene()
  end if
  return m.scene
end function

'
' startLoadingSpinner: Start a loading spinner and attach it to the main JRScene.
' Displays an invisible ProgressDialog node by default to disable keypresses while loading.
'
' @param {boolean} [disableRemote=true]
sub startLoadingSpinner(disableRemote = true as boolean, loadingText = "" as string)
  scene = appScene()

  ' Everything JRScene.onIsLoadingChanged reads must be set before isLoading, which is
  ' therefore its own write. One setFields: main.bs and loginRouter call this off the render
  ' thread, where each write to the scene is a rendezvous. The kind is written too, so this
  ' spinner never inherits the kind of one before it (a cast's).
  scene.setFields({ isRemoteDisabled: disableRemote, loadingText: loadingText, loadingKind: LoadingKind.PLAIN })
  scene.isLoading = true
end sub

sub stopLoadingSpinner()
  scene = appScene()

  scene.loadingText = ""
  scene.isRemoteDisabled = false
  scene.isLoading = false
end sub

' displayToast: Display a transient toast notification via the scene-level Toast component.
' @param {string} message - The message to display
' @param {string} [toastType="error"] - "error", "success", or "info"
sub displayToast(message as string, toastType = "error" as string)
  scene = appScene()
  if isValid(scene)
    scene.callFunc("showToast", message, toastType)
  end if
end sub

' accepts the raw json string of /system/info/public and returns
' a boolean indicating if ProductName is "Jellyfin Server"
function isJellyfinServer(systemInfo as object) as boolean
  data = ParseJson(systemInfo)
  if isValid(data) and isValid(data.ProductName)
    return LCase(data.ProductName) = m.global.constants.jellyfinServerResponse
  end if
  return false
end function

' Check if a specific value is inside of an array
function arrayHasValue(arr as object, value as dynamic) as boolean
  for each entry in arr
    if entry = value
      return true
    end if
  end for
  return false
end function

' Filters an array of nodes, excluding any whose nodeKey value appears in excludeArray
'
' @param {object} nodeArray - Array of nodes to filter
' @param {string} nodeKey - Field name to check on each node
' @param {object} excludeArray - Array of values to exclude
' @return {object} Filtered array with excluded nodes removed
function filterNodeArray(nodeArray as object, nodeKey as string, excludeArray as object) as object
  ' Validate nodeArray
  if not isValid(nodeArray) then return []
  nodeType = Type(nodeArray)
  if nodeType <> "roArray" and nodeType <> "Array" then return []

  ' Validate excludeArray - treat invalid/non-array as empty (no filtering)
  if not isValid(excludeArray) then return nodeArray
  excludeType = Type(excludeArray)
  if excludeType <> "roArray" and excludeType <> "Array" then return nodeArray

  ' Performance shortcut
  if excludeArray.IsEmpty() then return nodeArray

  newNodeArray = []
  for each node in nodeArray
    excludeThisNode = false
    for each exclude in excludeArray
      if node[nodeKey] = exclude
        excludeThisNode = true
        exit for
      end if
    end for
    if not excludeThisNode
      newNodeArray.Push(node)
    end if
  end for
  return newNodeArray
end function

' Takes an array of data, shuffles the order, then returns the array
' uses the Fisher-Yates shuffling algorithm
function shuffleArray(array as object) as object
  for i = array.count() - 1 to 1 step -1
    j = Rnd(i + 1) - 1
    t = array[i] : array[i] = array[j] : array[j] = t
  end for
  return array
end function

' convert value to boolean and return value
function toBoolean(value as dynamic) as dynamic
  if not isValid(value) then return invalid

  valueType = Type(value)

  ' Return booleans unchanged
  if valueType = "roBoolean" or valueType = "Boolean" then return value

  ' Only process strings - handle both "roString" (runtime) and "String" (compile-time)
  if valueType <> "roString" and valueType <> "String" then return value

  ' Case-insensitive comparison for string boolean values
  lowerValue = LCase(value)
  if lowerValue = "true"
    return true
  else if lowerValue = "false"
    return false
  else
    return value
  end if
end function

' A numeric field from a Jellyfin media object, as an integer.
'
' Jellyfin's numeric fields (Height, Width, Channels, BitRate, Level) arrive as
' integers from ParseJson, but the app has always handled them arriving as strings
' too. isWithinH264HardwareCeiling (Height and Width) and buildPlaybackInfoPostData
' in source/api/items.bs (the audio stream's Channels and the transcoding profile's
' MaxAudioChannels) still carry their own inline copies of this coercion. This is
' that coercion, once.
'
' Returns 0 for anything missing or non-numeric, so callers can compare without a
' validity check first: a stream that does not report a height has no height,
' and 0 is the honest reading of that for every comparison the report makes.
'
' @param {dynamic} value - a field off a MediaStream / MediaSourceInfo / TranscodingInfo
' @returns {integer} - the numeric value, or 0
' The type names below were measured, not reasoned, and the same integer reports
' three different names depending on where it came from. On a Stick 4K
' (Roku OS 15.3.4):
'
'   type(2)                  -> "Integer"    an unboxed literal
'   type(parsed.someInt)     -> "Integer"    read out of an AA from ParseJson
'   type(aa.someInt)         -> "roInteger"  boxed, read out of an AA literal
'   type(node.someIntField)  -> "roInt"      read off a SceneGraph node field
'
' (The ParseJson row, and the AA-literal and node rows again, measured 2026-10-11
' on a Stick 4K, including an AA read back through an `assocarray` node field.)
'
' All three are listed because this function is handed all three: MediaStream
' fields arrive from ParseJson ("Integer") or read back through a node field
' ("roInt"), user settings arrive from a JellyfinUserSettings node, and callers and
' specs pass literals, an AA literal among them ("roInteger"). Enumerating only the
' plausible-looking names is what produced the bug this function was extracted to
' fix: `type(x) = "Integer"` against a node field is always false, so
' GetBitRateLimit discarded every user-set bitrate limit and silently substituted
' Roku's hardware ceiling.
'
' Not for byte counts. The return type is a 32-bit Integer, so anything above
' 2,147,483,647 overflows, and MediaSourceInfo.Size is a byte count, which passes
' that at ~2.1 GB. Every other numeric media field (Height, Width, Channels,
' BitRate, Level, SampleRate, BitDepth) is comfortably inside the range. Format
' sizes from the raw value in floating point instead.
'
' @param {dynamic} value - a numeric field off a media object, a settings node, or a literal
' @returns {integer} - the numeric value, or 0 for anything missing or non-numeric
function mediaNumber(value as dynamic) as integer
  if not isValid(value) then return 0

  valueType = LCase(type(value))
  if valueType = "string" or valueType = "rostring" then return Int(Val(value))
  if valueType = "integer" or valueType = "rointeger" or valueType = "roint" then return value
  if valueType = "float" or valueType = "rofloat" then return Int(value)
  if valueType = "double" or valueType = "rodouble" then return Int(value)
  if valueType = "longinteger" or valueType = "rolonginteger" then return Int(value)

  return 0
end function

' getFirstVideoStream: Finds the first video stream in MediaStreams array
' MediaStreams[0] is not always a video stream - it could be subtitle or audio
'
' @param {roArray} mediaStreams - Array of media streams from playback info
' @return {object} First video stream object, or invalid if not found
function getFirstVideoStream(mediaStreams as object) as dynamic
  if not isValid(mediaStreams) then return invalid

  for each stream in mediaStreams
    if isValid(stream.Type) and LCase(stream.Type) = "video"
      return stream
    end if
  end for

  return invalid
end function

' The MediaSource this playback is actually using, off the cached
' /PlaybackInfo response.
'
' One definition because two surfaces describe the same streams (the
' playback-info report in PlayerHostView and the playback-error Details readout
' in VideoPlayerView), and two copies of "which source are we describing" is
' exactly how they would come to disagree about one stream.
'
' MediaSources[0] is correct rather than merely convenient: the app asks the
' server for ONE source (the selected mediaSourceId), so the response carries
' the one that was negotiated for this play session, not a menu to pick from.
'
' @param {dynamic} cachedPlaybackInfo - the cached ItemPostPlaybackInfo response
' @return {object} the MediaSource AA, or invalid when nothing is cached
function primaryMediaSource(cachedPlaybackInfo as dynamic) as dynamic
  if not isValid(cachedPlaybackInfo) then return invalid
  if not isValidAndNotEmpty(cachedPlaybackInfo.MediaSources) then return invalid

  return cachedPlaybackInfo.MediaSources[0]
end function

' Converts a Jellyfin audio stream index to a Roku audioTrack Track identifier.
' For MKV direct play, Roku exposes tracks using 1-based MKV track numbers that
' include ALL track types (video, audio, subtitle). Jellyfin's stream Index is 0-based
' and also counts all track types, so Roku Track = Jellyfin Index + 1.
'
' When availableAudioTracks is provided (populated by Roku during active playback),
' matches by language with ordinal disambiguation: among same-language Jellyfin streams
' (in original order), find the position N of the chosen index, then return the Nth
' same-language Roku track. This avoids returning the wrong track when multiple streams
' share a language (for example main + commentary, both English: issue #500).
' Falls back to Index + 1 mapping when availableAudioTracks is not available or no
' ordinal match can be found (for example before playback starts or for non-MKV containers).
'
' @param {integer} jellyfinAudioIndex - The Jellyfin audio stream index
' @param {dynamic} audioStreamsArray - Jellyfin fullAudioData array (must have .index and .language fields)
' @param {dynamic} [availableAudioTracks=invalid] - Roku's availableAudioTracks array (has .track and .language)
' @returns {string} - Roku track identifier string for audioTrack field
function getRokuAudioTrackPosition(jellyfinAudioIndex as integer, audioStreamsArray as dynamic, availableAudioTracks = invalid as dynamic) as string
  if isValid(availableAudioTracks) and isValid(audioStreamsArray)
    ' Find the language of the selected Jellyfin stream
    selectedLanguage = ""
    for each stream in audioStreamsArray
      if isValid(stream.index) and stream.index = jellyfinAudioIndex
        if isValid(stream.language)
          selectedLanguage = LCase(stream.language)
        end if
        exit for
      end if
    end for

    if selectedLanguage <> ""
      ' Position of the chosen stream among same-language Jellyfin streams
      jfPosition = -1
      jfSeen = 0
      for each stream in audioStreamsArray
        if isValid(stream.language) and LCase(stream.language) = selectedLanguage
          if isValid(stream.index) and stream.index = jellyfinAudioIndex
            jfPosition = jfSeen
            exit for
          end if
          jfSeen += 1
        end if
      end for

      ' Take the Nth same-language Roku track
      if jfPosition >= 0
        rokuSeen = 0
        for each rokuTrack in availableAudioTracks
          if isValid(rokuTrack.track) and isValid(rokuTrack.language) and LCase(rokuTrack.language) = selectedLanguage
            if rokuSeen = jfPosition then return rokuTrack.track
            rokuSeen += 1
          end if
        end for
      end if
    end if
  end if

  ' Fallback: Roku Track = Jellyfin Index + 1 (MKV 1-based track numbering)
  return (jellyfinAudioIndex + 1).ToStr()
end function

' resolveSplashScreen: Resolves whether to show the splash screen on User Select screen
'
' Checks JellyRock global setting. If "disabled", never shows splash. Otherwise follows
' server branding configuration. Ensures a valid boolean is always returned.
'
' @param {dynamic} globalSplashSetting - JellyRock global splash screen setting ("enabled", "disabled", or invalid)
' @param {dynamic} serverSplashEnabled - Server's splashscreenEnabled setting (boolean or invalid)
' @returns {boolean} - Resolved splash screen enabled state (guaranteed boolean)
function resolveSplashScreen(globalSplashSetting as dynamic, serverSplashEnabled as dynamic) as boolean
  ' If user explicitly disabled it, never show splash
  if isValid(globalSplashSetting) and globalSplashSetting = "disabled"
    return false
  end if

  ' Otherwise follow server setting (default behavior when "enabled" or not set)
  ' Default to false if server value is invalid
  if isValid(serverSplashEnabled)
    valueType = Type(serverSplashEnabled)
    if valueType = "roBoolean" or valueType = "Boolean"
      return serverSplashEnabled
    end if
  end if

  return false
end function

' getGlobalSplashScreenSetting: Gets the global splash screen setting from registry or default
'
' Reads from global registry first. If not found (user never changed it), reads default
' from settings.json (single source of truth). Works before user login since global
' settings are device-wide.
'
' @returns {string} - Global splash screen setting value ("enabled" or "disabled")
function getGlobalSplashScreenSetting() as string
  ' Read from global registry (returns invalid if user never changed it)
  globalSetting = getSetting("globalSplashScreen")

  ' If not in registry (user never changed it), get default from settings.json
  if not isValid(globalSetting) or globalSetting = ""
    configTree = GetConfigTree()
    settingDef = findConfigTreeKey("globalSplashScreen", configTree)
    if isValid(settingDef) and isValid(settingDef.default)
      return settingDef.default
    else
      ' Fallback if settings.json is missing/invalid - default to enabled
      return "enabled"
    end if
  end if

  return globalSetting
end function

' injectApiParams: Pure function to inject default image parameters and version-specific fields
' Separated from ApiClient for testability without network I/O
'
' @param {object} params - Original query parameters from caller
' @param {integer} apiVersion - API version (1 for pre-10.9, 2+ for 10.9+)
' @param {object} imageDefaults - Default image parameters (for example { EnableImageTypes: "Primary,Backdrop,Logo,Thumb", ImageTypeLimit: 1 })
' @returns {object} New params object with defaults merged and version-specific fields added
function injectApiParams(params as object, apiVersion as integer, imageDefaults as object) as object
  merged = {}

  ' First apply image defaults
  merged.append(imageDefaults)

  ' Then apply user params (will override defaults if specified)
  merged.append(params)

  ' Add image-related fields
  ' PrimaryImageAspectRatio is a valid ItemField that triggers ImageTags to be returned
  fields = ""
  if merged.DoesExist("fields")
    fields = merged.fields
  end if

  ' Add PrimaryImageAspectRatio if not present - this causes ImageTags to be returned
  if fields.inStr("PrimaryImageAspectRatio") = -1
    if fields = ""
      fields = "PrimaryImageAspectRatio"
    else
      fields = fields + ",PrimaryImageAspectRatio"
    end if
  end if

  ' Always add Chapters field
  if fields.inStr("Chapters") = -1
    if fields = ""
      fields = "Chapters"
    else
      fields = fields + ",Chapters"
    end if
  end if

  ' Add Trickplay for 10.9+ servers (API v2+)
  if apiVersion >= 2 and fields.inStr("Trickplay") = -1
    fields = fields + ",Trickplay"
  end if

  merged.fields = fields

  return merged
end function