components_captionTask.bs

import "pkg:/source/api/baserequest.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/misc.bs"

sub init()
  m.log = log.Logger("captionTask")
  m.top.observeField("url", "fetchCaption")
  m.top.currentCaption = []
  m.top.currentPos = 0
  m.lastCaptionTexts = invalid

  m.captionTimer = m.top.findNode("captionTimer")
  m.captionTimer.ObserveField("fire", "updateCaption")

  m.captionList = []
  m.reader = createObject("roUrlTransfer")
  m.font = CreateObject("roSGNode", "Font")
  m.tags = CreateObject("roRegex", "{\\an\d*}|&lt;.*?&gt;|<.*?>", "s")

  ' Caption Style
  m.fontSizeDict = { "Default": 60, "Large": 60, "Extra Large": 70, "Medium": 50, "Small": 40 }
  m.percentageDict = { "Default": 1.0, "100%": 1.0, "75%": 0.75, "50%": 0.5, "25%": 0.25, "Off": 0 }
  m.textColorDict = { "Default": &HFFFFFFFF, "White": &HFFFFFFFF, "Black": &H000000FF, "Red": &HFF0000FF, "Green": &H008000FF, "Blue": &H0000FFFF, "Yellow": &HFFFF00FF, "Magenta": &HFF00FFFF, "Cyan": &H00FFFFFF }
  m.bgColorDict = { "Default": &H000000FF, "White": &HFFFFFFFF, "Black": &H000000FF, "Red": &HFF0000FF, "Green": &H008000FF, "Blue": &H0000FFFF, "Yellow": &HFFFF00FF, "Magenta": &HFF00FFFF, "Cyan": &H00FFFFFF }

  deviceInfo = CreateObject("roDeviceInfo")
  m.fontSize = m.fontSizeDict[deviceInfo.GetCaptionsOption("Text/Size")]
  m.textColor = m.textColorDict[deviceInfo.GetCaptionsOption("Text/Color")]
  m.textOpac = m.percentageDict[deviceInfo.GetCaptionsOption("Text/Opacity")]
  m.bgColor = m.bgColorDict[deviceInfo.GetCaptionsOption("Background/Color")]
  m.bgOpac = m.percentageDict[deviceInfo.GetCaptionsOption("Background/Opacity")]

  ' Validate fontSize - fallback to default if invalid
  if not isValid(m.fontSize) or m.fontSize <= 0
    m.fontSize = m.fontSizeDict["Default"]
    m.log.warn("Invalid font size from device settings, using default", m.fontSize)
  end if

  ' Validate text opacity - ensure visibility if set to 0 (Off)
  if not isValid(m.textOpac) or m.textOpac <= 0
    m.textOpac = m.percentageDict["Default"]
    m.log.warn("Text opacity is 0 or invalid, using default for visibility", m.textOpac)
  end if

  setFont()
end sub

sub setFont()
  fs = CreateObject("roFileSystem")

  if fs.Exists("tmp:/font")
    m.font.uri = "tmp:/font"
    m.font.size = m.fontSize
  else
    m.font = "font:LargeSystemFont"
  end if
end sub

function arraysEqual(arr1, arr2) as boolean
  if not isValid(arr1) and not isValid(arr2) then return true
  if not isValid(arr1) or not isValid(arr2) then return false
  if arr1.count() <> arr2.count() then return false

  for i = 0 to arr1.count() - 1
    if arr1[i] <> arr2[i] then return false
  end for

  return true
end function

sub fetchCaption()
  m.log.debug("Start fetchCaption()", m.top.url)
  m.captionTimer.control = "stop"
  re = CreateObject("roRegex", "(http.*?\.vtt)", "s")
  url = re.match(m.top.url)[0]

  if isValid(url)
    port = createObject("roMessagePort")
    m.reader.setUrl(url)
    m.reader.setMessagePort(port)
    if m.reader.AsyncGetToString()
      ' Wait for response with 10 second timeout to prevent infinite blocking
      msg = port.waitMessage(10000)
      if isValid(msg) and type(msg) = "roUrlEvent"
        responseCode = msg.GetResponseCode()
        if responseCode = 200
          vttContent = msg.GetString()
          m.log.debug("VTT content received, length:", vttContent.len())
          m.captionList = parseVTT(vttContent)
          m.log.info("Parsed", m.captionList.count(), "caption entries")
          m.captionTimer.control = "start"
        else
          m.log.error("Failed to fetch captions. HTTP response code:", responseCode)
        end if
      else
        m.log.error("Timeout or invalid response when fetching captions")
      end if
    else
      m.log.error("Failed to initiate caption download")
    end if
  else
    m.log.warn("No valid VTT URL found in:", m.top.url)
    m.captionTimer.control = "stop"
  end if
  m.log.debug("End fetchCaption()")
end sub

function newlabel(txt)
  label = CreateObject("roSGNode", "Label")
  label.text = txt
  label.font = m.font
  label.font.size = m.fontSize
  label.color = m.textColor
  label.opacity = m.textOpac
  return label
end function

function newLayoutGroup(labels)
  newlg = CreateObject("roSGNode", "LayoutGroup")
  newlg.appendchildren(labels)
  newlg.horizalignment = "center"
  newlg.vertalignment = "bottom"
  return newlg
end function

function newRect(lg)
  rectLG = CreateObject("roSGNode", "LayoutGroup")
  rectxy = lg.BoundingRect()
  rect = CreateObject("roSGNode", "Rectangle")
  rect.color = m.bgColor
  rect.opacity = m.bgOpac
  rect.width = rectxy.width + 50
  rect.height = rectxy.height
  if lg.getchildCount() = 0
    rect.width = 0
    rect.height = 0
  end if
  rectLG.translation = [0, -rect.height / 2]
  rectLG.horizalignment = "center"
  rectLG.vertalignment = "center"
  rectLG.appendchild(rect)
  return rectLG
end function


sub updateCaption()
  if LCase(m.top.playerState) = "playingon"
    m.top.currentPos = m.top.currentPos + 100
    texts = []
    for each entry in m.captionList
      if entry["start"] <= m.top.currentPos and m.top.currentPos < entry["end"]
        t = m.tags.replaceAll(entry["text"], "")
        texts.push(t)
      end if
    end for

    ' Only update captions if content has changed
    if not isValid(m.lastCaptionTexts) or not arraysEqual(texts, m.lastCaptionTexts)
      m.lastCaptionTexts = texts
      m.top.currentCaption = []

      if texts.count() > 0
        labels = []
        for each text in texts
          labels.push(newlabel(text))
        end for
        lines = newLayoutGroup(labels)
        rect = newRect(lines)
        m.top.currentCaption = [rect, lines]
      else
        ' Clear captions when no text should be displayed
        m.top.currentCaption = []
      end if
    end if
  else if LCase(m.top.playerState.right(1)) = "w"
    m.top.playerState = m.top.playerState.left(len(m.top.playerState) - 1)
  end if
end sub

function isTime(text)
  return text.right(1) = chr(31)
end function

function toMs(t)
  t = t.replace(".", ":")
  t = t.left(12)
  timestamp = t.tokenize(":")
  return 3600000 * timestamp[0].toint() + 60000 * timestamp[1].toint() + 1000 * timestamp[2].toint() + timestamp[3].toint()
end function

function parseVTT(lines)
  lines = lines.replace(" --> ", chr(31) + chr(10))
  lines = lines.split(chr(10))
  curStart = -1
  curEnd = -1
  entries = []

  for i = 0 to lines.count() - 1
    if isTime(lines[i])
      curStart = toMs (lines[i])
      curEnd = toMs (lines[i + 1])
      i += 1
    else if curStart <> -1
      trimmed = lines[i].trim()
      if trimmed <> chr(0)
        entry = { "start": curStart, "end": curEnd, "text": trimmed }
        entries.push(entry)
      end if
    end if
  end for
  return entries
end function