source_utils_misc.bs

import "pkg:/source/utils/config.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 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) < 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

' 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

' 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 div_ceiling(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 get_dialog_result(dialog, port)
  while dialog <> 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", "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 = 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

' 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
  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 url[1] = invalid
    ' a proto wasn't declared
    url = parseUrl("none://" + input)
  end if
  ' 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
  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 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) <> "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 inputId <> invalid
  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() * 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] <> 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)
  if not isValid(m.scene)
    m.scene = m.top.getScene()
  end if

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

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

  m.scene.disableRemote = false
  m.scene.isLoading = false
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.jellyfin_server_response
  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

' 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

' Create and return a logo poster node
function createLogoPoster()
  logoPoster = createObject("roSGNode", "Poster")
  logoPoster.id = "overlayLogo"
  logoPoster.uri = "pkg:/images/branding/logo.png"
  logoPoster.translation = "[70, 53]"
  logoPoster.width = "191"
  logoPoster.height = "66"

  return logoPoster
end function

' Create and return a rectangle node used as a seperator in the overhang
function createSeperator(id as string)
  if not isValidAndNotEmpty(id) then return invalid

  seperator = createObject("roSGNode", "Rectangle")
  seperator.id = id
  seperator.color = "#666666"
  seperator.width = "2"
  seperator.height = "64"
  seperator.visible = true

  return seperator
end function

'
function createOverhangUser()
  userLabel = createObject("roSGNode", "Label")
  userLabel.id = "overlayCurrentUser"
  userLabel.font = "font:MediumSystemFont"
  userLabel.horizAlign = "right"
  userLabel.vertAlign = "center"
  userLabel.width = "300"
  userLabel.height = "64"

  return userLabel
end function

' convert value to boolean and return value
function toBoolean(value as dynamic) as dynamic
  if value = invalid then return invalid
  if Type(value) = "Boolean" then return value
  if Type(value) <> "String" then return value

  if value = "true"
    return true
  else if value = "false"
    return false
  else
    return value
  end if
end function