jf-roku/docs/api/source_utils_misc.bs.html

512 lines
54 KiB
HTML
Raw Normal View History

2023-11-11 13:41:20 +00:00
<!DOCTYPE html><html lang="en" style="font-size:16px"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Source: source/utils/misc.bs</title><!--[if lt IE 9]>
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
2023-12-05 16:56:00 +00:00
<![endif]--><script src="scripts/third-party/hljs.js" defer="defer"></script><script src="scripts/third-party/hljs-line-num.js" defer="defer"></script><script src="scripts/third-party/popper.js" defer="defer"></script><script src="scripts/third-party/tippy.js" defer="defer"></script><script src="scripts/third-party/tocbot.min.js"></script><script>var baseURL="/",locationPathname="";baseURL=(baseURL=(baseURL="https://jellyfin.github.io/jellyfin-roku/").replace(/https?:\/\//i,"")).substr(baseURL.indexOf("/"))</script><link rel="stylesheet" href="styles/clean-jsdoc-theme.min.css"><svg aria-hidden="true" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="display:none"><defs><symbol id="copy-icon" viewbox="0 0 488.3 488.3"><g><path d="M314.25,85.4h-227c-21.3,0-38.6,17.3-38.6,38.6v325.7c0,21.3,17.3,38.6,38.6,38.6h227c21.3,0,38.6-17.3,38.6-38.6V124 C352.75,102.7,335.45,85.4,314.25,85.4z M325.75,449.6c0,6.4-5.2,11.6-11.6,11.6h-227c-6.4,0-11.6-5.2-11.6-11.6V124 c0-6.4,5.2-11.6,11.6-11.6h227c6.4,0,11.6,5.2,11.6,11.6V449.6z"/><path d="M401.05,0h-227c-21.3,0-38.6,17.3-38.6,38.6c0,7.5,6,13.5,13.5,13.5s13.5-6,13.5-13.5c0-6.4,5.2-11.6,11.6-11.6h227 c6.4,0,11.6,5.2,11.6,11.6v325.7c0,6.4-5.2,11.6-11.6,11.6c-7.5,0-13.5,6-13.5,13.5s6,13.5,13.5,13.5c21.3,0,38.6-17.3,38.6-38.6 V38.6C439.65,17.3,422.35,0,401.05,0z"/></g></symbol><symbol id="search-icon" viewBox="0 0 512 512"><g><g><path d="M225.474,0C101.151,0,0,101.151,0,225.474c0,124.33,101.151,225.474,225.474,225.474 c124.33,0,225.474-101.144,225.474-225.474C450.948,101.151,349.804,0,225.474,0z M225.474,409.323 c-101.373,0-183.848-82.475-183.848-183.848S124.101,41.626,225.474,41.626s183.848,82.475,183.848,183.848 S326.847,409.323,225.474,409.323z"/></g></g><g><g><path d="M505.902,476.472L386.574,357.144c-8.131-8.131-21.299-8.131-29.43,0c-8.131,8.124-8.131,21.306,0,29.43l119.328,119.328 c4.065,4.065,9.387,6.098,14.715,6.098c5.321,0,10.649-2.033,14.715-6.098C514.033,497.778,514.033,484.596,505.902,476.472z"/></g></g></symbol><symbol id="font-size-icon" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11.246 15H4.754l-2 5H.6L7 4h2l6.4 16h-2.154l-2-5zm-.8-2L8 6.885 5.554 13h4.892zM21 12.535V12h2v8h-2v-.535a4 4 0 1 1 0-6.93zM19 18a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></symbol><symbol id="add-icon" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/></symbol><symbol id="minus-icon" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M5 11h14v2H5z"/></symbol><symbol id="dark-theme-icon" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M10 7a7 7 0 0 0 12 4.9v.1c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2h.1A6.979 6.979 0 0 0 10 7zm-6 5a8 8 0 0 0 15.062 3.762A9 9 0 0 1 8.238 4.938 7.999 7.999 0 0 0 4 12z"/></symbol><symbol id="light-theme-icon" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-2a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85l1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"/></symbol><symbol id="reset-icon" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M18.537 19.567A9.961 9.961 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 2.136-.67 4.116-1.81 5.74L17 12h3a8 8 0 1 0-2.46 5.772l.997 1.795z"/></symbol><symbol id="down-icon" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.7803 6.21967C13.0732 6.51256 13.0732 6.98744 12.7803 7.28033L8.53033 11.5303C8.23744 11.8232 7.76256 11.8232 7.46967 11.5303L3.21967 7.28033C2.92678 6.98744 2.92678 6.51256 3.21967 6.21967C3.51256 5.92678 3.98744 5.92678 4.28033 6.21967L8 9.93934L11.7197 6.21967C12.0126 5.92678 12.4874 5.92678 12.7803 6.21967Z"></path></symbol><symbol id="codepen-icon" viewBox="0 0 24 24"><path fill="none" d=
2023-10-31 21:36:19 +00:00
function isNodeEvent(msg, field as string) as boolean
2023-10-06 03:18:36 +00:00
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 &lt;> ""
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 buttons = invalid then return invalid
active_button = buttons.focusedChild
return active_button
end function
function leftPad(base as string, fill as string, length as integer) as string
while len(base) &lt; 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) &lt; 10 then minutes = "0" + minutes
if val(seconds) &lt; 10 then seconds = "0" + seconds
r = ""
if val(hours) > 0 then r = hours + ":"
r = r + minutes + ":" + seconds
return r
end function
2023-11-12 18:13:07 +00:00
function secondsToHuman(totalSeconds as integer, addLeadingMinuteZero as boolean) as string
humanTime = ""
2023-10-06 03:18:36 +00:00
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()
2023-11-12 18:13:07 +00:00
if val(hours) > 0 or addLeadingMinuteZero
if val(minutes) &lt; 10
minutes = "0" + minutes
end if
end if
if val(seconds) &lt; 10
seconds = "0" + seconds
end if
if val(hours) > 0
hours = hours + ":"
else
hours = ""
end if
humanTime = hours + minutes + ":" + seconds
return humanTime
2023-10-06 03:18:36 +00:00
end function
' Format time as 12 or 24 hour format based on system clock setting
function formatTime(time) as string
hours = time.getHours()
minHourDigits = 1
if m.global.device.clockFormat = "12h"
meridian = "AM"
if hours = 0
hours = 12
meridian = "AM"
else if hours = 12
hours = 12
meridian = "PM"
else if hours > 12
hours = hours - 12
meridian = "PM"
end if
else
' For 24hr Clock, no meridian and pad hours to 2 digits
minHourDigits = 2
meridian = ""
end if
return Substitute("{0}:{1} {2}", leftPad(stri(hours).trim(), "0", minHourDigits), leftPad(stri(time.getMinutes()).trim(), "0", 2), meridian)
end function
function div_ceiling(a as integer, b as integer) as integer
if a &lt; 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 get_dialog_result(dialog, port)
while dialog &lt;> invalid
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)
if isValid(obj.focusedChild) and isValid(obj.focusedChild.focusedChild) and LCase(obj.focusedChild.focusedChild.subType()) = "tvepisodes"
if isValid(obj.focusedChild.focusedChild.lastFocus)
return obj.focusedChild.focusedChild.lastFocus
end if
end if
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 show_dialog(message as string, options = [], defaultSelection = 0) as integer
lastFocus = lastFocusedChild(m.scene)
dialog = createObject("roSGNode", "JFMessageDialog")
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 = get_dialog_result(dialog, port)
m.scene.removeChildIndex(m.scene.getChildCount() - 1)
lastFocus.setFocus(true)
return result
end function
function message_dialog(message = "" as string)
return show_dialog(message, ["OK"])
end function
function option_dialog(options, message = "", defaultSelection = 0) as integer
return show_dialog(message, options, defaultSelection)
end function
2023-10-31 21:36:19 +00:00
' 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 = get_setting("saved_servers")
if isValid(saved)
savedServers = ParseJson(saved)
if isValid(savedServers.lookup(url)) then return url
2023-10-06 03:18:36 +00:00
end if
2023-10-31 21:36:19 +00:00
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")
2023-10-06 03:18:36 +00:00
end if
2023-10-31 21:36:19 +00:00
req.AsyncGetToString()
end for
handled = 0
timeout = CreateObject("roTimespan")
if hosts.count() > 0
while timeout.totalseconds() &lt; 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 url[1] = invalid
' a proto wasn't declared
url = parseUrl("none://" + input)
2023-10-06 03:18:36 +00:00
end if
2023-10-31 21:36:19 +00:00
' if the proto is still invalid then the string is not valid
if url[1] = invalid 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
2023-10-06 03:18:36 +00:00
end if
2023-10-31 21:36:19 +00:00
finalCandidates = []
if isValid(port) and port &lt;> "" ' 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
2023-10-06 03:18:36 +00:00
end function
sub setFieldTextValue(field, value)
node = m.top.findNode(field)
if node = invalid or value = invalid 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) &lt;> "roString" and type(value) &lt;> "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 &lt;> invalid
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 &lt;> ""
2023-11-15 00:22:33 +00:00
else if inputType = "rosgnode"
inputId = input.id
return inputId &lt;> invalid
2023-10-06 03:18:36 +00:00
else if countableTypes.doesExist(inputType)
return input.count() > 0
else
print "Called isValidAndNotEmpty() with invalid type: ", inputType
return false
end if
end function
2023-10-31 21:36:19 +00:00
' Returns an array from a url = [ url, proto, host, port, subdir+params ]
2023-10-06 03:18:36 +00:00
' 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() &lt; 3
for i = version.Count() to 3 step 1
version.AddTail("0")
end for
end if
minVersion = regEx.Split(minVersionAccepted)
if minVersion.Count() &lt; 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() * 10)
rightHand = (minVersion[0].ToInt() * 10000) + (minVersion[1].ToInt() * 100) + (minVersion[2].ToInt() * 10)
return leftHand >= rightHand
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] &lt;> 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)) &lt;> "rostring" and LCase(type(valueToFind)) &lt;> "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
2023-11-30 01:22:26 +00:00
'
' startLoadingSpinner: Start a loading spinner and attach it to the main JFScene.
' Displays an invisible ProgressDialog node by default to disable keypresses while loading.
'
' @param {boolean} [disableRemote=true]
sub startLoadingSpinner(disableRemote = true as boolean)
if not isValid(m.scene)
m.scene = m.top.getScene()
end if
2023-10-06 03:18:36 +00:00
2023-11-30 01:22:26 +00:00
if not m.scene.isLoading
m.scene.disableRemote = disableRemote
m.scene.isLoading = true
end if
2023-10-06 03:18:36 +00:00
end sub
sub stopLoadingSpinner()
2023-11-30 01:22:26 +00:00
if not isValid(m.scene)
m.scene = m.top.getScene()
2023-10-06 03:18:36 +00:00
end if
2023-11-30 01:22:26 +00:00
if m.scene.isLoading
m.scene.disableRemote = false
m.scene.isLoading = false
2023-10-06 03:18:36 +00:00
end if
end sub
2023-10-31 21:36:19 +00:00
' 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.jellyfin_server
end if
return false
end function
2023-10-06 03:18:36 +00:00
' 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
2023-10-28 21:26:12 +00:00
' 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
2023-12-05 16:56:00 +00:00
</code></pre></article></section><footer class="footer" id="PeOAagUepe"><div class="wrapper"><span class="jsdoc-message">Automatically generated using <a href="https://github.com/jsdoc/jsdoc" target="_blank">JSDoc</a> and the <a href="https://github.com/ankitskvmdam/clean-jsdoc-theme" target="_blank">clean-jsdoc-theme</a>.</span></div></footer></div></div></div><div class="search-container" id="PkfLWpAbet" style="display:none"><div class="wrapper" id="iCxFxjkHbP"><button class="icon-button search-close-button" id="VjLlGakifb" aria-label="close search"><svg><use xlink:href="#close-icon"></use></svg></button><div class="search-box-c"><svg><use xlink:href="#search-icon"></use></svg> <input type="text" id="vpcKVYIppa" class="search-input" placeholder="Search..." autofocus></div><div class="search-result-c" id="fWwVHRuDuN"><span class="search-result-c-text">Type anything to view search result</span></div></div></div><div class="mobile-menu-icon-container"><button class="icon-button" id="mobile-menu" data-isopen="false" aria-label="menu"><svg><use xlink:href="#menu-icon"></use></svg></button></div><div id="mobile-sidebar" class="mobile-sidebar-container"><div class="mobile-sidebar-wrapper"><a href="/" class="sidebar-title sidebar-title-anchor">jellyfin-roku Code Documentation</a><div class="mobile-nav-links"><div class="external-link navbar-item"><a id="jellyfin-link-mobile" href="https://jellyfin.org/" target="_blank">Jellyfin</a></div><div class="external-link navbar-item"><a id="github-link-mobile" href="https://github.com/jellyfin/jellyfin-roku" target="_blank">GitHub</a></div><div class="external-link navbar-item"><a id="forum-link-mobile" href="https://forum.jellyfin.org/f-roku-development" target="_blank">Forum</a></div><div class="external-link navbar-item"><a id="matrix-link-mobile" href="https://matrix.to/#/#jellyfin-dev-roku:matrix.org" target="_blank">Matrix</a></div></div><div class="mobile-sidebar-items-c"><div class="sidebar-section-title with-arrow" data-isopen="false" id="sidebar-modules"><div>Modules</div><svg><use xlink:href="#down-icon"></use></svg></div><div class="sidebar-section-children-container"><div class="sidebar-section-children"><a href="module-AlbumData.html">AlbumData</a></div><div class="sidebar-section-children"><a href="module-AlbumGrid.html">AlbumGrid</a></div><div class="sidebar-section-children"><a href="module-AlbumTrackList.html">AlbumTrackList</a></div><div class="sidebar-section-children"><a href="module-AlbumView.html">AlbumView</a></div><div class="sidebar-section-children"><a href="module-Alpha.html">Alpha</a></div><div class="sidebar-section-children"><a href="module-ArtistView.html">ArtistView</a></div><div class="sidebar-section-children"><a href="module-AudioPlayer.html">AudioPlayer</a></div><div class="sidebar-section-children"><a href="module-AudioPlayerView.html">AudioPlayerView</a></div><div class="sidebar-section-children"><a href="module-AudioTrackListItem.html">AudioTrackListItem</a></div><div class="sidebar-section-children"><a href="module-ButtonGroupHoriz.html">ButtonGroupHoriz</a></div><div class="sidebar-section-children"><a href="module-ButtonGroupVert.html">ButtonGroupVert</a></div><div class="sidebar-section-children"><a href="module-ChannelData.html">ChannelData</a></div><div class="sidebar-section-children"><a href="module-Clock.html">Clock</a></div><div class="sidebar-section-children"><a href="module-CollectionData.html">CollectionData</a></div><div class="sidebar-section-children"><a href="module-ConfigData.html">ConfigData</a></div><div class="sidebar-section-children"><a href="module-ConfigItem.html">ConfigItem</a></div><div class="sidebar-section-children"><a href="module-ConfigList.html">ConfigList</a></div><div class="sidebar-section-children"><a href="module-ExtrasItem.html">ExtrasItem</a></div><div class="sidebar-section-children"><a href="module-ExtrasRowList.html">ExtrasRowList</a></div><div class="sidebar-section-children"><a href="module-FavoriteItemsTask.html">FavoriteItemsTask</a></div><div class="sidebar-section-children"><a href="module-Folder