' bsc-disable-file print-locations (legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements)
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/api/items.bs"
import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/dialogNarration.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/screenReadiness.bs"
import "pkg:/source/utils/screenWaits.bs"
import "pkg:/source/utils/searchOutcome.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"
' This screen has TWO loads, and they are measured as two ledger runs rather than one.
'
' Opening search and running a search are separated by the user typing, which is an
' unbounded wait belonging to the person rather than to the app. Measured on `.177` on 2026-08-14, the
' RTA harness alone sits 1.2 s in that gap. One run spanning both would publish a `settled`
' dominated by how fast something typed, which is not a property of this screen at all.
'
' So `init` opens a run that ends when the keyboard is typable (variant `open`), and every
' query opens a fresh one that ends when its results are on screen (variant `query`):
' `begin`'s documented restart semantics, a re-load being a new run rather than a
' continuation. The variants are what let a reader select between them; `npm run measure
' --nav search` refuses a median until one is named, and names both.
sub init()
screenLoad.begin("searchResults")
m.log = new log.Logger("SearchResults")
' Clear backdrop immediately when search screen opens
m.global.sceneManager.callFunc("setBackgroundImage", "")
m.top.isOptionsAvailable = false
m.searchSelect = m.top.findnode("searchSelect")
m.searchTask = CreateObject("roSGNode", "SearchTask")
m.searchHelpText = m.top.findNode("SearchHelpText")
m.searchHelpText.text = translate(translationKeys.MessageYouCanSearchForTitlesPeople)
' The help text also says "No results." for an answered empty search; this says which.
m.isShowingNoResults = false
' Search-failure state. The button centers itself in the results area once it has measured
' its label.
m.searchFailed = m.top.findNode("searchFailed")
m.searchFailedText = m.top.findNode("searchFailedText")
m.searchRetryButton = m.top.findNode("searchRetryButton")
m.failureNarrationTimer = m.top.findNode("failureNarrationTimer")
m.searchFailedText.text = translate(translationKeys.MessageSearchFailed)
m.searchRetryButton.text = translate(translationKeys.ButtonTryAgain)
m.searchRetryButton.observeField("isReady", "onRetryButtonReady")
m.failureNarrationTimer.duration = narration.OPENING_DELAY
m.failureNarrationTimer.observeField("fire", "onFailureNarrationDue")
' Whether this view is the one on screen: a search answer can land while a result's details
' screen covers this one, and its toast must not show over that screen.
m.isShown = false
' Cache search keyboard reference and observe focus changes
' to adjust alphabet layout when voice button popup appears
searchBox = m.top.findNode("SearchBox")
m.searchAlphabox = searchBox.findNode("searchKey")
m.searchAlphabox.observeField("focusedChild", "onKeyboardFocusChange")
' Observe row item focus for backdrop updates
m.searchSelect.observeField("rowItemFocused", "onSearchItemFocused")
' Observe contentReady to close the `rows` fill in the readiness ledger. SearchRow builds
' the rows in its OWN `m`, so it cannot resolve this screen's ledger directly. It marks a
' field and this screen resolves on it, the same signal ExtrasRowList gives ItemDetails.
m.searchSelect.observeField("contentReady", "onResultRowsReady")
' SearchResults owns its own navigation: a selected result navigates to its route.
m.searchSelect.observeField("itemSelected", "onSearchItemSelected")
' A non-navigable result (plays directly) sets m.top.quickPlayNode; this self-observer
' forwards it to the QueueManager launcher.
m.top.observeField("quickPlayNode", "onQuickPlayLaunch")
' Set initial focus for scene navigation
m.top.lastFocus = searchBox
' First show focuses the keyboard to type immediately; later shows (a resume, for example back
' from a result detail) restore the prior focus.
m.isFirstShow = true
' Last voice query applied via applyRouteQuery. Guards against re-applying (and re-grabbing
' focus) on a back-resume that replays the same route context.
m.lastAppliedQuery = ""
' The keyboard is BUILT here but not yet typable: nothing has focus until the router shows
' the view and `onScreenShown` runs, which is a later turn of the event loop. So it is
' outstanding at paint and has to be declared here rather than discovered afterwards:
' the same shape as `settings`' right-hand panel, and for the same reason.
screenLoad.pending("focus")
' PAINT: the search UI exists and the help text is on screen. What it is not yet is
' usable, which is exactly what `focus` above measures; collapsing the two into one
' number would hide the wait between a screen appearing and it accepting a keystroke.
screenLoad.paint("open")
' 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("searchAlpha", "searchMedias")
end sub
sub onScreenShown()
' The screen is now showing and about to take focus, so it has become typable. Resolved at
' the TOP because every path below this line returns early on some route (a voice query,
' a first show, a keepAlive resume), and a `pending` that any path can skip leaves the
' screen permanently unsettled. The wait being measured is the router's show, not the
' focus assignments underneath it.
'
' Silent on every later call: `/search` is keepAlive, so a back-resume re-runs this long
' after the open run settled, and `resolve` ignores a run that is over (see
' screenReadiness.bs). The one case it does not swallow is a resume onto a query run still
' in flight, where it warns and returns without counting; left unguarded on purpose, since
' nothing is mis-timed by it and the warning is the only way that ordering would ever be
' seen. Same reasoning as `onResultRowsReady`.
screenLoad.resolve("focus")
m.isShown = true
' Voice search arrives as route.context.query: pre-populate the search box and focus its
' text entry, skipping normal focus restoration.
if applyRouteQuery() then return
' Restore texture management: reactivate and restore buffer range so cells reload.
if isValid(m.searchSelect) and isValid(m.searchSelect.content)
updateTextureBufferRange(m.searchSelect.content, m.searchSelect.rowItemFocused[0], m.searchSelect.rowItemFocused[1], m.searchSelect.numRows)
activateTextureManager(m.searchSelect.content)
end if
' On first open, focus + activate the keyboard so the user can type immediately (the router
' mounts this view). Subsequent shows (a resume) restore the prior focus.
if m.isFirstShow
m.isFirstShow = false
if isValid(m.searchAlphabox)
m.searchAlphabox.setFocus(true)
m.searchAlphabox.active = true
return
end if
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
sub onScreenHidden()
m.log.info("onScreenHidden")
m.isShown = false
if isValid(m.searchSelect) and isValid(m.searchSelect.content)
hideTextureManager(m.searchSelect.content)
end if
end sub
' If the route carries a voice query in its context, pre-populate the search box (which
' triggers the search via the searchAlpha alias) and focus the text entry. Returns true when
' a query was applied so onScreenShown can skip normal focus.
'
' onScreenShown fires on first open AND on resume, and the router replays the
' ORIGINAL route (with its query context) on a back-resume (Router.brs reuses view.route), so
' applying unconditionally would re-grab focus to the text box every time the user backs out of
' a result. Guard on the last-applied value: a back-resume carries the same query we already
' applied (skip), while a new voice search carries a different query (apply). The box
' already holds the prior term on a resume, so skipping a repeat is also the right UX.
function applyRouteQuery() as boolean
route = m.top.route
if not isValid(route) or not isValid(route.context) then return false
query = route.context.query
if not isValidAndNotEmpty(query) then return false
if query = m.lastAppliedQuery then return false
m.lastAppliedQuery = query
m.searchAlphabox.text = query
m.searchAlphabox.textEditBox.setFocus(true)
return true
end function
' A search result was selected: navigate to its route (rich node as context). Non-navigable
' results fall through to playback.
sub onSearchItemSelected(msg)
item = getMsgPicker(msg)
if not isValid(item) then return
route = routeForItem(item)
if isValid(route)
sgrouter.navigateTo(route, { context: { item: item } })
else
m.top.quickPlayNode = item
end if
end sub
' Forward a non-navigable result to the playback launcher.
sub onQuickPlayLaunch(msg)
node = msg.getData()
if isValid(node) then m.global.queueManager.callFunc("launchItem", node)
end sub
' onKeyboardFocusChange: Fires when focus changes within the keyboard subtree (including entry/exit).
' Clears stale backdrop when keyboard gains focus, and adjusts alphabet layout when the
' voice button popup appears (textEditBox focused moves the textbox up to avoid overlap).
sub onKeyboardFocusChange()
if not isValid(m.searchAlphabox) then return
' Clear stale backdrop whenever keyboard gains focus
if m.searchAlphabox.isInFocusChain()
m.global.sceneManager.callFunc("setBackgroundImage", "")
end if
' Check if the textEditBox has focus (voice button popup is visible)
if m.searchAlphabox.textEditBox.hasFocus()
' Move textbox up so voice button popup doesn't cover alphabet rows below
m.searchAlphabox.textEditBox.translation = "[0, -150]"
else
' Reset textbox to original position
m.searchAlphabox.textEditBox.translation = "[0, 0]"
end if
end sub
' onSearchItemFocused: Update backdrop when search result is focused
sub onSearchItemFocused()
if not isValid(m.searchSelect.rowItemFocused) or m.searchSelect.rowItemFocused[0] = -1 or m.searchSelect.rowItemFocused[1] = -1
return
end if
updateTextureBufferRange(m.searchSelect.content, m.searchSelect.rowItemFocused[0], m.searchSelect.rowItemFocused[1], m.searchSelect.numRows)
' Get focused item from search results
rowContent = m.searchSelect.content.getChild(m.searchSelect.rowItemFocused[0])
if isValid(rowContent)
focusedItem = rowContent.getChild(m.searchSelect.rowItemFocused[1])
if isValid(focusedItem) and isValidAndNotEmpty(focusedItem.id)
' Pass device resolution so the URL matches other screens showing the same item,
' allowing BackdropFader to deduplicate and avoid unnecessary reloads/flicker.
deviceRes = m.global.device.uiResolution
backdropUrl = getItemBackdropUrl(focusedItem, { width: deviceRes[0], height: deviceRes[1] })
m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
else
' Item has no backdrop - set transparent
m.global.sceneManager.callFunc("setBackgroundImage", "")
end if
end if
end sub
sub searchMedias()
query = m.top.searchAlpha
' A new query (or a retry) clears what the last one ended with.
clearSearchOutcome()
' An emptied search box waits on nothing.
if query.len() = 0
screenWaits.finish(m.top, "results")
end if
' A NEW node per keystroke, never a relaunch of the previous one (see replaceTask): a
' relaunch of a node still searching is sometimes ignored, and then the letter last typed
' is never searched at all.
m.searchTask = replaceTask(m.searchTask, "SearchTask", "results", { query: query })
if isValid(query) and query <> ""
' A new query is a NEW run, not a continuation: `begin` restarts the ledger, so the
' clock starts at the keystroke rather than at the screen opening. Each keystroke
' supersedes the last (the line above stops the task in flight), and the superseded run
' never paints, so it emits nothing rather than a truncated number.
'
' Inside this guard on purpose: an emptied search box also reaches here, and it
' starts no task and produces no results, so opening a run for it would leave a ledger
' that can never settle.
screenLoad.begin("searchResults")
' Declared BEFORE `launchTask` below, so the resolve can never outrun its own pending.
screenLoad.pending("results")
m.searchHelpText.visible = false
' A new query restarts the wait: the viewer is waiting on this one, not the last.
screenWaits.begin(m.top, "results")
end if
m.searchTask.observeField("results", "loadResults")
m.top.overhangTitle = translate(translationKeys.ButtonSearch) + ": " + query
launchTask(m.searchTask)
end sub
sub loadResults(event as object)
if not isCurrentTaskEvent(event, m.searchTask)
' An invalid m.searchTask is teardown: onDestroy released the node, so there is nothing
' to report. Otherwise this is a delivery from a node searchMedias has since replaced,
' one queued before its observer was removed. Reading m.searchTask in its place would
' read the NEW run's empty results, resolve its fill early and unobserve it, so the live
' query's results would never arrive; hence the drop.
'
' WARN, not info: this line is the only field evidence that Roku delivers an event
' queued before `unobserveField`, which its docs neither promise nor rule out. The
' measured expectation is that it never fires: `SearchTask` publishes once, as its last
' act, so a stopped search does not deliver at all.
if isValid(m.searchTask)
stale = event.getData()
answered = ""
if isValid(stale) and isValid(stale.query) then answered = stale.query
m.log.warn("superseded search delivery dropped - answered", answered, "current", m.top.searchAlpha)
end if
return
end if
' Read ONCE and used for everything below.
results = m.searchTask.results
' The server work is done. Resolved at the TOP because this handler has an early return
' on the no-results path. Measured on `.177`, this fill is nearly the whole wait: the
' task runs FOUR round trips serially (`/items`, `/persons`, `/artists`,
' `/livetv/programs`), 1676 ms end to end for a one-character query.
screenLoad.resolve("results")
m.searchTask.unobserveField("results")
screenWaits.finish(m.top, "results")
' Declared before the assignment that triggers it, not after: `SearchRow.getData` runs
' off an `onChange`, and declaring first is correct whether Scene Graph dispatches that
' inline or a turn later.
'
' The BUILD is answered: it runs inline inside the assignment below. Across every series
' taken on `.177` the `rows` fill measures ~46 ms and `settled` lands within 1 ms of
' `paint`, so that 46 ms sits between THIS line and paint rather than after it, which
' means `paint` for a query already contains the row building, and the paint/settle split
' says nothing for this variant. The actionable split is the two fills: server (~1.3 s
' cold) against row-building (~46 ms).
'
' The MARK's delivery is the half that moves (at or shortly before paint in one series, 1 ms
' after it in another), and that is the whole reason `onResultRowsReady` asks the ledger
' before resolving rather than assuming it is still the same run.
screenLoad.pending("rows")
m.searchSelect.itemdata = results
m.searchSelect.query = m.top.SearchAlpha
' PAINT: the results are handed over, the rows are built and the spinner is down. Note
' this is not the usual paint-then-settle shape: the build ran inline in the assignment
' above, so both fills are already closed here and `settled` follows within a millisecond.
' For this variant the two FILLS are the split, not paint against settled.
screenLoad.paint("query")
outcome = searchOutcome.view(results.status, results.TotalRecordCount)
if outcome.show = searchOutcome.SHOW_FAILED
showSearchFailure()
else if outcome.show = searchOutcome.SHOW_EMPTY
m.searchHelpText.text = translate(translationKeys.MessageSearchNoResults)
m.searchHelpText.visible = true
m.isShowingNoResults = true
else if outcome.toastKey <> "" and m.isShown
displayToast(translate(outcome.toastKey))
end if
if outcome.announceKey <> "" and m.isShown then announceSearchOutcome(outcome)
if results.TotalRecordCount = 0
' make sure focus is on the keyboard
if m.searchSelect.isinFocusChain()
m.searchAlphabox.setFocus(true)
end if
return
end if
end sub
' Every read of the search failed: say so where the results go, with Try again. Focus stays
' where it is: the viewer may still be typing, and results arriving do not take it either.
sub showSearchFailure()
m.searchHelpText.visible = false
m.isShowingNoResults = false
m.searchFailed.visible = true
end sub
' Hides the failure state and the no-results text before a new query or a retry. Focus on Try
' again goes back to the keyboard, as the button it is on is going.
sub clearSearchOutcome()
if m.searchFailed.visible
m.searchFailed.visible = false
if m.searchRetryButton.hasFocus() then m.searchAlphabox.setFocus(true)
end if
if m.isShowingNoResults
m.searchHelpText.visible = false
m.isShowingNoResults = false
end if
end sub
' Speaks a failed or empty search, which the screen reader would not: focus stays on the
' keyboard, and it speaks only what has focus. Queued, not flushed, as "Loading more" is
' (BaseGridView.onLoadingMoreDue): nothing moved, so what is being read is still true. A failure
' names Try again too, so the viewer knows there is something to reach.
sub announceSearchOutcome(outcome as object)
text = translate(outcome.announceKey)
if outcome.show = searchOutcome.SHOW_FAILED
text = composeDialogAnnouncement("", text, m.searchRetryButton.text)
end if
narrateStatus(text)
end sub
sub focusRetryButton()
m.searchRetryButton.setFocus(true)
' The platform speaks only the focused button; the message is ours to announce.
m.failureNarrationTimer.control = "stop"
m.failureNarrationTimer.control = "start"
end sub
sub onRetryButtonReady()
area = m.searchFailedText.translation
width = m.searchRetryButton.localBoundingRect().width
x = int(area[0] + (m.searchFailedText.width - width) / 2)
' On the 720p output grid (every 3 pixels of 1080p); see dialogLayout.bs.
m.searchRetryButton.translation = [x - (x mod 3), m.searchRetryButton.translation[1]]
end sub
sub onFailureNarrationDue()
if not m.isShown or not m.searchFailed.visible or not m.searchRetryButton.hasFocus() then return
narrateDialogOpening("", m.searchFailedText.text, m.searchRetryButton.text)
end sub
' The result rows are built and on screen: closes the `rows` fill.
'
' Reads the VALUE rather than treating the notification as the signal: SearchRow clears the
' field at the start of every build, so half the callbacks here are the clear rather than
' the completion.
'
' UNGUARDED against arriving late, on purpose. The mark is delivered by a CHILD, so a
' keystroke landing between the build and the mark would open the next run before it lands,
' and this would then resolve a fill that run never declared. `resolve` already handles that
' safely on its own (it warns and returns WITHOUT counting, and the next run still declares
' and closes its own `rows`), so the only thing a guard here would buy is silence, and the
' warning is the one signal that would ever tell anyone the race is real. Unobserved on
' `.177` across two keystroke intervals; if `screen-load unbalanced` ever names `rows`, that
' is the race, and the tripwire is worth more than the suppressed edge case.
sub onResultRowsReady()
if not m.searchSelect.contentReady then return
screenLoad.resolve("rows")
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "left" and (m.searchSelect.isinFocusChain() or m.searchRetryButton.hasFocus())
m.searchAlphabox.setFocus(true)
return true
else if key = "OK" and m.searchRetryButton.hasFocus()
' Runs the same query again, through the path a keystroke takes.
if m.searchFailed.visible then searchMedias()
return true
else if key = "right" and m.searchFailed.visible and m.searchAlphabox.isInFocusChain()
focusRetryButton()
return true
else if key = "right" and isValid(m.searchSelect.content) and m.searchSelect.content.getChildCount() > 0
m.searchSelect.setFocus(true)
return true
else if key = "play" and m.searchSelect.isinFocusChain() and m.searchSelect.rowItemFocused.count() > 0
print "play was pressed from search results"
if isValid(m.searchSelect.rowItemFocused)
selectedContent = m.searchSelect.content.getChild(m.searchSelect.rowItemFocused[0])
if isValid(selectedContent)
selectedItem = selectedContent.getChild(m.searchSelect.rowItemFocused[1])
if isValid(selectedItem)
m.top.quickPlayNode = selectedItem
m.top.quickPlayNode = invalid
return true
end if
end if
end if
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()
' Detach the interface observers registered in init(). Must precede every
' reference release below: these handlers dereference them.
m.top.unobserveField("searchAlpha")
m.log.verbose("onDestroy")
destroyTextureManager(m.searchSelect.content)
' Fully release the voice route before teardown: the DynamicMiniKeyboard's
' textEditBox claims the firmware's global voice route via voiceEnabled + active.
' Both must be cleared: active=false stops input capture, voiceEnabled=false
' releases the "only one voiceEnabled at a time" slot so the returning screen's
' VoiceTextEditBox can reclaim it.
if isValid(m.searchAlphabox) and isValid(m.searchAlphabox.textEditBox)
m.searchAlphabox.textEditBox.voiceEnabled = false
m.searchAlphabox.textEditBox.active = false
end if
' Unobserve child node observers
if isValid(m.searchAlphabox) then m.searchAlphabox.unobserveField("focusedChild")
m.searchSelect.unobserveField("rowItemFocused")
m.searchSelect.unobserveField("contentReady") ' readiness ledger's `rows` fill
m.searchSelect.unobserveField("itemSelected") ' self-navigation observer
m.top.unobserveField("quickPlayNode") ' playback launcher
m.searchRetryButton.unobserveField("isReady")
m.failureNarrationTimer.unobserveField("fire")
m.failureNarrationTimer.control = "stop"
' Drop the row content, same shape and same reason as BaseGridView.onDestroy: SearchRow's
' cells are BrowseRowItem (a JRRowItem), which caches m.contentRoot and observes a field
' on it with plain observeField, so the content root holds each cell's scope. JRRowItem has
' no onDestroy and SearchRow has none either, so the release has to come from here. Below the
' unobserve block for the same reason it is there in BaseGridView: emptying the RowList can
' move rowItemFocused, and onSearchItemFocused dereferences `.content`.
m.searchSelect.content = invalid
' Stop and release task node (observer may already be cleared by loadResults())
m.searchTask = releaseTask(m.searchTask, "results")
' Clear node references
m.searchSelect = invalid
m.searchAlphabox = invalid
m.searchHelpText = invalid
m.searchFailed = invalid
m.searchFailedText = invalid
m.searchRetryButton = invalid
m.failureNarrationTimer = invalid
end sub