sub Main (args as dynamic) as void
printRegistry()
' The main function that runs when the application is launched.
m.screen = CreateObject("roSGScreen")
m.port = CreateObject("roMessagePort")
m.screen.setMessagePort(m.port)
m.global = m.screen.getGlobalNode()
setGlobals()
' Load translations using device locale (pre-login, no user context yet)
loadTranslations(resolveTranslationLocale())
user.settings.SaveDefaults()
' Enable auto-sync AFTER loading defaults
m.global.user.settings.callFunc("enableAutoSync")
' migrate registry if needed
m.wasMigrated = false
runGlobalMigrations()
runRegistryUserMigrations()
' update LastRunVersion now that migrations are finished
if m.global.app.version <> m.global.app.lastRunVersion
setSetting("LastRunVersion", m.global.app.version)
end if
if m.wasMigrated then printRegistry()
m.scene = m.screen.CreateScene("JRScene")
m.screen.show() ' vscode_rale_tracker_entry
'vscode_rdb_on_device_component_entry
' setup global nodes now that the screen has been shown
setGlobalNodes()
appStart:
m.global.sceneManager.callFunc("clearScenes")
' First thing to do is validate the ability to use the API
if not LoginFlow() then return
' remove login scenes from the stack
m.global.sceneManager.callFunc("clearScenes")
' Initialize fallback font processing
initializeFallbackFont()
' load home page - this may be delayed if UI fallback fonts are enabled
loadHomeScreen()
' Reset transient state used by button handlers across scene transitions
m.photoAlbumShuffleMode = false
' Handle input messages
input = CreateObject("roInput")
input.SetMessagePort(m.port)
' Receive Roku voice transport commands (play/pause/seek/next/startover/skip/etc.)
' as roInputEvent with info.type = "transport". Manifest gates: supports_voice_roinput,
' supports_etc_seek, supports_etc_next. Dispatch lives in the roInputEvent branch below.
input.EnableTransportEvents()
device = CreateObject("roDeviceInfo")
device.setMessagePort(m.port)
device.EnableScreensaverExitedEvent(true)
device.EnableAppFocusEvent(true)
device.EnableLowGeneralMemoryEvent(true)
device.EnableLinkStatusEvent(true)
device.EnableCodecCapChangedEvent(true)
device.EnableAudioGuideChangedEvent(true)
' Check if we were sent content to play with the startup command (Deep Link)
if isValidAndNotEmpty(args.mediaType) and isValidAndNotEmpty(args.contentId)
m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem({ id: args.contentId, type: "video" }))
m.global.queueManager.callFunc("playQueue")
end if
' This is the core logic loop. Mostly for transitioning between scenes
' This now only references m. fields so could be placed anywhere, in theory
' "group" is always "whats on the screen"
' m.scene's children is the "previous view" stack
group = invalid
while true
msg = wait(0, m.port)
if type(msg) = "roSGScreenEvent" and msg.isScreenClosed()
print "CLOSING SCREEN"
return
else if isNodeEvent(msg, "exit")
return
else if isNodeEvent(msg, "closeSidePanel")
group = m.global.sceneManager.callFunc("getActiveScene")
if isValid(group.lastFocus)
group.lastFocus.setFocus(true)
else
group.setFocus(true)
end if
else if isNodeEvent(msg, "isFontDownloadCompleted")
' Handle font download completion
fontDownloadTask = msg.getRoSGNode()
if isValid(fontDownloadTask)
handleFontDownloadCompletion(fontDownloadTask)
end if
else if isNodeEvent(msg, "quickPlayNode")
' Read from msg.getData() — the value at event-queue time — not from
' the current field value which may already be cleared to invalid by
' the set-then-clear pattern that prevents stale re-fires.
itemNode = msg.getData()
if isValid(itemNode) and isValid(itemNode.id) and itemNode.id <> ""
itemType = invalid
if isValid(itemNode.type) and itemNode.type <> ""
itemType = Lcase(itemNode.type)
end if
if isValid(itemType)
startLoadingSpinner()
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
' Synchronous types (no API calls) — handle inline
if itemType = "chapter"
' Chapter: start playback of parent video at chapter position
queueItem = nodeHelpers.createQueueItem(itemNode)
if isValid(queueItem)
queueItem.type = itemNode.parentType
queueItem.startingPoint = itemNode.playbackPositionTicks
m.global.queueManager.callFunc("push", queueItem)
m.global.queueManager.callFunc("playQueue")
end if
else if itemType = "episode" or itemType = "recording" or itemType = "movie" or itemType = "video"
quickplay.video(itemNode)
m.global.queueManager.callFunc("playQueue")
else if itemType = "audio"
quickplay.audio(itemNode)
m.global.queueManager.callFunc("playQueue")
else if itemType = "musicvideo"
quickplay.musicVideo(itemNode)
m.global.queueManager.callFunc("playQueue")
else if itemType = "photo"
quickplay.photo(itemNode)
else if itemType = "tvchannel"
quickplay.tvChannel(itemNode)
m.global.queueManager.callFunc("playQueue")
else if itemType = "program"
quickplay.program(itemNode)
m.global.queueManager.callFunc("playQueue")
else
' All other types need API calls — delegate to QuickPlayTask
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = {
action: itemType,
id: itemNode.id,
seriesId: itemNode.seriesId,
collectionType: itemNode.collectionType,
folderType: itemNode.folderType,
movieCount: itemNode.movieCount,
seriesCount: itemNode.seriesCount,
channelId: itemNode.channelId
}
m.activeQuickPlayTask.observeField("output", m.port)
m.activeQuickPlayTask.control = "RUN"
end if
end if
end if
else if isNodeEvent(msg, "voiceQuery")
' Voice search from Home screen — open SearchResults with spoken query pre-populated
voiceQuery = msg.getData()
if isValidAndNotEmpty(voiceQuery)
' Guard: skip if a SearchResults is already the active scene (prevents duplicate pushes
' when voice system fires multiple text changes for a single input)
activeScene = m.global.sceneManager.callFunc("getActiveScene")
if isValid(activeScene) and activeScene.subtype() = "SearchResults"
searchKey = activeScene.findNode("SearchBox").findNode("searchKey")
searchKey.text = voiceQuery
searchKey.textEditBox.setFocus(true)
else
group = CreateSearchPage()
group.shouldSkipInitialFocus = true
m.global.sceneManager.callFunc("pushScene", group)
searchKey = group.findNode("SearchBox").findNode("searchKey")
searchKey.text = voiceQuery
searchKey.textEditBox.setFocus(true)
end if
end if
else if isNodeEvent(msg, "output")
task = msg.getRoSGNode()
if isValid(task) and task.subtype() = "QuickPlayTask"
handleQuickPlayOutput(task)
end if
else if isNodeEvent(msg, "result")
task = msg.getRoSGNode()
if isValid(task) and task.subtype() = "RecordProgramTask"
handleRecordResult(task)
end if
else if isNodeEvent(msg, "isDone")
resultNode = msg.getRoSGNode()
if isValid(resultNode) and resultNode.isSameNode(m.favoriteResultNode)
handleFavoriteToggleDone()
else if isValid(resultNode) and resultNode.isSameNode(m.watchedResultNode)
handleWatchedToggleDone()
end if
else if isNodeEvent(msg, "selectedItem")
' If you select a library from ANYWHERE, follow this flow
selectedItem = msg.getData()
if isValid(selectedItem)
startLoadingSpinner()
selectedItemType = selectedItem.type
' Normalize alias types to their canonical forms
if selectedItemType = "LiveTvChannel" then selectedItemType = "TvChannel"
if selectedItemType = "LiveTvProgram" or selectedItemType = "TvProgram" then selectedItemType = "Program"
if selectedItemType = "CollectionFolder"
group = CreateLibraryView(selectedItem)
group.observeField("selectedItem", m.port)
group.observeField("quickPlayNode", m.port)
m.global.sceneManager.callFunc("pushScene", group)
else if selectedItemType = "Genre" or selectedItemType = "MusicGenre" or (selectedItemType = "Folder" and (selectedItem.folderType = "Genre" or selectedItem.folderType = "MusicGenre"))
' User clicked on a genre/music-genre — open scoped library view
group = CreateLibraryView(selectedItem)
group.observeField("selectedItem", m.port)
group.observeField("quickPlayNode", m.port)
m.global.sceneManager.callFunc("pushScene", group)
else if selectedItemType = "Studio"
' Studio items from /Studios endpoint have IsFolder=false so type="Studio" directly.
' Route to a library view scoped to that studio (presenter inferred via collectionType).
group = CreateLibraryView(selectedItem)
group.observeField("selectedItem", m.port)
group.observeField("quickPlayNode", m.port)
m.global.sceneManager.callFunc("pushScene", group)
else if selectedItemType = "UserView" or selectedItemType = "Folder" or selectedItemType = "Channel"
group = CreateLibraryView(selectedItem)
group.observeField("selectedItem", m.port)
group.observeField("quickPlayNode", m.port)
m.global.sceneManager.callFunc("pushScene", group)
else if selectedItemType = "BoxSet"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Episode" or LCase(selectedItemType) = "recording"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Series"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Season"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Movie"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Person"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Video"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "TvChannel" or selectedItemType = "Program"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Photo"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "PhotoAlbum"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "MusicArtist"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "MusicAlbum"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Audio"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "MusicVideo"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Playlist"
group = CreateItemDetailsGroup(selectedItem)
else if selectedItemType = "Chapter"
' Chapter: start playback of parent video at chapter position
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
queueItem = nodeHelpers.createQueueItem(selectedItem)
if isValid(queueItem)
queueItem.type = selectedItem.parentType
queueItem.startingPoint = selectedItem.playbackPositionTicks
m.global.queueManager.callFunc("push", queueItem)
m.global.queueManager.callFunc("playQueue")
end if
else
' TODO - switch on more node types
stopLoadingSpinner()
messageDialog(translate(translationKeys.ErrorTypeNotYetSupported, [selectedItemType]))
end if
end if
else if isNodeEvent(msg, "movieSelected")
' If you select a movie from ANYWHERE, follow this flow
startLoadingSpinner()
node = getMsgPicker(msg, "picker")
group = CreateItemDetailsGroup(node)
else if isNodeEvent(msg, "seriesSelected")
' If you select a TV Series from ANYWHERE, follow this flow
startLoadingSpinner()
node = getMsgPicker(msg, "picker")
group = CreateItemDetailsGroup(node)
else if isNodeEvent(msg, "playItem")
' User has selected audio they want us to play
startLoadingSpinner()
selectedIndex = msg.getData()
screenContent = msg.getRoSGNode()
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("set", screenContent.albumData.items)
m.global.queueManager.callFunc("setPosition", selectedIndex)
m.global.queueManager.callFunc("playQueue")
else if isNodeEvent(msg, "searchValue")
query = msg.getRoSGNode().searchValue
group.findNode("SearchBox").visible = false
options = group.findNode("SearchSelect")
options.visible = true
options.setFocus(true)
m.searchDialog = createObject("roSGNode", "ProgressDialog")
m.searchDialog.title = translate(translationKeys.LabelLoadingSearchData)
m.scene.dialog = m.searchDialog
m.searchGroup = group
m.searchQuery = query
m.searchTask = CreateObject("roSGNode", "SearchTask")
m.searchTask.query = query
m.searchTask.observeField("results", m.port)
m.searchTask.control = "RUN"
else if isNodeEvent(msg, "results")
' Search results arrived from SearchTask
task = msg.getRoSGNode()
task.unobserveField("results")
results = task.results
task.control = "STOP"
m.searchTask = invalid
if isValid(m.searchDialog)
m.searchDialog.close = true
m.searchDialog = invalid
end if
if isValid(m.searchGroup) and isValid(results)
searchOptions = m.searchGroup.findNode("SearchSelect")
searchOptions.itemData = results
searchOptions.query = m.searchQuery
end if
m.searchGroup = invalid
else if isNodeEvent(msg, "itemSelected")
' Search item selected
startLoadingSpinner()
node = getMsgPicker(msg)
' Normalize alias types to their canonical forms
nodeType = node.type
if nodeType = "LiveTvChannel" then nodeType = "TvChannel"
if nodeType = "LiveTvProgram" or nodeType = "TvProgram" then nodeType = "Program"
if nodeType = "Series"
group = CreateItemDetailsGroup(node)
else if nodeType = "Movie"
group = CreateItemDetailsGroup(node)
else if nodeType = "MusicArtist"
group = CreateItemDetailsGroup(node)
else if nodeType = "MusicAlbum"
group = CreateItemDetailsGroup(node)
else if nodeType = "Audio"
group = CreateItemDetailsGroup(node)
else if nodeType = "MusicVideo"
group = CreateItemDetailsGroup(node)
else if nodeType = "Person"
group = CreateItemDetailsGroup(node)
else if nodeType = "BoxSet"
group = CreateItemDetailsGroup(node)
else if nodeType = "TvChannel" or nodeType = "Program"
group = CreateItemDetailsGroup(node)
else if nodeType = "Episode"
group = CreateItemDetailsGroup(node)
else if LCase(nodeType) = "recording"
group = CreateItemDetailsGroup(node)
else if nodeType = "Playlist"
group = CreateItemDetailsGroup(node)
else if nodeType = "Photo" or nodeType = "PhotoAlbum"
group = CreateItemDetailsGroup(node)
else if nodeType = "Video"
group = CreateItemDetailsGroup(node)
else
' TODO - switch on more node types
stopLoadingSpinner()
messageDialog(translate(translationKeys.ErrorTypeNotYetSupported, [nodeType]))
end if
else if isNodeEvent(msg, "buttonSelected")
' If a button is selected, we have some determining to do
btn = getButton(msg)
if not isValid(btn) then print "No button found in buttonSelected event"
' Guard: skip if spinner is active — isRemoteDisabled prevents FUTURE key events
' but cannot clear events already queued in m.port from rapid presses.
if isValid(btn) and not m.scene.isLoading
group = m.global.sceneManager.callFunc("getActiveScene")
if btn.id = "playButton"
if not isValid(group) then return
' User chose Play button from movie detail view
startLoadingSpinner()
' Check if a specific Audio Stream was selected
audioStreamIdx = 0
if isValid(group.selectedAudioStreamIndex)
audioStreamIdx = group.selectedAudioStreamIndex
end if
' Subtitle dropdown override (-1 = SubtitleSelection.NONE). Only forwarded for
' ItemDetails screens since that's the only place selectedSubtitleStreamIndex
' is surfaced pre-playback.
subtitleStreamIdx = invalid
if isValid(group.selectedSubtitleStreamIndex)
subtitleStreamIdx = group.selectedSubtitleStreamIndex
end if
if isValid(group.itemContent)
' Types requiring API calls — delegate to QuickPlayTask
itemContentType = group.itemContent.type
if group.subtype() = "ItemDetails" and (itemContentType = "Series" or itemContentType = "Season" or itemContentType = "BoxSet" or itemContentType = "MusicArtist" or itemContentType = "MusicAlbum" or itemContentType = "Playlist")
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = {
action: "playAll" + itemContentType,
id: group.itemContent.id,
seriesId: group.itemContent.seriesId
}
m.activeQuickPlayTask.observeField("output", m.port)
m.activeQuickPlayTask.control = "RUN"
else
queueItem = nodeHelpers.createQueueItem(group.itemContent)
if not isValid(queueItem) then return
queueItem.selectedAudioStreamIndex = audioStreamIdx
if isValid(subtitleStreamIdx)
queueItem.selectedSubtitleStreamIndex = subtitleStreamIdx
end if
' selectedVideoStreamId is the user-chosen MediaSource (alternate version).
' This is a separate identifier from the Jellyfin item id — it must land in
' queueItem.mediaSourceId, which VideoPlayerView.init passes to
' LoadMetaDataTask.mediaSourceId. Overwriting queueItem.id here would
' replace the Jellyfin item id and break metadata lookup entirely.
if isValidAndNotEmpty(group.selectedVideoStreamId)
queueItem.mediaSourceId = group.selectedVideoStreamId
end if
' ItemDetails: Play button always starts from beginning (Resume button handles resume)
if group.subtype() = "ItemDetails"
queueItem.startingPoint = 0
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", queueItem)
m.global.queueManager.callFunc("playQueue")
end if
end if
end if
if isValid(group.lastFocus) and isValid(group.lastFocus.id) and group.lastFocus.id = "mainGroup"
buttons = group.findNode("buttons")
if isValid(buttons)
group.lastFocus = group.findNode("buttons")
end if
end if
if isValid(group.lastFocus)
group.lastFocus.setFocus(true)
end if
else if btn.id = "resumeButton"
if not isValid(group) then return
' User chose Resume button from detail view
startLoadingSpinner()
' Check if a specific Audio Stream was selected
audioStreamIdx = 0
if isValid(group.selectedAudioStreamIndex)
audioStreamIdx = group.selectedAudioStreamIndex
end if
' Subtitle dropdown override (see playButton path above)
subtitleStreamIdx = invalid
if isValid(group.selectedSubtitleStreamIndex)
subtitleStreamIdx = group.selectedSubtitleStreamIndex
end if
if isValid(group.itemContent)
' Series on ItemDetails: resume the next-up episode stored on the component
if group.subtype() = "ItemDetails" and group.itemContent.type = "Series"
nextEp = group.nextUpEpisode
if isValid(nextEp) and isValidAndNotEmpty(nextEp.id)
queueItem = nodeHelpers.createQueueItem(nextEp)
if not isValid(queueItem)
stopLoadingSpinner()
return
end if
queueItem.startingPoint = nextEp.playbackPositionTicks
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", queueItem)
m.global.queueManager.callFunc("playQueue")
else
stopLoadingSpinner()
end if
else
queueItem = nodeHelpers.createQueueItem(group.itemContent)
if not isValid(queueItem) then return
queueItem.selectedAudioStreamIndex = audioStreamIdx
if isValid(subtitleStreamIdx)
queueItem.selectedSubtitleStreamIndex = subtitleStreamIdx
end if
' selectedVideoStreamId is the user-chosen MediaSource (alternate version).
' Land it in mediaSourceId (not id) — see playButton handler comment above
' for the rationale.
if isValidAndNotEmpty(group.selectedVideoStreamId)
queueItem.mediaSourceId = group.selectedVideoStreamId
end if
' Set starting point to saved position
if group.itemContent.playbackPositionTicks > 0
queueItem.startingPoint = group.itemContent.playbackPositionTicks
else
queueItem.startingPoint = 0
end if
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", queueItem)
m.global.queueManager.callFunc("playQueue")
end if
end if
if isValid(group.lastFocus) and isValid(group.lastFocus.id) and group.lastFocus.id = "mainGroup"
buttons = group.findNode("buttons")
if isValid(buttons)
group.lastFocus = group.findNode("buttons")
end if
end if
if isValid(group.lastFocus)
group.lastFocus.setFocus(true)
end if
else if btn.id = "trailerButton"
' User chose to play a trailer — delegate to QuickPlayTask
startLoadingSpinner()
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = { action: "loadTrailers", id: group.id }
m.activeQuickPlayTask.observeField("output", m.port)
m.activeQuickPlayTask.control = "RUN"
else if btn.id = "watchedButton"
item = group.itemContent
if isValid(item) and isValidAndNotEmpty(item.id)
' Series: confirm before marking all episodes watched/unwatched (can affect hundreds of episodes)
if item.type = "Series"
watchedButton = group.findNode("watchedButton")
m.pendingWatchedItemId = item.id
' Read current state from button — writing to itemContent triggers a full rebuild
m.pendingWatchedIsCurrentlyWatched = isValid(watchedButton) and watchedButton.isButtonSelected
if m.pendingWatchedIsCurrentlyWatched
confirmMsg = translate(translationKeys.MessageMarkAllEpisodesInThisSeries)
confirmBtn = translate(translationKeys.ButtonMarkUnwatched)
else
confirmMsg = translate(translationKeys.MessageMarkAllEpisodesInThisSeries2)
confirmBtn = translate(translationKeys.ButtonMarkWatched)
end if
m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelWatched), [confirmMsg], [translate(translationKeys.ButtonCancel), confirmBtn])
else
watchedButton = group.findNode("watchedButton")
if isValid(watchedButton) and watchedButton.isLoading then continue while ' Already in-flight
' Read current state from button — writing to itemContent triggers a full rebuild
isCurrentlyWatched = isValid(watchedButton) and watchedButton.isButtonSelected
if isCurrentlyWatched
req = GetApi().BuildUnmarkPlayedRequest(item.id)
else
req = GetApi().BuildMarkPlayedRequest(item.id)
end if
newWatchedState = not isCurrentlyWatched
if isValid(watchedButton) then watchedButton.isLoading = true
' Set resume button to loading while we wait for async response
if newWatchedState
resumeButton = group.findNode("resumeButton")
if isValid(resumeButton) then resumeButton.isLoading = true
end if
resultNode = submitApiRequest(req, "watchedToggle")
if isValid(resultNode)
m.watchedResultNode = resultNode
m.pendingWatchedButton = watchedButton
m.pendingWatchedNewState = newWatchedState
m.pendingWatchedItemType = item.type
m.watchedResultNode.observeField("isDone", m.port)
else
' Pool not ready — fall back to fire-and-forget + direct update
SubmitSideEffect(req)
if isValid(watchedButton)
watchedButton.isButtonSelected = newWatchedState
watchedButton.isLoading = false
end if
' Remove stale resume button when marking as watched
if newWatchedState
removeResumeButtonFromGroup(group)
end if
end if
end if
end if
else if btn.id = "favoriteButton"
item = group.itemContent
favoriteButton = group.findNode("favoriteButton")
if isValid(item) and isValidAndNotEmpty(item.id) and isValid(favoriteButton)
if favoriteButton.isLoading then continue while ' Already in-flight
' Read current state from button — writing to itemContent triggers a full rebuild
newState = not favoriteButton.isButtonSelected
if newState
req = GetApi().BuildMarkFavoriteRequest(item.id)
else
req = GetApi().BuildUnmarkFavoriteRequest(item.id)
end if
favoriteButton.isLoading = true
resultNode = submitApiRequest(req, "favoriteToggle")
if isValid(resultNode)
m.favoriteResultNode = resultNode
m.pendingFavoriteButton = favoriteButton
m.pendingFavoriteNewState = newState
m.favoriteResultNode.observeField("isDone", m.port)
else
' Pool not ready — fall back to fire-and-forget
SubmitSideEffect(req)
favoriteButton.isButtonSelected = newState
favoriteButton.isLoading = false
end if
end if
else if btn.id = "refreshButton"
' Trigger data refresh for the current screen
if isValid(group)
startLoadingSpinner()
group.refreshExtrasData = not group.refreshExtrasData
group.refreshItemDetailsData = not group.refreshItemDetailsData
end if
else if btn.id = "shuffleButton"
' Shuffle: routes by item type so new types (Season, Artist, Person, etc.) can be added.
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
startLoadingSpinner()
item = group.itemContent
if item.type = "PhotoAlbum"
' PhotoAlbum shuffle: launch random slideshow
m.photoAlbumShuffleMode = true
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = { action: "loadPhotoAlbum", id: item.id }
m.activeQuickPlayTask.observeField("output", m.port)
m.activeQuickPlayTask.control = "RUN"
else
' API calls run off the render thread via GetShuffleItemsTask; result handled by "shuffleItems" event.
m.shuffleTask = CreateObject("roSGNode", "GetShuffleItemsTask")
m.shuffleTask.shuffleInput = { type: item.type, id: item.id, seriesId: item.seriesId }
m.shuffleTask.observeField("shuffleItems", m.port)
m.shuffleTask.control = "RUN"
end if
end if
else if btn.id = "instantMixButton"
' Instant Mix — delegate to QuickPlayTask
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
startLoadingSpinner()
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = { action: "instantMix", id: group.itemContent.id }
m.activeQuickPlayTask.observeField("output", m.port)
m.activeQuickPlayTask.control = "RUN"
end if
else if btn.id = "goToSeriesButton"
' Navigate to the parent series detail screen
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.seriesId)
CreateItemDetailsGroup({ id: group.itemContent.seriesId, type: "Series" })
end if
else if btn.id = "goToAlbumButton"
' Navigate to the album detail screen for the current Audio item
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.albumId)
CreateItemDetailsGroup({ id: group.itemContent.albumId, type: "MusicAlbum" })
end if
else if btn.id = "goToArtistButton"
' Navigate to the artist detail screen for the current MusicAlbum.
' Prefers albumArtists (primary album artist); falls back to artistItems when only one artist.
if isValid(group) and isValid(group.itemContent)
item = group.itemContent
artistId = ""
if isValid(item.albumArtists) and item.albumArtists.count() > 0
firstAlbumArtist = item.albumArtists[0]
if isValid(firstAlbumArtist) and isValidAndNotEmpty(firstAlbumArtist.Id)
artistId = firstAlbumArtist.Id
end if
end if
if artistId = "" and isValid(item.artistItems) and item.artistItems.count() = 1
firstArtistItem = item.artistItems[0]
if isValid(firstArtistItem) and isValidAndNotEmpty(firstArtistItem.Id)
artistId = firstArtistItem.Id
end if
end if
if isValidAndNotEmpty(artistId)
CreateItemDetailsGroup({ id: artistId, type: "MusicArtist" })
end if
end if
else if btn.id = "viewPhotoButton"
' Open Photo in the PhotoDetails viewer
if isValid(group) and isValid(group.itemContent)
quickplay.photo(group.itemContent)
end if
else if btn.id = "slideshowButton"
' Launch sequential slideshow for PhotoAlbum
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
startLoadingSpinner()
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = { action: "loadPhotoAlbum", id: group.itemContent.id }
m.activeQuickPlayTask.observeField("output", m.port)
m.activeQuickPlayTask.control = "RUN"
end if
else if btn.id = "watchChannelButton"
' Play a Live TV channel (TvChannel or Program's channel)
if isValid(group) and isValid(group.itemContent)
startLoadingSpinner()
item = group.itemContent
queueItem = nodeHelpers.createQueueItem(item)
' Program: play the channel the program is on
if item.type = "Program" and isValidAndNotEmpty(item.channelId)
queueItem.id = item.channelId
queueItem.type = "TvChannel"
end if
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", queueItem)
m.global.queueManager.callFunc("playQueue")
end if
else if btn.id = "goToChannelButton"
' Navigate to the TvChannel detail screen for this Program
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.channelId)
CreateItemDetailsGroup({ id: group.itemContent.channelId, type: "TvChannel" })
end if
else if btn.id = "recordButton"
item = group.itemContent
recordButton = group.findNode("recordButton")
if isValid(recordButton) and recordButton.isLoading then continue while ' Already in-flight
programToRecord = invalid
if item.type = "TvChannel" and isValid(item.currentProgram)
programToRecord = item.currentProgram
else if item.type = "Program"
programToRecord = item
end if
if isValid(programToRecord)
if isValid(recordButton) then recordButton.isLoading = true
m.pendingRecordButton = recordButton
m.pendingRecordIsCancel = isValidAndNotEmpty(programToRecord.timerId)
recordTask = CreateObject("roSGNode", "RecordProgramTask")
recordTask.programDetails = programToRecord
recordTask.observeField("result", m.port)
recordTask.control = "RUN"
end if
else if btn.id = "deleteButton"
print "deleteButton pressed"
if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
m.pendingDeleteItemId = group.itemContent.id
m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.ButtonDelete), [translate(translationKeys.ErrorAreYouSureYouWantTo)], [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonDelete)])
end if
else
' If there are no other button matches, check if this is a simple "OK" Dialog & Close if so
dialog = msg.getRoSGNode()
if dialog.id = "OKDialog"
dialog.unobserveField("buttonSelected")
dialog.close = true
return
end if
print "ERROR: Unhandled button id: " + btn.id
end if
end if ' isValid(btn) and not m.scene.isLoading
else if isNodeEvent(msg, "optionSelected")
button = msg.getRoSGNode()
group = m.global.sceneManager.callFunc("getActiveScene")
actionId = button.id
' Close the side panel before handling the action
' Search from scene — panel may be reparented to optionsPanelOverlay while open
if isValid(group)
panel = m.scene.findNode("options")
if isValid(panel)
panel.visible = false
end if
if isValid(group.lastFocus)
group.lastFocus.setFocus(true)
else
group.setFocus(true)
end if
end if
if handleMenuAction(actionId) then goto appStart
else if isNodeEvent(msg, "userMenuAction")
actionId = msg.getData()
if handleMenuAction(actionId) then goto appStart
else if type(msg) = "roDeviceInfoEvent"
event = msg.GetInfo()
if event.exitedScreensaver = true
m.global.sceneManager.callFunc("resetTime")
group = m.global.sceneManager.callFunc("getActiveScene")
if isValid(group)
' refresh the current view
if group.isSubType("JRScreen")
group.callFunc("onScreenShown")
end if
end if
else if isValid(event.audioGuideEnabled)
tmpGlobalDevice = m.global.device
tmpGlobalDevice.AddReplace("isaudioguideenabled", event.audioGuideEnabled)
' update global device array
m.global.setFields({ device: tmpGlobalDevice })
else if isValid(event.Mode)
' Indicates the current global setting for the Caption Mode property, which may be one of the following values:
' "On"
' "Off"
' "Instant replay"
' "When mute" (Only returned for a TV; this option is not available on STBs).
print "event.Mode = ", event.Mode
if isValid(event.Mute)
print "event.Mute = ", event.Mute
end if
else if isValid(event.linkStatus)
' True if the device currently seems to have an active network connection.
print "event.linkStatus = ", event.linkStatus
else if isValid(event.generalMemoryLevel)
' This event will be sent first when the OS transitions from "normal" to "low" state and will continue to be sent while in "low" or "critical" states.
' - "normal" means that the general memory is within acceptable levels
' - "low" means that the general memory is below acceptable levels but not critical
' - "critical" means that general memory are at dangerously low level and that the OS may force terminate the application
print "event.generalMemoryLevel = ", event.generalMemoryLevel
m.global.device.memoryLevel = event.generalMemoryLevel
else if isValid(event.audioCodecCapabilityChanged)
' The audio codec capability has changed if true.
print "event.audioCodecCapabilityChanged = ", event.audioCodecCapabilityChanged
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
else if isValid(event.videoCodecCapabilityChanged)
' The video codec capability has changed if true.
print "event.videoCodecCapabilityChanged = ", event.videoCodecCapabilityChanged
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
else if isValid(event.appFocus)
' It is set to False when the System Overlay (such as the confirm partner button HUD or the caption control overlay) takes focus and True when the channel regains focus
print "event.appFocus = ", event.appFocus
else
print "Unhandled roDeviceInfoEvent:"
print msg.GetInfo()
end if
else if type(msg) = "roInputEvent"
if msg.IsInput()
info = msg.GetInfo()
if info.DoesExist("mediatype") and info.DoesExist("contentid")
' Deep-link launch: another app handed us content to play.
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem({ id: info.contentId, type: "video" }))
m.global.queueManager.callFunc("playQueue")
else if isValid(info.type) and info.type = "transport"
' Voice transport command (play, pause, seek, next, startover, skip, ...).
' Dispatch to the active player; otherwise ack as no-media so Roku OS shows
' "Nothing is playing" rather than the default "Command not available".
' roAppManager calls happen here (MAIN thread) — it can't be created on the
' render thread where the player's callFunc executes.
activeScene = m.global.sceneManager.callFunc("getActiveScene")
transportStatus = "error.no-media"
transportNowPlaying = invalid
if isValid(activeScene)
sceneType = activeScene.subtype()
if sceneType = "VideoPlayerView" or sceneType = "AudioPlayerView"
transportRet = activeScene.callFunc("handleTransport", info)
if isValid(transportRet) and isValid(transportRet.status)
transportStatus = transportRet.status
else
transportStatus = "unhandled"
end if
if isValid(transportRet) and isValid(transportRet.nowPlaying)
transportNowPlaying = transportRet.nowPlaying
end if
end if
end if
if isValid(transportNowPlaying)
appMgr = CreateObject("roAppManager")
appMgr.SetNowPlayingContentMetaData(transportNowPlaying)
else if LCase(info.command) = "nowplaying"
' Per Roku doc: when we can't report current content, pass invalid to clear
' stale metadata. Covers both "no active player" and "player mounted but
' item not yet loaded" — neither is "currently playing" from the user's POV.
appMgr = CreateObject("roAppManager")
appMgr.SetNowPlayingContentMetaData(invalid)
end if
input.EventResponse({ id: info.id, status: transportStatus })
end if
end if
else if isNodeEvent(msg, "isDataReturned")
popupNode = msg.getRoSGNode()
stopLoadingSpinner()
if isValid(popupNode) and isValid(popupNode.returnData)
print "popupNode.returnData = ", popupNode.returnData
' Handle exit confirmation dialog
if m.global.sceneManager.isPendingExitConfirmation = true
m.global.sceneManager.isPendingExitConfirmation = false
if popupNode.returnData.indexSelected = 1 and popupNode.returnData.buttonSelected = translate(translationKeys.ButtonExit)
' User confirmed exit
m.scene.exit = true
end if
' Handle delete confirmation dialog
else if isValid(m.pendingDeleteItemId)
if popupNode.returnData.indexSelected = 1 and popupNode.returnData.buttonSelected = translate(translationKeys.ButtonDelete)
' User confirmed deletion
SubmitSideEffect(GetApi().BuildDeleteItemRequest(m.pendingDeleteItemId))
m.pendingDeleteItemId = invalid
m.global.sceneManager.callFunc("popScene")
else
' User cancelled or dialog closed
m.pendingDeleteItemId = invalid
end if
' Handle watched confirmation dialog (Series only)
else if isValid(m.pendingWatchedItemId)
if popupNode.returnData.indexSelected = 1
' User confirmed — mark/unmark all episodes
if m.pendingWatchedIsCurrentlyWatched
req = GetApi().BuildUnmarkPlayedRequest(m.pendingWatchedItemId)
else
req = GetApi().BuildMarkPlayedRequest(m.pendingWatchedItemId)
end if
newWatchedState = not m.pendingWatchedIsCurrentlyWatched
m.pendingWatchedItemId = invalid
m.pendingWatchedIsCurrentlyWatched = invalid
activeGroup = m.global.sceneManager.callFunc("getActiveScene")
watchedButton = invalid
if isValid(activeGroup)
watchedButton = activeGroup.findNode("watchedButton")
end if
if isValid(watchedButton) then watchedButton.isLoading = true
' Set resume button to loading while we wait for async data
resumeButton = invalid
if isValid(activeGroup) then resumeButton = activeGroup.findNode("resumeButton")
if isValid(resumeButton) then resumeButton.isLoading = true
resultNode = submitApiRequest(req, "watchedToggle")
if isValid(resultNode)
m.watchedResultNode = resultNode
m.pendingWatchedButton = watchedButton
m.pendingWatchedNewState = newWatchedState
m.pendingWatchedItemType = "Series"
m.watchedResultNode.observeField("isDone", m.port)
else
' Pool not ready — fall back to fire-and-forget + direct update
SubmitSideEffect(req)
if isValid(watchedButton)
watchedButton.isButtonSelected = newWatchedState
watchedButton.isLoading = false
end if
' Series: refresh resume data to reflect new watched state
if isValid(activeGroup)
activeGroup.refreshResumeData = not activeGroup.refreshResumeData
end if
end if
else
' User cancelled
m.pendingWatchedItemId = invalid
m.pendingWatchedIsCurrentlyWatched = invalid
end if
else
selectedItem = m.global.queueManager.callFunc("getHold")
m.global.queueManager.callFunc("clearHold")
if isValidAndNotEmpty(selectedItem) and isValid(selectedItem[0])
if popupNode.returnData.indexselected = 0
'Resume video from resume point
startLoadingSpinner()
startingPoint = 0
if isValid(selectedItem[0].playbackPositionTicks) and selectedItem[0].playbackPositionTicks > 0
startingPoint = selectedItem[0].playbackPositionTicks
end if
selectedItem[0].startingPoint = startingPoint
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", selectedItem[0])
m.global.queueManager.callFunc("playQueue")
else if popupNode.returnData.indexselected = 1
'Start Over from beginning selected, set position to 0
startLoadingSpinner()
selectedItem[0].startingPoint = 0
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", selectedItem[0])
m.global.queueManager.callFunc("playQueue")
end if
end if
end if
end if
else if isNodeEvent(msg, "shuffleItems")
' GetShuffleItemsTask completed — queue and play the results
shuffleTask = msg.getRoSGNode()
shuffleItems = shuffleTask.shuffleItems
shuffleTask.unobserveField("shuffleItems")
shuffleTask.control = "STOP"
m.shuffleTask = invalid
if isValid(shuffleItems) and shuffleItems.count() > 0
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("resetShuffle")
m.global.queueManager.callFunc("set", shuffleItems)
if shuffleItems.count() > 1
m.global.queueManager.callFunc("toggleShuffle") ' Fisher-Yates client-side shuffle
end if
m.global.queueManager.callFunc("playQueue")
else
stopLoadingSpinner()
end if
else if isNodeEvent(msg, "reloadHomeRequested")
' Theme colors changed - reload home screen with fresh state
m.global.sceneManager.callFunc("clearScenes")
createAndShowHomeGroup()
else
print "Unhandled " type(msg)
print msg
end if
end while
end sub
' Initialize fallback font download process
sub initializeFallbackFont()
' Check if user needs fallback fonts
needsFallbackFonts = m.global.user.settings.playbackSubsCustom = true or m.global.user.settings.uiFontFallback = true
if not needsFallbackFonts
print "User doesn't need fallback fonts, skipping font download"
return
end if
print "User has custom subtitles or fallback UI font enabled. Starting font download task..."
' Create and start font download task
fontDownloadTask = CreateObject("roSGNode", "FontDownloadTask")
fontDownloadTask.observeField("isFontDownloadCompleted", m.port)
fontDownloadTask.control = "RUN"
' Store whether we need to wait for completion (UI fonts enabled)
m.waitForFontDownload = (m.global.user.settings.uiFontFallback = true)
if m.waitForFontDownload
startLoadingSpinner(true, translate(translationKeys.LabelDownloadingFallbackFont))
print "UI fallback fonts enabled - app will wait for font download completion"
else
print "Only subtitle fallback fonts enabled - app will continue loading normally"
end if
end sub
' Load the home screen - may wait for font processing if UI fallback fonts are enabled
sub loadHomeScreen()
' If we don't need to wait for font download, load immediately
if not isValid(m.waitForFontDownload) or not m.waitForFontDownload
createAndShowHomeGroup()
return
end if
' Otherwise, we'll wait for the font download to complete
' The actual loading will happen in the message loop when we receive isFontDownloadCompleted
print "Waiting for font download completion before loading home screen"
end sub
' Create and show the home group
sub createAndShowHomeGroup()
group = CreateHomeGroup()
group.callFunc("loadLibraries")
m.global.sceneManager.callFunc("pushScene", group)
stopLoadingSpinner()
m.scene.observeField("exit", m.port)
' update lastRunVersion but only on prod
if not m.global.app.isDev
' has the current user ran this version before?
usersLastRunVersion = m.global.user.lastRunVersion
if not isValid(usersLastRunVersion) or not versionChecker(usersLastRunVersion, m.global.app.version)
setUserSetting("LastRunVersion", m.global.app.version)
end if
end if
end sub
' Handle font download completion and optionally calculate scale factor
sub handleFontDownloadCompletion(fontDownloadTask as object)
if not fontDownloadTask.isFontDownloadSuccess
print "WARNING: Font download failed: " + fontDownloadTask.errorMessage
else
' If UI fallback fonts are enabled, calculate scale factor
if m.global.user.settings.uiFontFallback = true
print "Calculating font scale factor for UI fallback fonts"
calculateFontScaleFactor()
end if
end if
' Clean up the task
fontDownloadTask.unobserveField("isFontDownloadCompleted")
fontDownloadTask = invalid
' If we were waiting for font download, now load the home screen
if isValid(m.waitForFontDownload) and m.waitForFontDownload
print "Font processing complete, loading home screen"
createAndShowHomeGroup()
end if
end sub
' Calculate global font scale factor for fallback font
sub calculateFontScaleFactor()
' Verify the fallback font file exists
fs = CreateObject("roFileSystem")
if not fs.Exists("tmp:/font")
print "ERROR - calculateFontScaleFactor: Fallback font file does not exist"
return
end if
' Create and run global font scaling task
fontTask = CreateObject("roSGNode", "FontScalingTask")
fontTask.control = "RUN"
' Wait for the task to complete (with timeout) - don't use observeField
timeout = CreateObject("roTimespan")
timeout.mark()
while fontTask.scaleFactor = 0 and timeout.TotalMilliseconds() < 10000
sleep(10)
end while
if fontTask.scaleFactor > 0
' Update font scale factor in global user session
m.global.user.fontScaleFactor = fontTask.scaleFactor
print "INFO - calculateFontScaleFactor: Set global font scale factor to " + fontTask.scaleFactor.toStr()
else
print "WARNING - calculateFontScaleFactor: Failed to calculate scale factor, using default"
end if
end sub
' Handles the output from QuickPlayTask — queues items, opens photo player, or plays trailers.
sub handleQuickPlayOutput(task as object)
output = task.output
task.unobserveField("output")
task.control = "STOP"
m.activeQuickPlayTask = invalid
if not isValid(output) or not isValidAndNotEmpty(output.items)
stopLoadingSpinner()
return
end if
if output.action = "queue"
quickplay.pushToQueue(output.items)
if output.firstItemStartingPoint > 0
m.global.queueManager.callFunc("setCurrentStartingPoint", output.firstItemStartingPoint)
end if
if output.shuffle and m.global.queueManager.callFunc("getCount") > 1
m.global.queueManager.callFunc("toggleShuffle")
end if
m.global.queueManager.callFunc("playQueue")
else if output.action = "photo"
photoPlayer = CreateObject("roSgNode", "PhotoDetails")
photoPlayer.isSlideshow = true
' PhotoAlbum shuffle sets isRandom=true for random slideshow
photoPlayer.isRandom = m.photoAlbumShuffleMode = true
m.photoAlbumShuffleMode = false
photoPlayer.itemsArray = output.items
photoPlayer.itemIndex = 0
m.global.sceneManager.callfunc("pushScene", photoPlayer)
stopLoadingSpinner()
else if output.action = "trailer"
if isValid(output.items[0]) and isValidAndNotEmpty(output.items[0].id)
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("set", output.items)
m.global.queueManager.callFunc("playQueue")
else
stopLoadingSpinner()
end if
end if
end sub
sub handleFavoriteToggleDone()
res = m.favoriteResultNode.result
m.favoriteResultNode.unobserveField("isDone")
m.favoriteResultNode = invalid
' Debug error injection — compiled out in production (bs_const=debug=false)
#if debug
if isValid(m.global.debug) and m.global.debug.shouldForceFavoriteFail
res = { ok: false }
end if
#end if
if isValid(res) and res.ok
if isValid(m.pendingFavoriteButton)
m.pendingFavoriteButton.isButtonSelected = m.pendingFavoriteNewState
end if
else
' Revert button to original state on failure
if isValid(m.pendingFavoriteButton)
m.pendingFavoriteButton.isButtonSelected = not m.pendingFavoriteNewState
end if
displayToast(translate(translationKeys.ErrorFailedToUpdateFavorite), "error")
end if
if isValid(m.pendingFavoriteButton)
m.pendingFavoriteButton.isLoading = false
end if
m.pendingFavoriteButton = invalid
m.pendingFavoriteNewState = invalid
end sub
sub handleWatchedToggleDone()
res = m.watchedResultNode.result
m.watchedResultNode.unobserveField("isDone")
m.watchedResultNode = invalid
' Debug error injection — compiled out in production (bs_const=debug=false)
#if debug
if isValid(m.global.debug) and m.global.debug.shouldForceWatchedFail
res = { ok: false }
end if
#end if
if isValid(res) and res.ok
if isValid(m.pendingWatchedButton)
m.pendingWatchedButton.isButtonSelected = m.pendingWatchedNewState
end if
' Update resume button to reflect new watched state
activeGroup = m.global.sceneManager.callFunc("getActiveScene")
if isValid(activeGroup)
if m.pendingWatchedItemType = "Series"
' Series: re-fetch next-up episode (may change or disappear)
activeGroup.refreshResumeData = not activeGroup.refreshResumeData
else if m.pendingWatchedNewState
' Non-Series marking watched: server clears playback position, remove stale resume button
removeResumeButtonFromGroup(activeGroup)
end if
end if
else
' Revert button to original state on failure
if isValid(m.pendingWatchedButton)
m.pendingWatchedButton.isButtonSelected = not m.pendingWatchedNewState
end if
' Revert resume button loading state on failure
activeGroup = m.global.sceneManager.callFunc("getActiveScene")
if isValid(activeGroup)
resumeButton = activeGroup.findNode("resumeButton")
if isValid(resumeButton) then resumeButton.isLoading = false
end if
displayToast(translate(translationKeys.ErrorFailedToUpdateWatchedStatus), "error")
end if
if isValid(m.pendingWatchedButton)
m.pendingWatchedButton.isLoading = false
end if
m.pendingWatchedButton = invalid
m.pendingWatchedNewState = invalid
m.pendingWatchedItemType = invalid
end sub
sub handleRecordResult(task as object)
res = task.result
task.unobserveField("result")
task.control = "STOP"
if isValid(res) and res.ok
' Toggle button label and icon color to reflect new state
if isValid(m.pendingRecordButton)
if m.pendingRecordIsCancel
m.pendingRecordButton.text = translate(translationKeys.ButtonRecord)
' Revert to default icon color (white)
m.pendingRecordButton.iconBackground = m.global.constants.colorTextPrimary
m.pendingRecordButton.iconFocusBackground = m.global.constants.colorTextPrimary
else
m.pendingRecordButton.text = translate(translationKeys.LabelCancelRecording)
' Active recording — red icon
m.pendingRecordButton.iconBackground = m.global.constants.colorError
m.pendingRecordButton.iconFocusBackground = m.global.constants.colorError
end if
end if
if res.action = "record"
displayToast(translate(translationKeys.LabelRecordingScheduled), "success")
else
displayToast(translate(translationKeys.LabelRecordingCancelled), "success")
end if
else
' Failed — button label stays as-is (no state change)
if isValid(res) and res.action = "record"
displayToast(translate(translationKeys.ErrorFailedToScheduleRecording), "error")
else
displayToast(translate(translationKeys.ErrorFailedToCancelRecording), "error")
end if
end if
if isValid(m.pendingRecordButton)
m.pendingRecordButton.isLoading = false
end if
m.pendingRecordButton = invalid
m.pendingRecordIsCancel = invalid
end sub
' removeResumeButtonFromGroup: Remove the resume button from an ItemDetails button group.
' Used after marking an item as watched — the server clears playback position so the
' resume button is stale. Focus is preserved on the nearest remaining button.
sub removeResumeButtonFromGroup(group as object)
resumeButton = group.findNode("resumeButton")
if not isValid(resumeButton) then return
buttonGrp = group.findNode("buttons")
if not isValid(buttonGrp) then return
currentFocusIndex = buttonGrp.buttonFocused
resumeIndex = -1
for i = 0 to buttonGrp.getChildCount() - 1
child = buttonGrp.getChild(i)
if isValid(child) and child.isSameNode(resumeButton)
resumeIndex = i
exit for
end if
end for
wasFocused = resumeButton.isInFocusChain()
buttonGrp.removeChild(resumeButton)
if resumeIndex >= 0 and currentFocusIndex >= resumeIndex
focusIdx = currentFocusIndex - 1
if focusIdx < 0 then focusIdx = 0
buttonGrp.buttonFocused = focusIdx
end if
if wasFocused
nextButton = buttonGrp.getChild(buttonGrp.buttonFocused)
if isValid(nextButton)
nextButton.setFocus(true)
end if
end if
end sub
' Shared handler for menu actions from both the OptionsSlider and the user dropdown.
' Returns true if the caller should goto appStart (session-ending actions).
function handleMenuAction(actionId as string) as boolean
if actionId = HomeAction.OPEN_SEARCH
group = CreateSearchPage()
m.global.sceneManager.callFunc("pushScene", group)
group.findNode("SearchBox").findNode("searchKey").setFocus(true)
group.findNode("SearchBox").findNode("searchKey").active = true
else if actionId = HomeAction.OPEN_SETTINGS
m.global.sceneManager.callFunc("settings")
else if actionId = HomeAction.CHANGE_SERVER
startLoadingSpinner()
unsetSetting("server")
server.Delete()
SignOut(false)
m.global.sceneManager.callFunc("clearScenes")
return true
else if actionId = HomeAction.CHANGE_USER
startLoadingSpinner()
SignOut(false)
m.global.sceneManager.callFunc("clearScenes")
return true
else if actionId = HomeAction.SIGN_OUT
startLoadingSpinner()
SignOut()
m.global.sceneManager.callFunc("clearScenes")
return true
end if
return false
end function