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