components_captionTask.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 = new log.Logger("captionTask")
m.top.functionName = "fetchCaption"
m.top.observeField("captionData", "onCaptionDataReceived")
m.top.currentCaption = []
m.top.currentPos = 0
m.lastCaptionTexts = invalid
m.captionTimer = m.top.findNode("captionTimer")
m.captionTimer.ObserveField("fire", "updateCaption")
m.captionList = []
m.font = CreateObject("roSGNode", "Font")
m.tags = CreateObject("roRegex", "{\\an\d*}|<.*?>|<.*?>", "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
' Runs on the Task thread via functionName. HTTP requests require a Task thread.
'
' Uses raw roUrlTransfer + WaitMessage instead of rr_Requests().
' rr_Requests_run() is a standalone function whose m resolves to the component's
' shared m AA. Its busy-polling loop reads m.top thousands of times per second
' from the task thread, racing with the render thread's 100ms caption timer that
' reads m.tags/m.captionList from the same unsynchronized m AA — corrupting it
' and causing the &hf3 crash on m.tags.replaceAll().
' WaitMessage() blocks at the OS level without touching m, eliminating the race.
sub fetchCaption()
' Cache m.top into a local so the rest of this function minimizes touches to
' the shared m AA. m is unsynchronized between threads; a single read
' to grab the node reference is far safer than repeated lookups.
' Note: m.log calls remain but are stripped from production builds.
top = m.top
m.log.debug("Start fetchCaption()", top.url)
' Clear captions immediately via the bridge field so the render-thread handler
' stops the caption timer and clears currentCaption before the fetch begins.
' This prevents stale captions from a previous track showing during the request.
top.captionData = { entries: [] }
re = CreateObject("roRegex", "(http.*?\.vtt)", "s")
matchResult = re.match(top.url)
if not isValid(matchResult) or matchResult.count() = 0
m.log.warn("No valid VTT URL found in:", top.url)
m.log.debug("End fetchCaption()")
return
end if
url = matchResult[0]
urlTransfer = CreateObject("roUrlTransfer")
if url.Left(8) = "https://"
urlTransfer.SetCertificatesFile("common:/certs/ca-bundle.crt")
end if
port = CreateObject("roMessagePort")
urlTransfer.SetMessagePort(port)
urlTransfer.SetUrl(url)
urlTransfer.AddHeader("Cache-Control", "no-cache")
if urlTransfer.AsyncGetToString()
msg = port.WaitMessage(10000)
if type(msg) = "roUrlEvent" and msg.GetResponseCode() = 200
vttContent = msg.GetString()
m.log.debug("VTT content received, length:", vttContent.len())
entries = parseVTT(vttContent)
m.log.info("Parsed", entries.count(), "caption entries")
top.captionData = { entries: entries }
else if type(msg) = "roUrlEvent"
m.log.error("Caption fetch failed — URL:", url, "responseCode:", msg.GetResponseCode(), "reason:", msg.GetFailureReason())
top.captionData = { entries: [] }
else
m.log.error("Caption fetch timed out — URL:", url)
top.captionData = { entries: [] }
end if
else
m.log.error("Failed to initiate caption download")
top.captionData = { entries: [] }
end if
m.log.debug("End fetchCaption()")
end sub
' Render-thread callback: receives parsed VTT data from the task thread and manages the caption timer
sub onCaptionDataReceived()
m.captionTimer.control = "stop"
captionData = m.top.captionData
if isValid(captionData) and isValid(captionData.entries) and captionData.entries.count() > 0
m.captionList = captionData.entries
m.lastCaptionTexts = invalid
m.captionTimer.control = "start"
m.log.debug("Caption data received, starting timer with", m.captionList.count(), "entries")
else
m.captionList = []
m.lastCaptionTexts = invalid
m.top.currentCaption = []
m.log.debug("No caption data, timer stopped")
end if
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"
' Guard against m fields being invalid (e.g. onDestroy() was called but a
' final queued timer event still fires before the observer is removed)
if not isValid(m.tags) or not isValid(m.captionList) then return
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
' onDestroy: Full teardown releasing all resources before component removal.
' Called by VideoPlayerView.onDestroy() when popping the player scene.
' Stops the caption timer and unobserves all fields to prevent callbacks
' firing on an orphaned component — including the case where an in-flight
' HTTP request in fetchCaption() completes after the player is torn down.
sub onDestroy()
m.log.verbose("onDestroy")
' Unobserve captionData first — prevents onCaptionDataReceived from
' restarting the timer if a slow fetchCaption() HTTP response arrives
' after the player has already been destroyed.
m.top.unobserveField("captionData")
' Stop and release caption timer (guarded — safe if onDestroy() is called
' twice or if init() partially failed and m.captionTimer was never set)
if isValid(m.captionTimer)
m.captionTimer.unobserveField("fire")
m.captionTimer.control = "stop"
m.captionTimer = invalid
end if
' Clear references
m.captionList = invalid
m.lastCaptionTexts = invalid
m.tags = invalid
m.font = invalid
end sub
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