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/backoff.bs"
import "pkg:/source/utils/guidePrograms.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/paging.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/translate.bs"
' Rows the guide shows at once, for when TimeGrid does not report numRows. Roku's TimeGrid
' reference does not list that field; both test devices report 6 (Roku OS 15.3.4, 2026-09-21).
const GUIDE_VISIBLE_ROWS = 6
' Channels per page of the channel list.
const CHANNEL_PAGE_SIZE = 25
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")
' Blocks hold programs for a time window around the grid's left edge (guidePrograms.timeWindow),
' which follows the left edge as the user scrolls (onGridScrolled); nothing before
' guideWindowStart is shown.
m.guideWindowStart = CreateObject("roDateTime").AsSeconds()
m.scheduleGrid.contentStartTime = m.guideWindowStart - 1800
m.guideLeftEdge = m.guideWindowStart
m.textLoading = translate(translationKeys.LabelLoading)
m.textNoSchedule = translate(translationKeys.LabelNoScheduleInformation)
m.textUnavailable = translate(translationKeys.LabelScheduleUnavailable)
m.scheduleGrid.observeField("channelFocused", "onChannelFocused")
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")
' A channel page or program block that failed is fetched again on this timer once its backoff
' delay is up (scheduleRetry). Delays run on m.clock; they wait while the guide is off screen.
m.clock = CreateObject("roTimespan")
m.isShown = true
m.retryTimer = m.top.findNode("retryTimer")
m.retryTimer.observeField("fire", "onRetryTimer")
m.top.lastFocus = m.scheduleGrid
' Generation guard for the program-details render-thread promise. Bumped whenever the
' focus moves, the grid is rebuilt (filter/search reset), programs are replaced, or a newer
' fetch fires, so a slow earlier result can't fill the details pane for a program the user
' has left, or ReplaceChild a cell the user scrolled past / a grid that's been torn down.
' Mirrors ItemDetails.checkTrailerAvailability's m.trailerCheckSeq.
m.programDetailsSeq = 0
' Program state, including m.fullyLoadedProgramIds: the program IDs whose full details
' are loaded, so re-focusing one makes no redundant request. Used instead of a
' .fullyLoaded field on the (immutable) JellyfinBaseItem node.
resetPrograms()
' The channel list's query: the last-applied filter and search term. Every page and every
' reload runs on a NEW LoadChannelsTask node (see replaceTask), so the node cannot be the
' memory of what was asked for.
m.channelQuery = invalid
' Guard: onChange handlers must not start tasks before startLoading() sets the query
m.loadingInitialized = false
' Interface observers are registered HERE rather than declared as XML onChange
' attributes: this component releases node references in onDestroy and these
' handlers dereference them, so the registrations have to be detachable. An XML
' onChange does not appear to be removable by unobserveField; see the wiring
' rule in components/CLAUDE.md.
m.top.observeField("filter", "channelFilterSet")
m.top.observeField("searchTerm", "channelsearchTermSet")
end sub
' Called by BaseGridView after setting filter/searchTerm to avoid double-fire.
' Loads the channel list in pages of CHANNEL_PAGE_SIZE for fast initial display.
sub startLoading()
m.channelQuery = { filter: m.top.filter, searchTerm: m.top.searchTerm, limit: CHANNEL_PAGE_SIZE }
m.loadingInitialized = true
startLoadingSpinner()
runChannelsPage(0)
end sub
' Runs m.channelQuery from `startIndex` on a NEW LoadChannelsTask node, releasing the run in
' flight. Never a relaunch of the previous node: see replaceTask.
sub runChannelsPage(startIndex as integer)
m.LoadChannelsTask = replaceTask(m.LoadChannelsTask, "LoadChannelsTask", "channels", paging.forPage(m.channelQuery, startIndex))
m.LoadChannelsTask.observeField("channels", "onChannelsLoaded")
launchTask(m.LoadChannelsTask)
end sub
' Rebuilds the guide for the current m.channelQuery: forgets every channel and program, and
' loads the list from its first page.
sub reloadChannels()
resetPrograms()
m.scheduleGrid.content = createObject("roSGNode", "ContentNode")
startLoadingSpinner()
runChannelsPage(0)
end sub
' BaseGridView forwards onScreenShown / onScreenHidden: the guide is its child, not a routed view.
sub onScreenShown()
' Back on screen: a retry that came due while it was hidden runs now
if not m.isShown
m.isShown = true
onRetryTimer()
end if
' Restore focus for scene navigation
if isValid(m.top.lastFocus)
m.top.lastFocus.setFocus(true)
else
m.top.setFocus(true)
end if
end sub
' Off screen (something plays on top of the library): retries wait until the guide is back.
sub onScreenHidden()
m.isShown = false
m.retryTimer.control = "stop"
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.channelQuery.filter <> m.top.filter
m.channelQuery.filter = m.top.filter
reloadChannels()
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
allWord = LCase(translate(translationKeys.LabelAll))
'Reset filter if user says all
if LCase(m.top.searchTerm) = allWord or m.channelQuery.searchTerm = allWord
' The query first: clearing m.top.searchTerm below re-enters this handler, which must then
' find nothing to change.
m.channelQuery.searchTerm = ""
m.top.searchTerm = ""
reloadChannels()
'filter if the searterm is not invalid
else if isValid(m.top.searchTerm) and LCase(m.channelQuery.searchTerm) <> LCase(m.top.searchTerm)
m.channelQuery.searchTerm = m.top.searchTerm
reloadChannels()
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.
' Programs are not part of a page: reconcilePrograms loads them for the rows near the focus.
sub onChannelsLoaded(event as object)
' A delivery queued by a run that has since been replaced (a newer filter or search, or the
' next page) or released: reading m.LoadChannelsTask in its place would read the new run's
' still-empty output.
if not isCurrentTaskEvent(event, m.LoadChannelsTask) then return
m.LoadChannelsTask.unobserveField("channels")
ok = m.LoadChannelsTask.ok
channels = m.LoadChannelsTask.channels
startIndex = m.LoadChannelsTask.startIndex
totalRecordCount = m.LoadChannelsTask.totalRecordCount
m.log.info("onChannelsLoaded", "ok", ok, "channelCount", channels.count(), "startIndex", startIndex, "totalRecordCount", totalRecordCount)
isFirstPage = startIndex = 0
if not ok
onChannelPageFailed(startIndex)
return
end if
m.channelPageFailure = invalid
' After a failed first page the grid's loading message says the schedule is unavailable
if m.scheduleGrid.loadingDataText <> m.textLoading then m.scheduleGrid.loadingDataText = m.textLoading
if channels.count() = 0 and isFirstPage
' No channels at all: stop spinner and restore focus so user can navigate back
m.channelsComplete = true
stopLoadingSpinner()
m.scheduleGrid.showLoadingDataFeedback = false
m.scheduleGrid.setFocus(true)
return
end if
for each item in channels
m.channelIds.push(item.id)
end for
if isFirstPage
' First page: create fresh grid content, while it is still detached
gridData = createObject("roSGNode", "ContentNode")
gridData.appendChildren(channels)
m.scheduleGrid.content = gridData
' Show channels immediately; their rows read "Loading…" until their programs arrive
m.scheduleGrid.showLoadingDataFeedback = false
stopLoadingSpinner()
m.scheduleGrid.setFocus(true)
if m.EPGLaunchCompleteSignaled = false
m.top.signalBeacon("EPGLaunchComplete")
m.EPGLaunchCompleteSignaled = true
end if
else if channels.count() > 0
' Subsequent page: append to existing grid content
m.scheduleGrid.content.appendChildren(channels)
end if
' Load the next page if more exist. An empty later page ends the list (paging.nextStart), and
' still reconciles below: it can complete a short last block.
nextStart = paging.nextStart(startIndex, channels.count(), totalRecordCount)
if nextStart >= 0
runChannelsPage(nextStart)
else
m.channelsComplete = true
end if
' A page can complete a block the focus is waiting on
reconcilePrograms()
end sub
' A failed channel page is not the end of the list: keep the rows already here and fetch the
' same page again after a backoff delay (scheduleRetry).
sub onChannelPageFailed(startIndex as integer)
m.log.warn("channel page failed", "startIndex", startIndex)
failure = backoff.afterFailure(m.channelPageFailure, m.clock.TotalMilliseconds())
failure.startIndex = startIndex
m.channelPageFailure = failure
' No page is in flight until the retry starts one: scheduleRetry arms the timer only then
m.LoadChannelsTask = releaseTask(m.LoadChannelsTask, "channels")
if startIndex = 0
' Nothing to show yet: say so over the whole grid, and hand the remote back to the user
' (the loading spinner holds it)
m.scheduleGrid.loadingDataText = m.textUnavailable
m.scheduleGrid.showLoadingDataFeedback = true
stopLoadingSpinner()
m.scheduleGrid.setFocus(true)
end if
scheduleRetry()
end sub
' Arms the retry timer for the earliest failure whose delay is not up yet (a channel page with
' none in flight, or a wanted program block with no fetch in flight), or stops it when nothing
' waits or the guide is off screen.
sub scheduleRetry()
retryAt = invalid
if isValid(m.channelPageFailure) and not isValid(m.LoadChannelsTask) then retryAt = m.channelPageFailure.retryAt
if isValid(m.programRetryAt) and (not isValid(retryAt) or m.programRetryAt < retryAt) then retryAt = m.programRetryAt
m.retryTimer.control = "stop"
if not isValid(retryAt) or not m.isShown then return
delayMs = retryAt - m.clock.TotalMilliseconds()
if delayMs < 50 then delayMs = 50
m.retryTimer.duration = delayMs / 1000
m.retryTimer.control = "start"
end sub
sub onRetryTimer()
' A fire already queued when onDestroy detached the observer finds nothing left to retry
if not isValid(m.clock) then return
failure = m.channelPageFailure
if isValid(failure) and not isValid(m.LoadChannelsTask) and not backoff.isWaiting(failure, m.clock.TotalMilliseconds())
runChannelsPage(failure.startIndex)
end if
' Starts a program block whose delay is up, and arms the timer for the next one
reconcilePrograms()
scheduleRetry()
end sub
' ── Programs: loaded near the focus, in blocks ──────────────────────────────────────
'
' Every channel row is in the grid, but only blocks of rows near the focused one hold
' programs (guidePrograms decides which). One block is fetched at a time; each landing
' reconciles again, so after a fast scroll the next fetch is the block nearest the focus NOW,
' never a queue of blocks the user has already left.
' Forgets every program and the channel rows' ids: the channel list is being rebuilt.
sub resetPrograms()
if isValid(m.LoadScheduleTask) then m.LoadScheduleTask = releaseTask(m.LoadScheduleTask, "programs")
m.programFetch = invalid
m.programRetryAt = invalid
m.guideHeld = {}
m.guideFailures = {}
m.channelPageFailure = invalid
m.channelIds = []
m.channelsComplete = false
m.fullyLoadedProgramIds = {}
' Invalidates any in-flight program-detail fetch: it belongs to the old grid
m.programDetailsSeq += 1
end sub
' Brings the rows near the focus to the programs they should hold: drops blocks beyond the
' cap, updates the text shown in rows without programs, starts the next fetch if none is in
' flight, and arms the retry timer for a failed block whose delay is not up yet.
sub reconcilePrograms()
content = m.scheduleGrid.content
if not isValid(content) then return
rowCount = content.getChildCount()
if rowCount = 0 then return
focusedRow = guideFocusedRow(rowCount)
wanted = guidePrograms.wantedBlocks(focusedRow, guideVisibleRows(), rowCount, m.channelsComplete)
window = guidePrograms.timeWindow(m.guideLeftEdge, m.guideWindowStart)
m.guideFailures = guidePrograms.pruneFailures(m.guideFailures, wanted)
dropPrograms(guidePrograms.evictions(m.guideHeld, wanted, focusedRow, guidePrograms.CAP))
' One trim per call: each is a write per row, so after a long scroll they spread over the next
' few turns instead of stacking into one hitch
trims = guidePrograms.trims(m.guideHeld, wanted, window, focusedRow)
if trims.count() > 0 then applyTrim(trims[0])
updateNoProgramsText(focusedRow, rowCount, window)
' A fetch for a block the focus has since left is abandoned, not waited out: fetches run one
' at a time, so on a slow server it would hold up the block the user is now looking at for
' the rest of its run.
if isValid(m.LoadScheduleTask) and not guidePrograms.isWanted(m.programFetch.block, wanted)
m.LoadScheduleTask = releaseTask(m.LoadScheduleTask, "programs")
m.programFetch = invalid
end if
m.programRetryAt = invalid
if not isValid(m.LoadScheduleTask)
fetch = guidePrograms.nextFetch(wanted, m.guideHeld, window, m.guideFailures, m.clock.TotalMilliseconds())
if isValid(fetch)
startProgramFetch(fetch, rowCount)
else
m.programRetryAt = guidePrograms.nextRetryAt(wanted, m.guideHeld, window, m.guideFailures)
end if
end if
scheduleRetry()
end sub
' The focused channel row, clamped to the grid. TimeGrid keeps reporting the old index for a
' moment after the content is replaced.
function guideFocusedRow(rowCount as integer) as integer
focusedRow = m.scheduleGrid.channelFocused
if not isValid(focusedRow) or focusedRow < 0 or focusedRow >= rowCount then return 0
return focusedRow
end function
function guideVisibleRows() as integer
rows = m.scheduleGrid.numRows
if isValid(rows) and rows > 0 then return rows
return GUIDE_VISIBLE_ROWS
end function
' Fetches the programs of one block on a new LoadScheduleTask node.
sub startProgramFetch(fetch as object, rowCount as integer)
rows = guidePrograms.blockRows(fetch.block, rowCount)
ids = []
for row = rows[0] to rows[1]
ids.push(m.channelIds[row])
end for
' LATER asks for what starts after the block's `until`; REPLACE and EARLIER for what is still
' airing at `from` or later. Both bounds are inclusive.
fields = { channelIds: ids, maxStartDate: isoFromSeconds(fetch.until) }
if fetch.kind = GuideFetchKind.LATER
fields.minStartDate = isoFromSeconds(fetch.from)
else
fields.minEndDate = isoFromSeconds(fetch.from)
end if
m.programFetch = fetch
m.LoadScheduleTask = replaceTask(m.LoadScheduleTask, "LoadScheduleTask", "programs", fields)
m.LoadScheduleTask.observeField("programs", "onProgramsLoaded")
launchTask(m.LoadScheduleTask)
end sub
sub onProgramsLoaded(event as object)
' A delivery from a fetch that has since been released (a filter or search reset)
if not isCurrentTaskEvent(event, m.LoadScheduleTask) then return
m.LoadScheduleTask.unobserveField("programs")
ok = m.LoadScheduleTask.ok
byChannel = m.LoadScheduleTask.programs
fetch = m.programFetch
m.LoadScheduleTask = releaseTask(m.LoadScheduleTask, "programs")
m.programFetch = invalid
if ok
applyPrograms(byChannel, fetch)
else
' Leave the block without programs: it is fetched again after a backoff delay, while the
' other blocks near the focus go on loading
m.log.warn("program fetch failed", "block", fetch.block)
key = fetch.block.toStr()
m.guideFailures[key] = backoff.afterFailure(m.guideFailures[key], m.clock.TotalMilliseconds())
end if
reconcilePrograms()
end sub
' Gives each channel row of `fetch.block` its programs, one write per row: REPLACE swaps what the
' row holds for them, LATER appends them and EARLIER inserts them ahead. The server's bounds are
' inclusive, so an extension repeats the programs the block already holds at its edge; those are
' dropped by id.
sub applyPrograms(byChannel as object, fetch as object)
content = m.scheduleGrid.content
rowCount = content.getChildCount()
rows = guidePrograms.blockRows(fetch.block, rowCount)
key = fetch.block.toStr()
state = m.guideHeld[key]
kind = fetch.kind
' A block trimmed away while its extension was in flight holds nothing left to extend
if not isValid(state) then kind = GuideFetchKind.REPLACE
focusedRow = guideFocusedRow(rowCount)
for row = rows[0] to rows[1]
channel = content.getChild(row)
programs = byChannel[m.channelIds[row]]
if not isValid(programs) then programs = []
held = channel.getChildCount()
if kind = GuideFetchKind.REPLACE
if held > 0 then channel.removeChildrenIndex(held, 0)
if programs.count() > 0 then channel.appendChildren(programs)
else if kind = GuideFetchKind.LATER
programs = withoutHeld(programs, channel, held - 1, -1, fetch.from)
if programs.count() > 0 then channel.appendChildren(programs)
else
programs = withoutHeld(programs, channel, 0, 1, fetch.until)
if programs.count() > 0 then channel.insertChildren(programs, 0)
end if
end for
isRefresh = kind = GuideFetchKind.REPLACE and isValid(state)
if kind = GuideFetchKind.REPLACE
m.guideHeld[key] = { from: fetch.from, until: fetch.until, stale: false }
else if kind = GuideFetchKind.LATER
state.until = fetch.until
else
state.from = fetch.from
end if
m.guideFailures.Delete(key)
if isRefresh or kind = GuideFetchKind.EARLIER
' Cells were replaced, or moved along by the ones inserted ahead: details and any detail
' fetch in flight belong to the old ones
m.fullyLoadedProgramIds = {}
m.programDetailsSeq += 1
end if
' The focused row may have gained its programs while the details pane showed nothing
if focusedRow >= rows[0] and focusedRow <= rows[1] and not isValid(m.detailsPane.programDetails)
onProgramFocused()
end if
end sub
' `programs` without those `channel` already holds at the edge an extension overlaps: the held
' programs from `index`, stepping by `stepBy` (-1 from the end, 1 from the start), while they
' start at or after (-1) / at or before (1) `boundary`.
function withoutHeld(programs as object, channel as object, index as integer, stepBy as integer, boundary as integer) as object
if programs.count() = 0 then return programs
heldIds = {}
while index >= 0 and index < channel.getChildCount()
child = channel.getChild(index)
if stepBy < 0 and child.PlayStart < boundary then exit while
if stepBy > 0 and child.PlayStart > boundary then exit while
heldIds[child.id] = true
index += stepBy
end while
if heldIds.count() = 0 then return programs
kept = []
for each program in programs
if not heldIds.DoesExist(program.id) then kept.push(program)
end for
return kept
end function
' Applies one of guidePrograms.trims: drops the block, or cuts it back to what airs in
' [trim.from, trim.until].
sub applyTrim(trim as object)
if trim.drop
dropPrograms([trim.block])
return
end if
content = m.scheduleGrid.content
rows = guidePrograms.blockRows(trim.block, content.getChildCount())
for row = rows[0] to rows[1]
channel = content.getChild(row)
count = channel.getChildCount()
' Programs that end before trim.from lead the row; those starting after trim.until trail it
head = 0
while head < count
program = channel.getChild(head)
if program.PlayStart + program.PlayDuration >= trim.from then exit while
head++
end while
tail = 0
while tail < count - head
if channel.getChild(count - 1 - tail).PlayStart <= trim.until then exit while
tail++
end while
if tail > 0 then channel.removeChildrenIndex(tail, count - tail)
if head > 0 then channel.removeChildrenIndex(head, 0)
end for
state = m.guideHeld[trim.block.toStr()]
state.from = trim.from
state.until = trim.until
' Cells moved along and programs went: details and any detail fetch in flight belong to them
m.fullyLoadedProgramIds = {}
m.programDetailsSeq += 1
end sub
' Drops the programs of `blocks`, one write per row.
sub dropPrograms(blocks as object)
if blocks.count() = 0 then return
content = m.scheduleGrid.content
rowCount = content.getChildCount()
for each block in blocks
rows = guidePrograms.blockRows(block, rowCount)
for row = rows[0] to rows[1]
channel = content.getChild(row)
held = channel.getChildCount()
if held > 0 then channel.removeChildrenIndex(held, 0)
end for
m.guideHeld.Delete(block.toStr())
end for
m.fullyLoadedProgramIds = {}
end sub
' A row without programs reads "Loading…" while a block around the focus is still loading,
' "Schedule unavailable" while one is waiting to retry, and "No schedule information" once they
' have all loaded. TimeGrid has one text for every such row, so it follows the rows the user
' can see.
sub updateNoProgramsText(focusedRow as integer, rowCount as integer, window as object)
visibleRows = guideVisibleRows()
firstRow = focusedRow - visibleRows
if firstRow < 0 then firstRow = 0
lastRow = focusedRow + visibleRows
if lastRow >= rowCount then lastRow = rowCount - 1
text = m.textNoSchedule
state = guidePrograms.spanState(firstRow, lastRow, m.guideHeld, m.guideFailures, window)
if state = GuideSpanState.LOADING
text = m.textLoading
else if state = GuideSpanState.FAILED
text = m.textUnavailable
end if
if m.scheduleGrid.channelNoDataText <> text then m.scheduleGrid.channelNoDataText = text
end sub
function isoFromSeconds(seconds as integer) as string
date = CreateObject("roDateTime")
date.FromSeconds(seconds)
return date.ToISOString()
end function
sub onChannelFocused()
reconcilePrograms()
end sub
sub onProgramFocused()
' A details fetch still in flight is for the program the focus has left. Without this, one
' that lands while the focus is on a row without programs (or on a program whose details
' are already loaded, which starts no fetch of its own) fills the pane with the old program.
m.programDetailsSeq += 1
m.top.watchChannel = invalid
' Make sure we have channels (for example, 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
' Track this program as fully loaded to prevent redundant API requests on re-focus, BEFORE
' the ReplaceChild below: replacing the focused cell re-enters onProgramFocused at once, which
' would otherwise find the program not yet loaded and fetch it a second time
m.fullyLoadedProgramIds[node.id] = true
' 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)
' 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
' The left edge sets the time window (guidePrograms.timeWindow): the blocks near the focus fetch
' what they miss of it, and what falls far outside it is trimmed. Blocks farther away catch up
' when the focus reaches them.
sub onGridScrolled()
leftEdge = m.scheduleGrid.leftEdgeTargetTime
if not isValid(leftEdge) or leftEdge <= 0 then return
leftEdge = Int(leftEdge)
if leftEdge = m.guideLeftEdge then return
m.guideLeftEdge = leftEdge
reconcilePrograms()
end sub
' Handle user selecting "Record Channel" from Program Details
sub onRecordChannelSelected()
if m.detailsPane.shouldRecordSelectedChannel = false then return
startRecording(false)
end sub
' Handle user selecting "Record Series" from Program Details
sub onRecordSeriesChannelSelected()
if m.detailsPane.shouldRecordSeriesSelectedChannel = false then return
startRecording(true)
end sub
' Records (or, if it already records, cancels) the program in the details pane, on a new
' RecordProgramTask node, so a second press while one is in flight starts a run of its own.
sub startRecording(isSeries as boolean)
' Set focus back to grid before showing channel, to ensure grid has focus when we return
focusProgramDetails(false)
m.scheduleGrid.showLoadingDataFeedback = true
m.RecordProgramTask = replaceTask(m.RecordProgramTask, "RecordProgramTask", "result", { programDetails: m.detailsPane.programDetails, shouldRecordSeries: isSeries })
m.RecordProgramTask.observeField("result", "onRecordOperationDone")
launchTask(m.RecordProgramTask)
end sub
sub onRecordOperationDone(event as object)
' A result from a run a later press has replaced
if not isCurrentTaskEvent(event, m.RecordProgramTask) then return
m.RecordProgramTask.unobserveField("result")
result = m.RecordProgramTask.result
isSeries = m.RecordProgramTask.shouldRecordSeries
m.RecordProgramTask = releaseTask(m.RecordProgramTask, "result")
m.scheduleGrid.showLoadingDataFeedback = false
toast = recordResultToast(result)
displayToast(toast.text, toast.type)
if not (isValid(result) and result.ok = true) then return
if isSeries
' Every airing of the series may now carry a recording indicator: mark every block's
' programs stale, so each is replaced: the ones near the focus now, the rest when the
' focus reaches them.
for each key in m.guideHeld
m.guideHeld[key].stale = true
end for
reconcilePrograms()
else
' This reloads only 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 = invalid
if isValid(channel) then prog = channel.GetChild(m.scheduleGrid.programFocusedDetails.focusIndex)
' Re-fetch unconditionally: the program's recording state has changed.
if isValid(prog) then 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.
' The guide is a child of BaseGridView, not a routed view, so BaseGridView calls this
' (hideTVGuide and its own onDestroy); the router never will.
sub onDestroy()
' Detach the interface observers registered in init(). Must precede every
' reference release below: these handlers dereference them.
m.top.unobserveField("filter")
m.top.unobserveField("searchTerm")
m.log.verbose("onDestroy")
' Unobserve child node observers
m.detailsPane.unobserveField("shouldWatchSelectedChannel")
m.detailsPane.unobserveField("shouldRecordSelectedChannel")
m.detailsPane.unobserveField("shouldRecordSeriesSelectedChannel")
m.scheduleGrid.unobserveField("channelFocused")
m.scheduleGrid.unobserveField("programFocused")
m.scheduleGrid.unobserveField("programSelected")
m.scheduleGrid.unobserveField("leftEdgeTargetTime")
m.retryTimer.unobserveField("fire")
m.retryTimer.control = "stop"
' Detach the record-operation observer before releasing any reference: onRecordOperationDone
' reads the grid and the program state, so leaving it attached while those are nulled below
' would let a late completion dot into invalid (&hec).
if isValid(m.RecordProgramTask) then m.RecordProgramTask.unobserveField("result")
' Stop and release the channel page and the program fetch in flight, if any
m.LoadChannelsTask = releaseTask(m.LoadChannelsTask, "channels")
m.LoadScheduleTask = releaseTask(m.LoadScheduleTask, "programs")
' 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.
m.RecordProgramTask = releaseTask(m.RecordProgramTask, "result")
' Clear node references
m.detailsPane = invalid
m.scheduleGrid = invalid
m.gridMoveAnimation = invalid
m.gridMoveAnimationPosition = invalid
m.retryTimer = invalid
m.clock = invalid
' Clear data structures
m.channelQuery = invalid
m.channelIds = invalid
m.guideHeld = invalid
m.guideFailures = invalid
m.channelPageFailure = invalid
m.fullyLoadedProgramIds = invalid
m.loadingInitialized = false
end sub