source_utils_misc.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 getButton(msg, subnode = "buttons" as string) as object
  buttons = msg.getRoSGNode().findNode(subnode)
  if not isValid(buttons) then return invalid
  activeButton = buttons.focusedChild
  return activeButton
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
' NOTE: This is NOT used by app's clock, only for displaying time 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.
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

' 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

function divCeiling(a as integer, b as integer) as integer
  if a < b then return 1
  if int(a / b) = a / b
    return a / b
  end if
  return a / b + 1
end function

'Returns the item selected or -1 on backpress or other unhandled closure of dialog.
function getDialogResult(dialog, port)
  while isValid(dialog)
    msg = wait(0, port)
    if isNodeEvent(msg, "backPressed")
      return -1
    else if isNodeEvent(msg, "itemSelected")
      return dialog.findNode("optionList").itemSelected
    end if
  end while
  'Dialog has closed outside of this loop, return -1 for failure
  return -1
end function

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

function showDialog(message as string, options = [], defaultSelection = 0) as integer
  lastFocus = lastFocusedChild(m.scene)

  dialog = createObject("roSGNode", "JRMessageDialog")
  if options.count() then dialog.options = options
  if message.len() > 0
    reg = CreateObject("roFontRegistry")
    font = reg.GetDefaultFont()
    dialog.fontHeight = font.GetOneLineHeight()
    dialog.fontWidth = font.GetOneLineWidth(message, 999999999)
    dialog.message = message
  end if

  if defaultSelection > 0
    dialog.findNode("optionList").jumpToItem = defaultSelection
  end if

  dialog.visible = true
  m.scene.appendChild(dialog)
  dialog.setFocus(true)

  port = CreateObject("roMessagePort")
  dialog.observeField("backPressed", port)
  dialog.findNode("optionList").observeField("itemSelected", port)

  result = getDialogResult(dialog, port)

  m.scene.removeChildIndex(m.scene.getChildCount() - 1)
  lastFocus.setFocus(true)

  return result
end function

function messageDialog(message = "" as string)
  return showDialog(message, [translate(translationKeys.ButtonOk)])
end function

function optionDialog(options, message = "", defaultSelection = 0) as integer
  return showDialog(message, options, defaultSelection)
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, just 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 just use 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

' Returns whether or not all items in passed array are valid
function isAllValid(input as object) as boolean
  for each item in input
    if not isValid(item) then return false
  end for
  return true
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

' Rounds number to nearest integer
function roundNumber(f as float) as integer
  ' BrightScript only has a "floor" round
  ' This compares floor to floor + 1 to find which is closer
  m = int(f)
  n = m + 1
  x = abs(f - m)
  y = abs(f - n)
  if y > x
    return m
  else
    return n
  end if
end function

' Converts ticks to minutes
function getMinutes(ticks) as integer
  ' A tick is .1ms, so 1/10,000,000 for ticks to seconds,
  ' then 1/60 for seconds to minutes... 1/600,000,000
  return roundNumber(ticks / 600000000.0)
end function

'
' Returns whether or not a version number (e.g. 10.7.7) is greater or equal
' to some minimum version allowed (e.g. 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

'
' 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)
  if not isValid(m.scene)
    m.scene = m.top.getScene()
  end if

  ' disableRemote and loadingText must be set first
  m.scene.isRemoteDisabled = disableRemote
  m.scene.loadingText = loadingText
  m.scene.isLoading = true
end sub

sub stopLoadingSpinner()
  if not isValid(m.scene)
    m.scene = m.top.getScene()
  end if

  m.scene.loadingText = ""
  m.scene.isRemoteDisabled = false
  m.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)
  if not isValid(m.scene)
    m.scene = m.top.getScene()
  end if
  if isValid(m.scene)
    m.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

' Traverses up the node hierarchy to find a parent node that has a specific field
' This is useful for finding the main component when handling button events
function findParentWithField(node as object, fieldName as string) as object
  if not isValid(node) or not isValid(fieldName) then return invalid

  currentNode = node
  while isValid(currentNode)
    if isValid(currentNode[fieldName]) then return currentNode
    currentNode = currentNode.getParent()
  end while

  return invalid
end function

' Traverses up the node hierarchy to find a parent node of a specific type
' This is useful for finding the main component when handling button events
function findParentOfType(node as object, typeName as string) as object
  if not isValid(node) or not isValid(typeName) then return invalid

  currentNode = node
  while isValid(currentNode)
    if isValid(currentNode.subType) and currentNode.subType() = typeName then return currentNode
    currentNode = currentNode.getParent()
  end while

  return invalid
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

' Returns the Jellyfin index of the first audio stream in the streams array
' @param {dynamic} streams - Array of media streams from Jellyfin
' @returns {integer} - Jellyfin index of first audio stream, or 0 if not found
function getFirstAudioStreamIndex(streams as dynamic) as integer
  if not isValid(streams) then return 0
  if streams.Count() = 0 then return 0

  for i = 0 to streams.Count() - 1
    if LCase(streams[i].Type) = "audio"
      if isValid(streams[i].index)
        return streams[i].index
      end if
    end if
  end for

  return 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 (e.g. main + commentary, both English — issue #500).
' Falls back to Index + 1 mapping when availableAudioTracks is not available or no
' ordinal match can be found (e.g., 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 (e.g., { 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