components_video_CaptionRenderer.bs
' CaptionRenderer: builds and publishes custom (app-drawn) subtitle lines.
'
' Everything here runs on the RENDER thread: the 100 ms caption timer, the font
' and style lookups, the parsed caption list and the label building. It extends
' Group rather than Task on purpose: with no functionName there is exactly one
' thread writing `m`, which makes the &hf3 class that produced the original crash
' structurally impossible rather than merely avoided. The fetch is a separate
' component, LoadCaptionTask, whose node this one owns.
'
' Its own lifetime is the player's. A subtitle change swaps the FETCH node, never
' this one, so `currentPos` and `playerState` (the live playback bridge) survive
' a track change untouched.
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/tasks.bs"
sub init()
' Not the onKeyEvent guard `auto-destroyed-guard.cjs` owns: this component has
' no onKeyEvent, so that plugin never reaches it. Same flag name and same `= true`
' comparison form on purpose: both are positionally first, so the plugin stays a
' no-op if an onKeyEvent is ever added here.
m.isDestroyed = false
m.log = new log.Logger("CaptionRenderer")
m.top.observeField("url", "onUrlChange")
m.top.currentCaption = []
m.top.currentPos = 0
m.lastCaptionTexts = invalid
m.fetchTask = invalid
m.captionTimer = m.top.findNode("captionTimer")
m.captionTimer.ObserveField("fire", "updateCaption")
m.captionList = []
' The downloaded fallback font, used only once a run confirms it is on disk.
m.fallbackFont = CreateObject("roSGNode", "Font")
' Safe default until then: a system font always renders, where a Font node
' pointed at a missing file has no defined behaviour we have measured.
m.font = "font:LargeSystemFont"
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
end sub
' Applies the downloaded fallback font, or a system font when it is not on disk.
'
' The answer comes from the fetch task (`fontAvailable`), not from a setting and not
' from a filesystem probe here: `CreateObject("roFileSystem")` returns Invalid on the
' render thread, where this component lives (measured 2026-09-22: it faulted &hec
' here while the identical code was fine in the Task this component was split out of).
'
' Asking the SETTING instead would be wrong in the case that matters: `playbackSubsCustom`
' being on is what TRIGGERS the download, not proof that it succeeded. A server that
' cannot supply the font leaves the setting on and the file absent, which is exactly
' when non-Latin subtitles need the fallback most.
sub applyFont(fontAvailable as boolean)
if fontAvailable
m.fallbackFont.uri = "tmp:/font"
m.fallbackFont.size = m.fontSize
m.font = m.fallbackFont
else
m.font = "font:LargeSystemFont"
end if
end sub
' A new subtitle track, or "" to clear captions entirely.
'
' Captions are cleared HERE rather than at the top of the fetch, so the old track's
' lines are gone the moment the track changes instead of one thread crossing later.
sub onUrlChange()
' A `url` write landing after teardown must not rebuild the fetch node: onDestroy has
' already released it and nulled the timer, so a node built here would never be
' released again. `onCaptionDataReceived` is guarded by `isCurrentTaskEvent`; this is
' the same protection on this component's other entry point.
if m.isDestroyed = true then return
clearCaptions()
url = m.top.url
if not isValid(url) or url = ""
' Nothing to show and nothing to fetch: drop any run still in flight.
m.fetchTask = releaseTask(m.fetchTask, "captionData")
return
end if
' A NEW node per track, never a relaunch: a launch on a node whose fetch has not
' returned is ignored every time, so switching tracks twice in quick succession
' would leave the second track never fetched (ADR 0037).
m.fetchTask = replaceTask(m.fetchTask, "LoadCaptionTask", "captionData", { url: url })
m.fetchTask.observeField("captionData", "onCaptionDataReceived")
launchTask(m.fetchTask)
end sub
' Receives parsed VTT data from the fetch task and manages the caption timer.
sub onCaptionDataReceived(event as object)
if not isCurrentTaskEvent(event, m.fetchTask)
' A delivery from a node onUrlChange has since replaced, queued before its
' observer was removed, which Roku's docs neither promise nor rule out. Reading
' m.fetchTask in its place would take the NEW track's empty output and stop the
' timer for a track that is still loading.
if isValid(m.fetchTask) then m.log.warn("superseded caption delivery dropped")
return
end if
m.captionTimer.control = "stop"
' Before any label is built: `updateCaption` reads `m.font` for every line.
applyFont(m.fetchTask.fontAvailable = true)
captionData = m.fetchTask.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
' Stops the timer and drops whatever is on screen. Safe to call before init has
' built the caption state (a `url` write can land first).
sub clearCaptions()
if isValid(m.captionTimer) then m.captionTimer.control = "stop"
m.captionList = []
m.lastCaptionTexts = invalid
m.top.currentCaption = []
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
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 (for example 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
' onDestroy: full teardown releasing all resources before component removal.
' Called by VideoPlayerView.onDestroy() when popping the player scene.
'
' ⚠️ This is reachable only because CaptionRenderer.xml declares
' `<function name="onDestroy" />`. Without that line callFunc() is a silent no-op
' and the 100 ms timer below keeps firing for the life of the app.
sub onDestroy()
m.isDestroyed = true
m.log.verbose("onDestroy")
' Detach every observer before releasing any reference, so a delivery that lands
' mid-teardown cannot dereference something already nulled.
m.top.unobserveField("url")
' Drops the fetch observer and STOPs the run, so a response arriving after the
' player is gone cannot restart the timer.
m.fetchTask = releaseTask(m.fetchTask, "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
m.fallbackFont = invalid
end sub