' 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/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/screenReadiness.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`, 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)
' 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, e.g. 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")
end sub
sub onScreenShown()
' The screen is now showing and about to take focus — 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")
' 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")
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 genuinely 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
'if user deletes the search string hide the spinner
if query.len() = 0
stopLoadingSpinner()
end if
'if search task is running and user selectes another letter stop the search and load the next letter
m.searchTask.control = "stop"
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
' simply never paints, so it emits nothing rather than a truncated number.
'
' Deliberately inside this guard: 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
startLoadingSpinner(false)
end if
m.searchTask.observeField("results", "loadResults")
m.searchTask.query = query
m.top.overhangTitle = translate(translationKeys.ButtonSearch) + ": " + query
launchTask(m.searchTask)
end sub
sub loadResults()
' Read ONCE and used for everything below, so the delivery this handler checks is provably
' the delivery it files and displays — a second read could not disagree today (nothing else
' writes the field on this thread), but then the assertion below would be about a different
' read than the data, which is a strange thing for an invariant check to be.
results = m.searchTask.results
' A DETECTOR, not a guard — it reports the invariant breaking and then carries on.
'
' The invariant: a delivery that arrives here answers the query the box currently holds.
' If it ever does not, three things go wrong at once — the screen shows a prefix of what
' was typed, `results` closes on a clock that started at the LATER keystroke (a short
' number filed against the wrong query), and the `unobserveField` below tears down the
' observer the live query still needs.
'
' It holds because of WHERE `SearchTask` publishes: `searchMedia` runs four fetches and
' assigns `m.top.results` exactly once, as its last statement. So the `control = "stop"`
' that every keystroke issues lands before that assignment for any query still in flight,
' and a superseded query does not deliver AT ALL — the exposure is only the notification
' already queued when the keystroke arrives, which is one field-write wide.
'
' Measured on `.177`, 12 keystrokes at 550 ms against a query answering in ~570 ms (so
' every one superseded the last): 12 launches produced 6 deliveries, and all 6 answered
' the current query. The stop is what makes the race unreachable in practice — not luck,
' and not this line. So this does NOT drop the delivery: acting on a window nothing has
' entered would be a behaviour change, and dropping it would also destroy the evidence
' that it happened. If a `superseded search delivery` line ever appears, the invariant
' above broke — most likely because something moved the publish earlier or made it
' incremental — and a fix is then justified on its own evidence rather than on this
' comment. See the followup in `docs/progress.md`.
' WARN, not info: this fires only when the invariant above has broken, and it is the twin of
' `screen-load unbalanced` — the ledger's own drift alarm, which warns for the same reason.
' At info it would sit unread in a console that prints a great deal of ordinary traffic.
if isValid(results) and isValid(results.query) and results.query <> m.top.searchAlpha
m.log.warn("superseded search delivery - answered", results.query, "current", m.top.searchAlpha)
end if
' 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")
stopLoadingSpinner()
' 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 just 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")
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
' 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.
'
' Deliberately UNGUARDED against arriving late. 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()
m.searchAlphabox.setFocus(true)
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()
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
' Drop the row content, same shape and same reason as BaseGridView.onDestroy: SearchRow's
' cells are BrowseRowItem (a JRRowItem), which caches m.contentRoot and registers scoped
' observers on it, 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.unobserveField("results")
m.searchTask.control = "STOP"
m.searchTask = invalid
' Clear node references
m.searchSelect = invalid
m.searchAlphabox = invalid
m.searchHelpText = invalid
end sub