components_liveTv_schedule.bs
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/apiPromise.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.log = new log.Logger("Schedule")
m.EPGLaunchCompleteSignaled = false
m.scheduleGrid = m.top.findNode("scheduleGrid")
m.detailsPane = m.top.findNode("detailsPane")
m.detailsPane.observeField("shouldWatchSelectedChannel", "onWatchChannelSelected")
m.detailsPane.observeField("shouldRecordSelectedChannel", "onRecordChannelSelected")
m.detailsPane.observeField("shouldRecordSeriesSelectedChannel", "onRecordSeriesChannelSelected")
m.gridStartDate = CreateObject("roDateTime")
m.scheduleGrid.contentStartTime = m.gridStartDate.AsSeconds() - 1800
m.gridEndDate = createObject("roDateTime")
m.gridEndDate.FromSeconds(m.gridStartDate.AsSeconds() + (24 * 60 * 60))
m.scheduleGrid.observeField("programFocused", "onProgramFocused")
m.scheduleGrid.observeField("programSelected", "onProgramSelected")
m.scheduleGrid.observeField("leftEdgeTargetTime", "onGridScrolled")
m.scheduleGrid.channelInfoWidth = 350
m.gridMoveAnimation = m.top.findNode("gridMoveAnimation")
m.gridMoveAnimationPosition = m.top.findNode("gridMoveAnimationPosition")
m.LoadChannelsTask = createObject("roSGNode", "LoadChannelsTask")
m.LoadChannelsTask.observeField("channels", "onChannelsLoaded")
m.top.lastFocus = m.scheduleGrid
m.channelIndex = {}
' Track which program IDs have been fully loaded to avoid redundant API requests.
' Used instead of a .fullyLoaded field on the (immutable) JellyfinBaseItem node.
m.fullyLoadedProgramIds = {}
' Generation guard for the program-details render-thread promise. Bumped whenever the
' grid is rebuilt (filter/search reset) or a newer fetch fires, so a slow earlier result
' can't ReplaceChild a cell the user scrolled past / a grid that's been torn down. Mirrors
' ItemDetails.checkTrailerAvailability's m.trailerCheckSeq (Phase-3b staleness guard).
m.programDetailsSeq = 0
' Guard: onChange handlers must not start tasks before startLoading() configures paging
m.loadingInitialized = false
end sub
' Called by BaseGridView after setting filter/searchTerm to avoid double-fire.
' Applies current filter and search state to the task before starting it.
' Loads channels in pages of m.channelPageSize for fast initial display.
sub startLoading()
m.channelPageSize = 25
m.pendingScheduleIds = []
m.LoadChannelsTask.filter = m.top.filter
m.LoadChannelsTask.searchTerm = m.top.searchTerm
m.LoadChannelsTask.limit = m.channelPageSize
m.LoadChannelsTask.startIndex = 0
m.loadingInitialized = true
startLoadingSpinner()
m.LoadChannelsTask.control = "RUN"
end sub
sub onScreenShown()
' Restore focus for scene navigation
if isValid(m.top.lastFocus)
m.top.lastFocus.setFocus(true)
else
m.top.setFocus(true)
end if
end sub
sub channelFilterSet()
' Don't start tasks before startLoading() has configured paging fields
if not m.loadingInitialized then return
m.scheduleGrid.jumpToChannel = 0
if isValid(m.top.filter) and m.LoadChannelsTask.filter <> m.top.filter
if m.LoadChannelsTask.state = "run" then m.LoadChannelsTask.control = "stop"
' Stop in-flight schedule task + invalidate program-detail fetch — their data belongs to the old filter
if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state = "run" then m.LoadScheduleTask.control = "stop"
m.programDetailsSeq += 1 ' invalidate any in-flight program-detail fetch — it belongs to the old grid
' Reset all state for fresh filter
m.LoadChannelsTask.startIndex = 0
m.channelIndex = {}
m.fullyLoadedProgramIds = {}
m.pendingScheduleIds = []
m.scheduleGrid.content = createObject("roSGNode", "ContentNode")
m.LoadChannelsTask.filter = m.top.filter
startLoadingSpinner()
m.LoadChannelsTask.control = "RUN"
end if
end sub
'Voice Search set
sub channelsearchTermSet()
' Don't start tasks before startLoading() has configured paging fields
if not m.loadingInitialized then return
m.scheduleGrid.jumpToChannel = 0
'Reset filter if user says all
if LCase(m.top.searchTerm) = LCase(translate(translationKeys.LabelAll)) or m.LoadChannelsTask.searchTerm = LCase(translate(translationKeys.LabelAll))
m.top.searchTerm = ""
m.LoadChannelsTask.searchTerm = ""
' Stop in-flight schedule task + invalidate program-detail fetch — their data belongs to the old search
if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state = "run" then m.LoadScheduleTask.control = "stop"
m.programDetailsSeq += 1 ' invalidate any in-flight program-detail fetch — it belongs to the old grid
' Reset all state for fresh search
m.LoadChannelsTask.startIndex = 0
m.channelIndex = {}
m.fullyLoadedProgramIds = {}
m.pendingScheduleIds = []
m.scheduleGrid.content = createObject("roSGNode", "ContentNode")
startLoadingSpinner()
m.LoadChannelsTask.control = "RUN"
'filter if the searterm is not invalid
else if isValid(m.top.searchTerm) and LCase(m.LoadChannelsTask.searchTerm) <> LCase(m.top.searchTerm)
if m.LoadChannelsTask.state = "run" then m.LoadChannelsTask.control = "stop"
' Stop in-flight schedule task + invalidate program-detail fetch — their data belongs to the old search
if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state = "run" then m.LoadScheduleTask.control = "stop"
m.programDetailsSeq += 1 ' invalidate any in-flight program-detail fetch — it belongs to the old grid
' Reset all state for fresh search
m.LoadChannelsTask.startIndex = 0
m.channelIndex = {}
m.fullyLoadedProgramIds = {}
m.pendingScheduleIds = []
m.scheduleGrid.content = createObject("roSGNode", "ContentNode")
m.LoadChannelsTask.searchTerm = m.top.searchTerm
startLoadingSpinner()
m.LoadChannelsTask.control = "RUN"
end if
end sub
' Channels loaded callback — handles both first and subsequent pages.
' First page: creates grid content, shows channels immediately.
' Subsequent pages: appends channels to existing grid.
' Each page queues its channel IDs for schedule loading.
sub onChannelsLoaded()
channels = m.LoadChannelsTask.channels
m.log.info("onChannelsLoaded", "channelCount", channels.count(), "startIndex", m.LoadChannelsTask.startIndex, "totalRecordCount", m.LoadChannelsTask.totalRecordCount)
if channels.count() = 0
' No channels returned — stop spinner and restore focus so user can navigate back
stopLoadingSpinner()
m.scheduleGrid.showLoadingDataFeedback = false
m.scheduleGrid.setFocus(true)
return
end if
isFirstPage = not isValid(m.scheduleGrid.content) or m.scheduleGrid.content.getChildCount() = 0
channelIdList = ""
if isFirstPage
' First page: create fresh grid content
gridData = createObject("roSGNode", "ContentNode")
for each item in channels
gridData.appendChild(item)
m.channelIndex[item.id] = m.channelIndex.count()
channelIdList += item.id + ","
end for
m.scheduleGrid.content = gridData
' Show channels immediately — programs populate as schedule batches arrive
m.scheduleGrid.showLoadingDataFeedback = false
stopLoadingSpinner()
' Create task instances only once — filter/search resets reuse them.
' Program details are loaded via a render-thread promise (loadProgramDetails), not a Task.
if not isValid(m.LoadScheduleTask)
m.LoadScheduleTask = createObject("roSGNode", "LoadScheduleTask")
m.LoadScheduleTask.observeField("schedule", "onScheduleLoaded")
end if
m.scheduleGrid.setFocus(true)
if m.EPGLaunchCompleteSignaled = false
m.top.signalBeacon("EPGLaunchComplete")
m.EPGLaunchCompleteSignaled = true
end if
else
' Subsequent page: append to existing grid content
for each item in channels
m.scheduleGrid.content.appendChild(item)
m.channelIndex[item.id] = m.channelIndex.count()
channelIdList += item.id + ","
end for
end if
' Queue schedule load for this batch of channels
m.pendingScheduleIds.push(channelIdList)
tryLoadNextScheduleBatch()
' Load next page of channels if more exist
loadedSoFar = m.LoadChannelsTask.startIndex + channels.count()
if loadedSoFar < m.LoadChannelsTask.totalRecordCount
m.LoadChannelsTask.startIndex = loadedSoFar
m.LoadChannelsTask.control = "RUN"
end if
end sub
' Starts the next queued schedule batch if LoadScheduleTask is free.
sub tryLoadNextScheduleBatch()
if not isValid(m.LoadScheduleTask) then return
if m.LoadScheduleTask.state = "run" then return
if m.pendingScheduleIds.count() = 0 then return
channelIds = m.pendingScheduleIds[0]
m.pendingScheduleIds.delete(0)
m.LoadScheduleTask.startTime = m.gridStartDate.ToISOString()
m.LoadScheduleTask.endTime = m.gridEndDate.ToISOString()
m.LoadScheduleTask.channelIds = channelIds
m.LoadScheduleTask.control = "RUN"
end sub
' When LoadScheduleTask completes (initial or more data) and we have a schedule to display
sub onScheduleLoaded()
m.log.info("onScheduleLoaded", "programCount", m.LoadScheduleTask.schedule.Count())
' make sure we actually have a schedule (i.e. filter by favorites, but no channels have been favorited)
if m.scheduleGrid.content.GetChildCount() <= 0
return
end if
for each item in m.LoadScheduleTask.schedule
if isValid(m.channelIndex[item.channelId])
channel = m.scheduleGrid.content.GetChild(m.channelIndex[item.channelId])
channel.appendChild(item)
end if
end for
m.scheduleGrid.showLoadingDataFeedback = false
m.scheduleGrid.setFocus(true)
m.LoadScheduleTask.schedule = []
' Process next queued schedule batch
tryLoadNextScheduleBatch()
end sub
sub onProgramFocused()
m.top.watchChannel = invalid
' Make sure we have channels (i.e. filter set to favorite yet there are none)
if m.scheduleGrid.content.getChildCount() <= 0
channel = invalid
else
channel = m.scheduleGrid.content.GetChild(m.scheduleGrid.programFocusedDetails.focusChannelIndex)
end if
m.detailsPane.channel = channel
m.top.focusedChannel = channel
' Exit if Channels not yet loaded
if not isValid(channel) or channel.getChildCount() = 0
m.detailsPane.programDetails = invalid
return
end if
prog = channel.GetChild(m.scheduleGrid.programFocusedDetails.focusIndex)
if isValid(prog) and not isValid(m.fullyLoadedProgramIds[prog.id])
loadProgramDetails(prog.id, m.scheduleGrid.programFocusedDetails.focusChannelIndex, m.scheduleGrid.programFocusedDetails.focusIndex)
end if
m.detailsPane.programDetails = prog
end sub
' Fetch full program details for a focused grid cell and swap the lightweight schedule
' item for the fully-loaded node. Render-thread promise — replaces the former
' LoadProgramDetailsTask, a pure-fetch Task consumed via createObject + observeField
' (Phase-3a collapse; canonical in docs/dev/promises.md). The transform here is a SINGLE
' item (transformBaseItem, not array processing), so it's render-thread-acceptable.
sub loadProgramDetails(programId as string, channelIndex as integer, programIndex as integer)
' Bump the generation so this fetch's result is the only one allowed to touch the grid.
' A grid rebuild (filter/search reset) also bumps it, dropping any earlier in-flight result.
m.programDetailsSeq += 1
req = GetApi().BuildGetLiveTvProgramRequest(programId, { UserId: m.global.user.id })
' Carry the target grid cell + generation through the promise context (no closures in BS) —
' these used to ride as Task fields echoed back in the programDetails wrapper.
context = { channelIndex: channelIndex, programIndex: programIndex, seq: m.programDetailsSeq }
promises.chain(fetchAsync(req, "liveTvProgram-" + programId), context).then(sub(res as object, ctx as object)
if ctx.seq <> m.programDetailsSeq then return ' stale — grid rebuilt or focus moved on
' Bail on a non-ok response / invalid body (the old Task set programDetails = {} here).
if not (res.ok and isValid(res.json))
m.scheduleGrid.showLoadingDataFeedback = false
return
end if
transformer = JellyfinDataTransformer()
node = transformer.transformBaseItem(res.json)
' Set hdSmallIconUrl for the TimeGrid recording indicator (ContentNode built-in read by
' Roku TimeGrid). Must be set here because the ReplaceChild below swaps the lightweight
' schedule item for this fully-loaded node, so the indicator must be carried over.
if isValidAndNotEmpty(node.timerId)
node.hdsmalliconurl = "pkg:/images/red.png"
else
node.hdsmalliconurl = ""
end if
' Replace the lightweight schedule item in the TimeGrid with the fully-loaded node
channel = m.scheduleGrid.content.GetChild(ctx.channelIndex)
channel.ReplaceChild(node, ctx.programIndex)
' Track this program as fully loaded to prevent redundant API requests on re-focus
m.fullyLoadedProgramIds[node.id] = true
' Update the details pane immediately so the user sees full program data
m.detailsPane.programDetails = node
m.scheduleGrid.showLoadingDataFeedback = false
end sub).catch(sub(err as object, ctx as object)
if ctx.seq <> m.programDetailsSeq then return ' stale — a newer fetch owns the cell now
' Transport failure / timeout (see the error contract) — the request never completed.
m.log.warn("program-details fetch failed", err.reason)
m.scheduleGrid.showLoadingDataFeedback = false
end sub)
end sub
sub onProgramSelected()
' If there is no program data - view the channel
if not isValid(m.detailsPane.programDetails)
m.top.watchChannel = m.scheduleGrid.content.GetChild(m.scheduleGrid.programFocusedDetails.focusChannelIndex)
return
end if
' Move Grid Down
focusProgramDetails(true)
end sub
' Move the TV Guide Grid down or up depending whether details are selected
sub focusProgramDetails(setFocused)
h = m.detailsPane.height
if h < 400 then h = 400
h = h + 160 + 80
if setFocused = true
m.gridMoveAnimationPosition.keyValue = [[0, 600], [0, h]]
m.detailsPane.setFocus(true)
m.detailsPane.hasFocus = true
m.top.lastFocus = m.detailsPane
else
m.detailsPane.hasFocus = false
m.gridMoveAnimationPosition.keyValue = [[0, h], [0, 600]]
m.scheduleGrid.setFocus(true)
m.top.lastFocus = m.scheduleGrid
end if
m.gridMoveAnimation.control = "start"
end sub
' Handle user selecting "Watch Channel" from Program Details
sub onWatchChannelSelected()
if m.detailsPane.watchSelectedChannel = false then return
' Set focus back to grid before showing channel, to ensure grid has focus when we return
focusProgramDetails(false)
m.top.watchChannel = m.detailsPane.channel
end sub
' As user scrolls grid, check if more data requries to be loaded
sub onGridScrolled()
' If we're within 12 hours of end of grid, load next 24hrs of data
if m.scheduleGrid.leftEdgeTargetTime + (12 * 60 * 60) > m.gridEndDate.AsSeconds()
' Ensure the task is not already (still) running,
if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state <> "run"
' Build channel ID list from ALL loaded channels (not just last batch)
allChannelIds = ""
for each key in m.channelIndex
allChannelIds += key + ","
end for
m.LoadScheduleTask.startTime = m.gridEndDate.ToISOString()
m.gridEndDate.FromSeconds(m.gridEndDate.AsSeconds() + (24 * 60 * 60))
m.LoadScheduleTask.endTime = m.gridEndDate.ToISOString()
m.LoadScheduleTask.channelIds = allChannelIds
m.LoadScheduleTask.control = "RUN"
end if
end if
end sub
' Handle user selecting "Record Channel" from Program Details
sub onRecordChannelSelected()
if m.detailsPane.shouldRecordSelectedChannel = false then return
' Set focus back to grid before showing channel, to ensure grid has focus when we return
focusProgramDetails(false)
m.scheduleGrid.showLoadingDataFeedback = true
m.RecordProgramTask = createObject("roSGNode", "RecordProgramTask")
m.RecordProgramTask.programDetails = m.detailsPane.programDetails
m.RecordProgramTask.shouldRecordSeries = false
m.RecordProgramTask.observeField("recordOperationDone", "onRecordOperationDone")
m.RecordProgramTask.control = "RUN"
end sub
' Handle user selecting "Record Series" from Program Details
sub onRecordSeriesChannelSelected()
if m.detailsPane.shouldRecordSeriesSelectedChannel = false then return
' Set focus back to grid before showing channel, to ensure grid has focus when we return
focusProgramDetails(false)
m.scheduleGrid.showLoadingDataFeedback = true
m.RecordProgramTask = createObject("roSGNode", "RecordProgramTask")
m.RecordProgramTask.programDetails = m.detailsPane.programDetails
m.RecordProgramTask.shouldRecordSeries = true
m.RecordProgramTask.observeField("recordOperationDone", "onRecordOperationDone")
m.RecordProgramTask.control = "RUN"
end sub
sub onRecordOperationDone()
if m.RecordProgramTask.shouldRecordSeries = true and m.LoadScheduleTask.state <> "run"
m.LoadScheduleTask.control = "RUN"
else
' This reloads just the details for the currently selected program, so that we don't have to
' reload the entire grid...
channel = m.scheduleGrid.content.GetChild(m.scheduleGrid.programFocusedDetails.focusChannelIndex)
prog = channel.GetChild(m.scheduleGrid.programFocusedDetails.focusIndex)
' Re-fetch unconditionally — the program's recording state just changed.
loadProgramDetails(prog.id, m.scheduleGrid.programFocusedDetails.focusChannelIndex, m.scheduleGrid.programFocusedDetails.focusIndex)
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
detailsGrp = m.top.findNode("detailsPane")
gridGrp = m.top.findNode("scheduleGrid")
if key = "back" and detailsGrp.isInFocusChain()
focusProgramDetails(false)
detailsGrp.setFocus(false)
gridGrp.setFocus(true)
return true
else if key = "back"
' The TV guide lives inside the router-owned library view — leave it via the router (back
' to Home / the previous routed view).
sgRouter.goBack()
return true
end if
return false
end function
' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
sub onDestroy()
m.log.verbose("onDestroy")
' Unobserve child node observers
m.detailsPane.unobserveField("shouldWatchSelectedChannel")
m.detailsPane.unobserveField("shouldRecordSelectedChannel")
m.detailsPane.unobserveField("shouldRecordSeriesSelectedChannel")
m.scheduleGrid.unobserveField("programFocused")
m.scheduleGrid.unobserveField("programSelected")
m.scheduleGrid.unobserveField("leftEdgeTargetTime")
' Stop and release always-present task node
m.LoadChannelsTask.unobserveField("channels")
m.LoadChannelsTask.control = "STOP"
m.LoadChannelsTask = invalid
' Stop and release conditionally-created task nodes
if isValid(m.LoadScheduleTask)
m.LoadScheduleTask.unobserveField("schedule")
m.LoadScheduleTask.control = "STOP"
m.LoadScheduleTask = invalid
end if
' Program details now load via a render-thread promise; the auto-abandon BSC plugin injects
' abandonApiPromises() here, so a late fetch can't fire into this destroyed component.
if isValid(m.RecordProgramTask)
m.RecordProgramTask.unobserveField("recordOperationDone")
m.RecordProgramTask.control = "STOP"
m.RecordProgramTask = invalid
end if
' Clear node references
m.detailsPane = invalid
m.scheduleGrid = invalid
m.gridMoveAnimation = invalid
m.gridMoveAnimationPosition = invalid
' Clear data structures
m.channelIndex = invalid
m.fullyLoadedProgramIds = invalid
m.pendingScheduleIds = invalid
m.loadingInitialized = false
end sub