Skip to content

[TV] Search screen: system on-screen keyboard + browse categories - #5717

Open
sztomek wants to merge 10 commits into
mainfrom
feat/tv-search-discover-extract
Open

[TV] Search screen: system on-screen keyboard + browse categories#5717
sztomek wants to merge 10 commits into
mainfrom
feat/tv-search-discover-extract

Conversation

@sztomek

@sztomek sztomek commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description

The Android TV search screen: an editable query field backed by Android TV's default system on-screen keyboard (the leanback IME, per Manage on-screen keyboards), plus a Browse categories row.

Design decision: we prototyped a bespoke tvOS-style on-screen keyboard, but design compared it against the platform keyboard and chose the system keyboard (autocomplete, dictation, physical-keyboard handling for free). This PR now integrates the system keyboard directly; the custom-keyboard spike (previously #5718) has been dropped, so this PR is based on main.

Search field + keyboard

  • search/TvSearchField.kt — an editable BasicTextField (search icon + placeholder + cursor) with ImeAction.Search, single line. It auto-focuses and shows the system keyboard on entry, so text comes from the leanback IME (or a connected physical keyboard).
  • search/TvSearchScreen.kt — holds the query state and drives it from the field's onValueChange; split into a stateful screen (hiltViewModel) and a preview-friendly stateless TvSearchContent. Wrapped in verticalScroll so the categories row is reachable by D-pad.
  • home/TvScaffold.kt — the TvTab.Search tab renders TvSearchScreen.

Browse categories (new, display-only)

Modeled on the Apple TV category tiles.

  • component/TvCategoryTile.kt — a TvTile-based rounded card with a centered tinted icon (category.icon, rendered as a monochrome mask — same treatment as the mobile category pills) over the category name; icon/label swap textSecondarytextPrimary on focus.
  • search/TvSearchViewModel.kt@HiltViewModel, loads the category list and exposes categories: StateFlow<List<DiscoverCategory>> (empty on failure). + unit tests.
  • A 1dp horizontal divider separates the field from a TvRow(title = "Browse categories") of TvCategoryTiles (shown only once categories load).

Note on the category source: the TV discover feed (tv/content_v3.json) does not currently carry a categories row, so the view model fetches the canonical categories endpoint directly (${SERVER_STATIC_URL}/discover/json/categories_v2.json — the same list the mobile discover feed references). When the TV feed gains a categories row this can switch to feed-driven.

Scope: display-only — category tiles have no click navigation and no analytics (both intentionally deferred). Autocomplete suggestions and search results are follow-ups (ImprovedSearchManager is already reachable from tv/).

Fixes POC-799 https://linear.app/a8c/issue/POC-799/search-skeleton-ui
Figma: Ftk3KwnfqaK4g57yCN63p0-fi-2595_2028
Conversation: p1786523963874079-slack-C0ATWH7BNH3

Testing Instructions

  1. Open the Search tab. The field auto-focuses and the system on-screen keyboard appears; below the divider, the Browse categories row loads (Arts, Business, Comedy, …).
  2. Type with the remote (D-pad) or a physical keyboard — characters land in the field, with the system keyboard's autocomplete suggestions.
  3. D-pad Down off the keyboard into the categories row; tiles focus-highlight (icon/label brighten, card lifts). Pressing a tile does nothing (display-only, by design).
  4. ./gradlew :tv:testDebugUnitTest passes.

Screenshots or Screencast

Search in focus Browse category in focus
Screenshot_20260812_133212 Screenshot_20260812_133227

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml
  • Any jetpack compose components I added or changed are covered by compose previews
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 3m 3s —— View job


Review: TV Search groundwork — extract content rows renderer

  • Read the full diff against origin/main
  • Verify the refactor is behavior-preserving
  • Check package / visibility / dependency conventions
  • Review test changes
  • Post review (3 inline comments)

Verdict: looks good to merge. This is a genuine no-op move and the renames are complete. Three non-blocking notes, one of which I'd suggest handling now because it's the exact thing the extraction is for.


Verification

I compared the removed TvHomeRows block (TvHomeScreen.kt L144–247 on origin/main) against the new component/TvContentRows.kt line by line: the two are identical modulo the three type renames and privateinternal. Row branch order (FeaturedPodcastsEpisodesPodcasts), the itemSpacing = 32.dp on the first two but not the third, the 8dp leading/trailing spacers, Arrangement.spacedBy(24.dp), item(key = row.id), the isInitialComposition guard and the runCatching around requestFocus() all carry over unchanged. Same for the three model types — including the eagerly-computed artworkUrl/thumbnailUrl/podcastArtworkUrl (960px thumbnail, 200px podcast artwork). So the "no behavior change to Home" claim holds.

Other checks:

  • grep for TvHomeRow|TvHomePodcast|TvHomeEpisode|TvHomeRows across the repo → no matches, so nothing was left dangling.
  • Imports in TvHomeScreen.kt were pruned correctly — the retained mutableIntStateOf/remember/rememberSaveable/mutableStateOf/Spacer/height/dp are all still used (L51–52 and TvHomeError), and the removed five aren't. Import ordering stays alphabetical, so spotlessCheck should be clean.
  • No new module dependencies; everything stays inside the tv app module, so internal visibility is module-wide and the component → home direction is genuinely broken. (Worth noting the "cycle" is a convention concern rather than a compiler one — Kotlin permits package cycles within a module — but the move is the right call regardless.)
  • Security / performance: nothing relevant. No new I/O, no new parsing, no data exposure; artwork URL construction is unchanged.

Inline comments

  1. TvContentRows.kt:87-88 — focus state keyed on rows.size (worth fixing here). Harmless on Home, where rows loads once. But Search replaces rows on every query, and equal-length replacements are common — lastFocusedRowIndex and the FocusRequester list then survive into unrelated content, so focus restore lands on the wrong row. TvRow already keys on items rather than items.size, so keying on rows.map { it.id } also makes the two consistent. One-liner, with a suggestion block in the inline comment. Fix this →

  2. TvContentRows.kt:80 — no @Preview for the new component. Every other file in component/ has one, and the preview checklist box is unchecked. The sample rows already exist in TvHomeContentPreview; moving that fixture down would give the renderer its own preview and let the Search work see it without Home.

  3. TvHomeViewModelTest.kt:556homeEpisode() helper name is now stale. Trivial.

Minor, no comment posted

  • TvContentRow.kt vs TvContentRows.kt differ by one character, which is easy to mis-navigate. Not worth churning the filenames over, but a one-line KDoc on TvContentRow ("presentation models rendered by TvContentRows, shared by Home and Search") would orient readers landing in either file.
  • onOpenPodcast: (String) -> Unit — naming the lambda parameter ((podcastUuid: String) -> Unit) reads better now that this is a shared component rather than a file-private helper.

What I couldn't verify

I wasn't able to run ./gradlew :tv:compileDebugKotlin, :tv:testDebugUnitTest, or spotlessCheck — the Gradle invocations were blocked by the tool permissions in this job, so my "should be clean" notes on formatting and compilation are from reading the code, not from a green build. CI will be the real check.
· branch feat/tv-search-discover-extract

Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvContentRows.kt Outdated
Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvContentRows.kt Outdated
Comment thread tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt Outdated
@sztomek sztomek changed the title TV Search groundwork: extract content rows renderer into shared component TV Search: content-rows extraction + search screen shell Aug 10, 2026
@sztomek sztomek changed the title TV Search: content-rows extraction + search screen shell TV Search: content-rows extraction + custom-keyboard search screen Aug 10, 2026
@sztomek
sztomek force-pushed the feat/tv-search-discover-extract branch from c462e11 to 16ca072 Compare August 11, 2026 12:59
@sztomek
sztomek changed the base branch from main to feat/tv-onscreen-keyboard August 11, 2026 12:59
@sztomek sztomek changed the title TV Search: content-rows extraction + custom-keyboard search screen [TV] Search screen: query field + on-screen keyboard wiring Aug 11, 2026
@sztomek
sztomek force-pushed the feat/tv-search-discover-extract branch from 16ca072 to 8abd445 Compare August 11, 2026 13:06
@sztomek sztomek changed the title [TV] Search screen: query field + on-screen keyboard wiring [TV] Search screen: on-screen keyboard + browse categories Aug 11, 2026
@sztomek
sztomek force-pushed the feat/tv-search-discover-extract branch from b4f145e to 81331ea Compare August 11, 2026 15:41
@sztomek sztomek added this to the 8.19 milestone Aug 11, 2026
@sztomek
sztomek marked this pull request as ready for review August 11, 2026 15:43
@sztomek
sztomek requested a review from a team as a code owner August 11, 2026 15:43
@sztomek
sztomek requested review from geekygecko and removed request for a team August 11, 2026 15:43
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 5m 34s —— View job


Review: TV Search screen — on-screen keyboard + browse categories

  • Read the diff against origin/feat/tv-onscreen-keyboard
  • Review the new composables (TvSearchField, TvSearchScreen, TvCategoryTile)
  • Review TvSearchViewModel + tests
  • Check conventions (Material3, strings, previews, focus handling)
  • Post review (5 inline comments)

Verdict: solid, one thing I'd want checked on a device before merge. The screen layer is clean, the stateful/stateless split is right, previews are there, strings are localized, and the TvTabPlaceholder fallback is correctly traded for an exhaustive when (adding a tab now fails compilation instead of silently rendering the placeholder — good). Nothing security-relevant: one GET against an existing static endpoint, no user data, no new permissions.


Findings

1. TvSearchScreen.kt:75autoFocus = true fights focus-driven tab selection. TvTabBar selects on focus (onFocus = { onTabSelect(index) }, L102), so merely moving focus onto the Search chip composes this screen, and one frame later the keyboard's requestFocus() yanks focus out of the top bar. Left/Right are then consumed by the keyboard's onPreviewKeyEvent, so returning to the bar needs an explicit Up. Search is last in both TvTab.entries lists so you can't traverse through it — blast radius is limited — but it contradicts what every other tab does (TvHomeScreen's isInitialComposition guard, the scaffold's autoFocusSelectedTab = !didFocusTopBar). Gating on tab activation (the existing onTabClick, or a restoreFocusTrigger-style counter) preserves the intent without the hijack. This is the one I'd verify on hardware. Fix this →

2. TvSearchField.kt:37 — caret trails one character behind. LaunchedEffect(query) { scrollTo(scrollState.maxValue) } runs before that composition's layout pass (effect coroutines drain on AndroidUiDispatcher ahead of measure/layout in the same frame), so maxValue is still the previous text's value and the scroll lands short by one glyph. Keying on scrollState.maxValue instead fixes it and makes query redundant — one-line suggestion in the inline comment. Fix this →

3. TvSearchViewModel.kt:25-36 — one-shot init load never recovers, and duplicates CategoriesManager. hiltViewModel() resolves against the activity store here (no nav graph under the Crossfade), so this view model outlives tab switches: one failed fetch — offline at launch — hides the Browse categories row for the whole process with no retry and no visible error. modules/services/repositories/.../categories/CategoriesManager.kt already wraps this exact call with a 1-day staleness check, the same log-and-keep-empty handling, and retry-when-empty; reusing it would also share the cache with mobile discover. Trade-off noted inline (its state is shaped around featured/selected semantics). Also worth putting the PR description's feed-driven caveat in the code as a TODO.

4. TvCategoryTile.kt:39-51 — focus tracking pattern. TvTile exposes interactionSource for this, and TvPlaylistCard uses collectIsFocusedAsState(). onFocusChanged works but relies on androidx.tv.material3.Card appending its focusable after the caller's modifier. Cosmetic sibling: the card scale animates while contentColor snaps.

5. TvSearchViewModelTest.kt:26 — tests are correct, two gaps. MainCoroutineRule's UnconfinedTestDispatcher means the init coroutine completes during construction, so reading .value synchronously is sound. But any() pins nothing about the endpoint — the one input that fails silently — and the CancellationException rethrow is untested.

Minor, no inline comment

  • query lives in rememberSaveable inside TvSearchScreen while the view model is activity-scoped, so switching tabs clears the query but keeps the categories. Fine for display-only, but once results land, query probably belongs in the view model — and TvSearchKeyboardState is remembered rather than saveable, so its page/selection resets on config change while query survives.
  • TvSearchField carries no semantics, so the typed query isn't announced. TvSearchKeyboard's live region announces the selected key only. An EditableText/contentDescription on the field would round out TalkBack support.
  • Both previews pass icon = "", so AsyncImage renders nothing and the previews show label-only tiles. A local drawable or placeholder would make them representative of the screenshots.
  • tv_search_browse_categories is inserted after tv_search_keyboard, one slot off alphabetical. Truly trivial.

What I couldn't verify

I didn't run ./gradlew :tv:compileDebugKotlin, :tv:testDebugUnitTest, or spotlessCheck — Gradle isn't runnable in this job — so the correctness notes above come from reading the code, not a green build. CI is the real check. I also can't confirm the on-device focus behaviour in finding 1; the reasoning is from TvTabBar.kt:102 plus the keyboard's LaunchedEffect(Unit), but a remote in hand beats my reading.
· branch feat/tv-search-discover-extract

Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt Outdated
Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt Outdated
Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt Outdated
@sztomek
sztomek force-pushed the feat/tv-search-discover-extract branch from 81331ea to b607b74 Compare August 11, 2026 18:33
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 5m 18s —— View job


Review: TV Search screen — re-review after b607b74

  • Read the diff against origin/feat/tv-onscreen-keyboard
  • Check which of the previous findings were addressed
  • Re-review the changed files end to end
  • Post review (2 new inline comments)

Verdict: one previous finding fixed, one still open that I'd want checked on hardware. The only change since my last pass is the caret fix; everything else is as reviewed. Two new (minor) findings below, plus a couple of things I chased down and cleared.


Since the last review

b607b74 is a one-line change to TvSearchField.kt:37:

LaunchedEffect(scrollState.maxValue) { scrollState.scrollTo(scrollState.maxValue) }

That closes the stale-maxValue finding correctly — the effect now relaunches after the layout that changed maxValue, so the caret lands on the end of the text instead of one glyph short. It also behaves on the way back down: deleting shrinks maxValue, relaunches, scrolls to the new end; emptying the query drops the scrollable Row entirely and the next keystroke re-keys on maxValue == 0. No edge left over.

Still open

TvSearchScreen.kt:75autoFocus = true vs focus-driven tab selection. Unchanged, and I re-confirmed the mechanism: TvTabBar.kt:102 is onFocus = { onTabSelect(index) }, so focusing the Search chip switches the tab, the Crossfade composes TvSearchScreen, and one frame later the keyboard's requestFocus() pulls focus out of the bar. Left/Right are then consumed by the keyboard's onPreviewKeyEvent, so the way back is an explicit Up. TabRow's focusRestorer() does return you to the Search chip when you press Up, so it's recoverable rather than a trap — but "focus the tab, lose the tab bar" is still not what the other tabs do. Still the one I'd verify with a remote in hand before merging.

TvSearchViewModel.kt:36 — one-shot init load. Unchanged. Worth re-reading with the UI in mind: the divider and the row are both behind categories.isNotEmpty(), so a failed fetch and a slow fetch are pixel-identical, and neither ever resolves for the life of the process. At minimum the hardcoded URL deserves the PR description's caveat as a code comment.

The other three (tile focus pattern, test gaps, and the minor list) are all non-blocking and unchanged — no need to re-litigate them here.

New this pass

1. TvCategoryTile.kt:65-69 — the label wraps on long category names. No maxLines/overflow and no horizontal padding on the Column, so Society & Culture, Religion & Spirituality, Health & Fitness lay out as two lines flush to the 280dp card edges. Nothing clips (the fixed 128dp height absorbs it), which is why it doesn't show in the screenshots — only a few names are long enough. Suggestion block inline. Fix this →

2. TvSearchScreen.kt:70-76 — physical-keyboard typing stops once focus leaves the keyboard. All the character handling is in TvSearchKeyboard's onPreviewKeyEvent, attached to the keyboard's own focusable Row; Compose dispatches key events only along the focused node's ancestor chain, and the category tiles aren't descendants of that Row. So D-pad Down into Browse categories and typing silently stops updating the query. Minor while the row is display-only — but it's the wrong shape once results sit below the keyboard and a keyboard user wants to refine while looking at them. Hoisting the handler to the screen root (delegating to the same state) is a move, not a rewrite; fine as a follow-up.

Checked and cleared

  • preferences isn't declared in tv/build.gradle.kts but TvSearchViewModel imports Settings. It compiles because repositories and servers both api-expose it, it matches seven existing tv/src/main files, and the root dependencyAnalysis block sets onUsedTransitiveDependencies to warn. Not a new buildHealth failure.
  • The exhaustive when (tab) in TvScaffold.kt:91 compiles despite TvTab.TextWithIconTab having no branch — that sealed subclass is uninhabited. TvTabPlaceholder is still referenced by TvScaffoldPreview (L214), so dropping the else doesn't leave an unused symbol behind.
  • category.icon is an absolute URL (mobile does a bare imageView.load(category.icon)), so the tinted AsyncImage resolves — no relative-path trap.
  • Category names aren't localized on mobile either (CategoriesListRowAdapter assigns category.name raw), so bypassing the feed's region substitution costs nothing today on that front. It does mean TV drifts if the feed ever region-scopes the categories source — which is the argument for the TODO.
  • Security: unchanged from the last pass. One GET against an existing static endpoint, no credentials, no user data, no new permissions.

What I couldn't verify

Gradle isn't runnable in this job, so :tv:compileDebugKotlin, :tv:testDebugUnitTest and spotlessCheck are unverified — everything above comes from reading the code. CI is the real check, and finding 1 above still wants a device.
· branch feat/tv-search-discover-extract

@sztomek
sztomek force-pushed the feat/tv-search-discover-extract branch from ae186ef to b42ccfd Compare August 12, 2026 08:56
@sztomek
sztomek changed the base branch from feat/tv-onscreen-keyboard to main August 12, 2026 08:56
@sztomek sztomek changed the title [TV] Search screen: on-screen keyboard + browse categories [TV] Search screen: system on-screen keyboard + browse categories Aug 12, 2026

@geekygecko geekygecko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interaction doesn't feel quite right. When navigating left and right through the top menu, moving to Search immediately jumps into the page and focuses the search field.

I think the user should have to press Enter on the search field before the keyboard appears. Instead, when they navigate to the Search page, pressing Down once could highlight the search field in some way, for example by changing the magnifying glass from grey to white.

From there, pressing Enter would open the keyboard, or instead pressing Down again would continue into the Discover content.

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 4m 30s —— View job


Review: TV Search screen — re-review after the resting/editing rework

  • Read the diff against origin/main
  • Re-review TvSearchField, TvSearchScreen, TvSearchViewModel, TvCategoryTile, TvScaffold
  • Reconcile earlier findings with the reshaped code
  • Post review (4 inline comments)

Verdict: the interaction rework is the right shape and resolves @geekygecko's feedback — but there's one rendering bug I'd fix before merge. The branch is now off main (no stacked base), the custom keyboard is gone, and TvSearchField has grown a two-state resting/editing model. That model is a clear improvement; it just has a gap in the resting state and misses the module's frame-guard idiom on focus restore.


The interaction feedback is addressed

@geekygecko asked for: no auto-jump into the page, Down to highlight the field, Enter to open the keyboard, Down again to continue into content. That is exactly what the new field does:

  • No auto-focus anywhere in the screen. autoFocus is gone entirely, so focusing the Search chip in the top bar no longer composes a keyboard that steals focus one frame later. This closes the long-running autoFocus finding — left/right through the tab bar now behaves like every other tab.
  • Down highlights the field — the resting Row is focusable via clickable, and contentColor flips textSecondarytextPrimary on focus, i.e. the grey→white magnifying glass.
  • Enter opens the keyboardonClick sets editing = true, which swaps in the BasicTextField and calls keyboardController.show().
  • Down again continues into content — nothing focusable sits between the field and the TvRow; the 1dp divider is a plain Box.

Also worth noting: the previous onPreviewKeyEvent hoisting finding is moot now — there's no custom key handling left to hoist, the system IME owns it.

Findings

1. TvSearchField.kt:135-144 — the typed query isn't rendered in the resting state. The resting branch passes innerTextField = null, and TvSearchFieldContent draws the placeholder only when query.isEmpty() — so a non-empty query draws nothing. Type, press Enter, and the field collapses back to a lone magnifying glass while the state quietly survives in TvSearchScreen. TvSearchFieldPreview passing query = "huberman" and rendering blank is the tell. Suggestion block inline. This is the one I'd call blocking. Fix this →

2. TvSearchField.kt:96-101 — focus restore skips withFrameNanos {}. Both requestFocus() calls target nodes composed in the same recomposition, before they're placed; LaunchedEffect bodies drain ahead of measure/layout, so the request can fail and runCatching hides it. On the restore path that leaves focus nowhere after dismissing the keyboard. TvEmptyState.kt:41-44, TvNowPlayingScreen.kt:179-184, TvTabBar.kt:65-73 and TvEpisodeListFocus.kt:96-99 all guard this — the last two by retrying across frames. Suggestion inline.

3. TvSearchField.kt:69BackHandler stays armed after focus leaves the field. Nothing clears editing on focus loss. With a physical keyboard attached (leanback IME often doesn't appear), the user can D-pad Down into Browse categories with editing still true; Back then calls stopEditing() instead of leaving the screen, and restoreRestFocus drags focus back up to the field. An onFocusChanged { if (!it.isFocused) stopEditing() } on the text field ties the state to reality. Wants a device check with a USB keyboard.

4. TvSearchField.kt:109-112 — a11y nit. The resting field is an unlabelled clickable with no role; combined with finding 1, TalkBack has nothing to announce once a query is set.

Unchanged from previous passes

  • TvSearchViewModel.kt:39 — hardcoded categories_v2.json, one-shot init load, duplicates CategoriesManager. Previously deferred to the follow-up that moves this to discoverFeedLoader.loadSearch. Now that this branch is based on main rather than stacked, that deferral is a little less airtight — a single failed fetch still hides the whole row for the life of the process, and the divider is behind the same categories.isNotEmpty() guard so a failure and a slow load look identical. Not blocking for a display-only row, but the endpoint really does deserve the PR description's caveat as a code comment; nothing in the file explains why it bypasses the feed.
  • TvSearchViewModelTest.kt — still any() (nothing pins the endpoint) and the CancellationException rethrow at L29-30 is still uncovered. The tests that exist are sound: MainCoroutineRule's UnconfinedTestDispatcher completes the init coroutine during construction, so the synchronous .value reads are safe.
  • query in rememberSaveable under a Crossfade with no SaveableStateHolder is disposed on tab switch while the activity-scoped categories survives. Fine while display-only.

Checked this pass, nothing to report

  • TvCategoryTile is unchanged from the last verified state — MutableInteractionSource + collectIsFocusedAsState() through TvTile's interactionSource (TvTile.kt:45), maxLines = 1 / TextOverflow.Ellipsis / padding(horizontal = 12.dp) on the label. Modifier.clickable's interaction source does receive focus interactions (clickable delegates to FocusableNode(interactionSource)), so the same pattern in TvSearchField is sound.
  • TvRow(title, items, contentPadding, key) matches TvRow.kt:45-53; DiscoverCategory.id is a non-null Int, so key = { it.id } is safe.
  • when (tab) in TvScaffold.kt:106-111is TvTab.Search -> replaces the else -> placeholder branch and still covers every inhabited TvTab; TvTabPlaceholder stays referenced by TvScaffoldPreview.
  • No leftovers from the dropped custom keyboard: tv/.../search/ contains only the three new files, and there is no dangling TvSearchKeyboard reference in the module.
  • The old caret/scrollTo workaround is gone — singleLine = true on BasicTextField handles horizontal scrolling internally, so that whole class of bug went away with the rewrite.
  • tv_search_browse_categories sits inside the tv_* block in strings.xml; single new string, translatable.
  • Security: unchanged. One GET against an existing static endpoint, no credentials, no user data, no new permissions. The system IME means text input is handled by the platform rather than custom code, which is the safer arrangement.

What I couldn't verify

Gradle isn't runnable in this job, so :tv:compileDebugKotlin, :tv:testDebugUnitTest and spotlessCheck are unverified — everything above is from reading the code. CI is the real check. Findings 2 and 3 both want a device (3 specifically with a physical keyboard attached).
· branch feat/tv-search-discover-extract

Comment on lines +135 to +144
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
text = stringResource(LR.string.search),
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textSecondary,
)
}
innerTextField?.invoke()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typed query disappears as soon as you leave editing mode.

In the resting branch the field is rendered with innerTextField = null (L106). This Box then contains the placeholder only when query.isEmpty(), and nothing else — so with a non-empty query it renders an empty Box. Type huberman, press Enter (ImeAction.SearchstopEditing()) or Back, and the field collapses to just the magnifying glass. The state is still there (query is hoisted in TvSearchScreen), it's simply never drawn.

The preview is the giveaway: TvSearchFieldPreview passes query = "huberman" and editing starts false, so the preview renders a blank field rather than the query it's clearly meant to show.

Suggested change
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
text = stringResource(LR.string.search),
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textSecondary,
)
}
innerTextField?.invoke()
}
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
text = stringResource(LR.string.search),
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textSecondary,
)
}
if (innerTextField != null) {
innerTextField()
} else {
Text(
text = query,
style = MaterialTheme.tvTypography.title3,
color = contentColor,
maxLines = 1,
)
}
}

That also gives contentColor a second use — right now it's computed from focus state in both branches but only ever applied to the icon, so the grey→white transition @geekygecko asked for lands on the icon alone and not on the text beside it.

Comment on lines +96 to +101
LaunchedEffect(restoreRestFocus) {
if (restoreRestFocus) {
runCatching { restFocusRequester.requestFocus() }
restoreRestFocus = false
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both requestFocus() calls here skip the withFrameNanos {} the rest of the TV module uses, and this one is the riskier of the two.

restFocusRequester is attached to a node that is composed in this very recompositionLaunchedEffect bodies are dispatched on AndroidUiDispatcher, which drains before the measure/layout pass, so the target hasn't been placed yet when requestFocus() runs. runCatching swallows the resulting failure, and because the BasicTextField that held focus was just removed from composition, focus ends up nowhere: after dismissing the keyboard the D-pad has no anchor on the screen.

Every other focus restore in tv/ guards against exactly this — TvEmptyState.kt:41-44, TvNowPlayingScreen.kt:179-184, and TvTabBar.kt:65-73 / TvEpisodeListFocus.kt:96-99 (which retry across several frames):

Suggested change
LaunchedEffect(restoreRestFocus) {
if (restoreRestFocus) {
runCatching { restFocusRequester.requestFocus() }
restoreRestFocus = false
}
}
LaunchedEffect(restoreRestFocus) {
if (restoreRestFocus) {
withFrameNanos {}
runCatching { restFocusRequester.requestFocus() }
restoreRestFocus = false
}
}

(needs androidx.compose.runtime.withFrameNanos.)

The same applies to the editing-entry effect at L65-68: fieldFocusRequester is likewise brand new, and keyboardController?.show() on the next line only does anything once the field actually holds focus — so if the request fails, the user presses Center on the field and no IME appears. Worth a frame there too.

Fix this →

runCatching { fieldFocusRequester.requestFocus() }
keyboardController?.show()
}
BackHandler(enabled = true) { stopEditing() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editing is tied to composition, not to focus, so this BackHandler can stay armed while focus is somewhere else entirely.

Nothing sets editing = false when the field loses focus. The system IME normally keeps focus inside its own window, so in the common case this doesn't bite — but with a physical keyboard attached (the leanback IME often doesn't show at all), the user can D-pad Down from the text field into the Browse categories row while editing is still true. From there:

  • Back no longer leaves the screen — it calls stopEditing(),
  • which sets restoreRestFocus = true,
  • which yanks focus back up to the search field from a row the user deliberately moved to.

Tying the two together closes it, and also handles the D-pad-away case generally:

modifier = modifier
    .focusRequester(fieldFocusRequester)
    .onFocusChanged { if (!it.isFocused) stopEditing() }

(with a guard so the initial unfocused composition doesn't immediately cancel editing before requestFocus() lands). Worth a device check with a USB keyboard plugged in, since that's the configuration where it's reachable.

Two smaller notes on the same state machine:

  • editing and restoreRestFocus are remember, not rememberSaveable, so a config change drops you back to the resting field while query (which is saveable) survives. Same asymmetry as before, just moved.
  • On the leanback IME, Back is usually consumed by the IME window to dismiss the keyboard, so this handler likely needs a second Back press to fire. That leaves an intermediate state — keyboard hidden, editing still true — which is the same state the onFocusChanged above would clean up.

Comment on lines +109 to +112
.clickable(
interactionSource = interactionSource,
indication = null,
) { editing = true },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, accessibility: the resting field is a bare clickable with no role and no label — the icon is contentDescription = null and the placeholder Text only exists while the query is empty. TalkBack announces "Search, double tap to activate" by accident when empty, and nothing at all once a query is set (see the rendering issue above). role = Role.Button plus onClickLabel = stringResource(LR.string.search) would make the affordance explicit.

Also cosmetic: the resting Row wraps its content, while the BasicTextField in the editing branch fills the available width. The focusable target therefore changes size between the two states, and since the only focus affordance is the icon tint (no background or border), the field can be easy to spot as "focused" only if you're looking at the icon. @geekygecko's suggestion was specifically the grey→white magnifying glass, so this may be exactly the intent — flagging just in case the Figma also has a container treatment.

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 2m 42s —— View job


Review: TV Search screen — re-review after b2dd4b3 + 67dcb09

  • Read the diff against origin/main
  • Check which previous findings were addressed
  • Re-review the changed files end to end
  • Post review

Verdict: this push swaps one focus-tracking mechanism for another; all four findings from the last pass are still open, including the blocking one. The rendering bug is unchanged and I re-confirmed it against the current file, so I'd still hold merge on it.


What changed since the last review

Two commits, both on TvSearchField.kt:

  • b2dd4b3 is the resting/editing rework I reviewed at 11:58 UTC (that review already covers it).
  • 67dcb09 is a 4/-5 line change: remember { MutableInteractionSource() } + collectIsFocusedAsState()var isFocused by remember { mutableStateOf(false) } + .onFocusChanged { isFocused = it.isFocused }, with clickable(interactionSource = null, …).

That change is correct and idiomatic for this module. Modifier order matters here and it's right: focusRequesteronFocusChangedclickable, so the focus-event modifier precedes the focus target clickable installs. TvSeekBar.kt:71 uses the identical .onFocusChanged { isFocused = it.isFocused } shape, and eight other files in tv/ do the same, so this is the majority pattern in the module. Imports are clean — the two foundation.interaction imports were removed, androidx.compose.ui.focus.onFocusChanged is inserted alphabetically after focusRequester, and no import is left unused.

One small note: TvCategoryTile.kt:39-40 in this same PR now uses the other pattern (MutableInteractionSource + collectIsFocusedAsState, which is what an earlier review asked for there). Both are fine; the two new files in one PR disagreeing is just a papercut for the next reader.

Still open — all four from the last pass

1. TvSearchField.kt:143 — the typed query still isn't rendered in the resting state. Unchanged, and still the blocking one.

Re-verified line by line against the current file:

  • L104 passes innerTextField = null in the resting branch.
  • TvSearchFieldContent L134-143 is Box { if (query.isEmpty()) Text(placeholder); innerTextField?.invoke() }.
  • Non-empty query + innerTextField == null → the placeholder is skipped and there's no text node, so the Box is empty and lays out at zero size.

So: type huberman, press Enter (ImeAction.SearchstopEditing()) or Back, and the field collapses to a lone magnifying glass while query sits alive in TvSearchScreen. TvSearchFieldPreview (L156) passes query = "huberman" and renders a blank field — the preview is the proof, no device needed. The suggestion block on the existing thread still applies verbatim, and it also gives contentColor its second use — right now the grey→white transition @geekygecko asked for lands on the icon only, not the text beside it. Fix this →

2. TvSearchField.kt:96 — both requestFocus() calls still skip withFrameNanos {}. L65 and L96 are unchanged. Worth restating why the restore path (L94-99) is the riskier of the two: stopEditing() removes the BasicTextField that held focus, so focus is already homeless when the effect runs; restFocusRequester's node is composed in that same recomposition and LaunchedEffect bodies drain on AndroidUiDispatcher ahead of measure/layout, so requestFocus() can throw, runCatching eats it, and the D-pad is left with no anchor on the screen. I re-grepped the module: TvEmptyState.kt:42, TvNowPlayingScreen.kt:181, TvTabBar.kt:69 and TvEpisodeListFocus.kt:98 are the only four requestFocus restores in tv/, and all four guard with withFrameNanos. This field would be the first that doesn't.

3. TvSearchField.kt:68BackHandler still stays armed after focus leaves the field. 67dcb09 added onFocusChanged to the resting branch only; the editing BasicTextField (L82) has none, so nothing clears editing on focus loss. With a physical keyboard attached (the leanback IME often doesn't appear), D-pad Down into Browse categories leaves editing == true, Back then calls stopEditing() instead of leaving the screen, and restoreRestFocus drags focus back up. Since onFocusChanged is now the pattern in this file anyway, .onFocusChanged { if (!it.isFocused) stopEditing() } on the text field is a natural fit (with a guard so the initial unfocused frame doesn't cancel editing before requestFocus() lands).

4. TvSearchField.kt:111 — a11y nit. The resting field is still an unlabelled clickable with no role; combined with finding 1, TalkBack has nothing to announce once a query is set.

Also unchanged and previously commented: TvSearchViewModel.kt:39 (hardcoded categories_v2.json, one-shot init load, no TODO explaining why it bypasses the feed) and the two test gaps (any() pins nothing about the endpoint; the CancellationException rethrow at L29-30 is uncovered).

Checked this pass, nothing to report

  • Compiles as written. clickable(interactionSource = null, indication = null) { … } matches the MutableInteractionSource? / Indication? overload with the trailing lambda binding to onClick. isFocused correctly re-initialises to false when the else-branch is composed fresh after stopEditing(), then flips true when the focus request lands.
  • TvCategoryTile, TvSearchViewModel, TvSearchViewModelTest, TvScaffold and strings.xml are byte-identical to the last verified state. is TvTab.Search -> still covers every inhabited TvTab and TvTabPlaceholder stays referenced by TvScaffoldPreview.
  • TvRow(title, items, contentPadding, key) matches TvRow.kt; DiscoverCategory.id is a non-null Int, so key = { it.id } is safe.
  • No auto-focus anywhere in the screen, so @geekygecko's tab-traversal complaint stays fixed — this push doesn't regress it.
  • Security: unchanged. One GET against an existing static endpoint, no credentials, no user data, no new permissions; the system IME means text input is handled by the platform rather than custom code.

What I couldn't verify

Gradle isn't runnable in this job, so :tv:compileDebugKotlin, :tv:testDebugUnitTest and spotlessCheck are unverified — everything above is from reading the code. CI is the real check. Findings 2 and 3 both want a device (3 specifically with a USB keyboard attached); finding 1 does not — the preview shows it.
· branch feat/tv-search-discover-extract

@sztomek

sztomek commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

thanks for your feedback @geekygecko

i took your advice and made changes:

Screen_recording_20260813_143102.mp4

please take another look when you can! 🙏

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 4m 2s —— View job


Review: TV Search screen — re-review after c5f5cf8

  • Read the diff against origin/main
  • Identify what changed in c5f5cf8
  • Re-review the changed files end to end
  • Reconcile the open findings
  • Post review (3 inline comments)

Verdict: the blocking rendering bug is fixed, and the resting field is now built out of the module's own TvTile/CardDefaults machinery — that's a nicer solution than the hand-rolled clickable it replaces. Two of the four earlier findings are still open, both non-blocking; one wants a device.


What changed

c5f5cf8 is TvSearchField.kt only (+30/-19), and it does two things:

1. The resting query now renders. TvSearchFieldContent gained an else if (query.isNotEmpty()) branch (L147-153), so leaving editing mode no longer collapses the field to a lone magnifying glass. This closes the blocking finding from the last two passes. Side benefit: TalkBack now has a text node to announce once a query is set, which was half of the a11y note.

2. The resting branch is now a TvTile rather than a bare clickable Row. remember { mutableStateOf(false) } + onFocusChanged are gone; focus colouring comes from CardDefaults.colors(contentColor = textSecondary, focusedContentColor = textPrimary) read back as LocalContentColor.current inside the card content. Checked the mechanics:

  • androidx.tv.material3.CardSurface, which resolves the state colour and provides it as LocalContentColor around content — so LocalContentColor.current at L114 is the focus-dependent value, not the ambient default. The lambda is inside that provider, so this resolves correctly.
  • scale = CardDefaults.scale(focusedScale = 1f) correctly suppresses the tile lift — a text field shouldn't grow 10% on focus.
  • shape = CardDefaults.shape(shape = RectangleShape) isn't just cosmetic: Surface clips to the shape, and the default RoundedCornerShape(12.dp) would clip a wrap-content row's edges. Right call.
  • border and glow come from TvTile's defaults (Border.None everywhere, Glow.None), and all three container colours are Transparent, so there's no visible card behind the row. Matches the screenshots.
  • The modifier/innerTextField parameter reorder in TvSearchFieldContent puts modifier first among the optionals, which is what ktlint's Compose rules want. Both call sites are named, so no positional breakage.

TvSearchScreen.kt, TvSearchViewModel.kt, TvSearchViewModelTest.kt, TvCategoryTile.kt, TvScaffold.kt and strings.xml are byte-identical to the last verified state.

Findings this pass

1. TvSearchField.kt:167 — the preview was flipped from "huberman" to "", so the branch that was just fixed has no preview coverage. The non-empty resting query is now the only state in the file nothing previews, and it's precisely the state that was broken. A second @Preview keeps both; suggestion block inline.

2. TvSearchField.kt:145-153 — the query Text hardcodes textPrimary while the Icon uses contentColor. At rest with a query set that's a grey magnifying glass beside white text, and only the icon takes part in the grey→white focus transition @geekygecko asked for. Defensible either way (the query is user data, not chrome) — just worth making the choice explicit. Same comment carries a nit: pressedContentColor is unspecified, so it defaults to contentColorFor(Color.Transparent)LocalContentColor.current. Near-white today so it looks fine, but TvButtonDefaults.kt:17-35 pins both halves everywhere else in the module.

3. TvCategoryTile.kt:39-41 — optional simplification. Now that TvSearchField shows the CardDefaults.colors + LocalContentColor route, the MutableInteractionSource + collectIsFocusedAsState in TvCategoryTile is redundant machinery for the identical result. Retracting my own earlier steer toward interactionSource here — the colours route is strictly less code, and it'd leave the two new composables in this PR using one mechanism rather than two.

Still open from previous passes

  • TvSearchField.kt:96 — both requestFocus() calls still skip withFrameNanos {}. Unchanged by this commit, and the restore path at L94-99 is the one that matters: stopEditing() removes the BasicTextField that held focus and composes the TvTile in the same recomposition, LaunchedEffect bodies drain on AndroidUiDispatcher ahead of measure/layout, so requestFocus() on the not-yet-placed card can throw, runCatching eats it, and the D-pad is left with no anchor. Re-grepped the module this pass: TvEmptyState.kt:42, TvNowPlayingScreen.kt:181, TvTabBar.kt:69 and TvEpisodeListFocus.kt:98 are the only four focus restores in tv/, and all four guard with withFrameNanos — two of them retrying across frames. This field would be the first that doesn't.
  • TvSearchField.kt:70BackHandler stays armed after focus leaves the field. Nothing clears editing on focus loss (the onFocusChanged that 67dcb09 added was on the resting branch, and c5f5cf8 removed it again). With a physical keyboard attached — where the leanback IME often doesn't appear — D-pad Down into Browse categories leaves editing == true; Back then calls stopEditing() instead of leaving the screen, and restoreRestFocus drags focus back up to the field. Wants a device check with a USB keyboard.
  • TvSearchViewModel.kt:39 — hardcoded categories_v2.json, one-shot init load, no TODO explaining why it bypasses the feed; a single failed fetch hides the divider and the row for the life of the process, so failure and slow-load look identical. Not blocking for a display-only row.
  • TvSearchViewModelTest.ktany() pins nothing about the endpoint; the CancellationException rethrow is uncovered.
  • editing/restoreRestFocus are remember, not rememberSaveable, while query is saveable — a config change drops you back to the resting field with the query intact. Harmless, arguably desirable.

Checked this pass, nothing to report

  • @geekygecko's interaction request stays satisfied: no auto-focus anywhere, Down highlights the field (icon grey→white), Enter/Center opens the IME via onClickediting = truekeyboardController.show(), Down again passes the 1dp non-focusable Box divider straight into Browse categories.
  • Imports are clean and alphabetical — clickable and focus.onFocusChanged removed, graphics.RectangleShape, tv.material3.CardDefaults, tv.material3.LocalContentColor and component.TvTile added in order. mutableStateOf/getValue/setValue are still used by editing/restoreRestFocus, so no dead imports. spotlessCheck should be clean.
  • The tv → component dependency direction is fine (TvTile is public in the same module).
  • TvRow(title, items, contentPadding, key) still matches TvRow.kt; DiscoverCategory.id is a non-null Int, so key = { it.id } is safe.
  • is TvTab.Search -> in TvScaffold.kt still covers every inhabited TvTab; TvTabPlaceholder stays referenced by TvScaffoldPreview.
  • Security: unchanged. One GET against an existing static endpoint, no credentials, no user data, no new permissions; text input is handled by the platform IME rather than custom code, which is the safer arrangement.
  • Performance: nothing new — the TvTile swap replaces a clickable node with a Surface, no extra recomposition scopes, no new allocations per frame.

What I couldn't verify

./gradlew is blocked by this job's tool permissions (I tried :tv:compileDebugKotlin and it was denied), so compilation, :tv:testDebugUnitTest and spotlessCheck are unverified — everything above is from reading the code. CI is the real check. Finding "BackHandler stays armed" specifically wants a device with a USB keyboard attached.
· branch feat/tv-search-discover-extract

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review: TV Search screen — re-review after a38b57e

  • Read the diff against origin/main
  • Identify what changed in a38b57e
  • Re-review the changed files end to end
  • Reconcile the open findings
  • Post review

View job run · branch feat/tv-search-discover-extract

Addresses review feedback that entering the Search tab jumped straight
into the field and popped the on-screen keyboard. The field now rests as
a focusable element whose magnifier brightens on focus; Enter opens the
keyboard and Down continues into the browse categories. The field is also
slightly smaller.
Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt Outdated
@sztomek
sztomek force-pushed the feat/tv-search-discover-extract branch from a38b57e to e740e43 Compare August 13, 2026 13:36
@dangermattic

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 2m 35s —— View job


Review: TV Search screen — re-review after the latest push

  • Read the changed files at the new head (e740e43)
  • Identify what changed in this push
  • Re-review the changed files end to end
  • Reconcile the open findings
  • Post review

Verdict: this push is a history rewrite, not new work — the working tree is unchanged from what I reviewed 4 minutes ago, so both findings from that pass are still open and unaddressed. No new findings.


What actually changed

The branch was squashed. The four commits I'd been reviewing incrementally (b2dd4b3 resting/editing rework → 67dcb09 onFocusChanged swap → c5f5cf8 TvTile + resting-query render → a38b57e key handler + hasFocused latch) are now a single commit:

e740e43  Give the TV search field a focusable resting state
         TvSearchField.kt  +144/-38 (net; file is 208 lines)
         TvSearchScreen.kt   -1      (drops `autoFocus = true`)

The resulting file content lines up exactly with the state described in my 13:36 passonPreviewKeyEvent at L115-129, the hasFocused latch at L82, the resting TvTile at L139-153, else if (query.isNotEmpty()) at L185-191, both requestFocus() calls at L70 and L75. TvSearchViewModel.kt, TvSearchViewModelTest.kt, TvCategoryTile.kt, TvScaffold.kt and strings.xml were not touched by this commit and match their last-verified state.

So the squash is clean and loses nothing. Worth noting only because the two inline threads I opened at 13:36 are anchored to the pre-squash SHAs; the code they point at is still there verbatim.

Still open from the last pass

1. TvSearchField.kt:70 — neither requestFocus() is guarded by withFrameNanos {}, and with the new hasFocused latch this is now load-bearing rather than cosmetic.

LaunchedEffect(editing) {
    if (editing) {
        runCatching { fieldFocusRequester.requestFocus() }   // L70 — node composed this pass
        keyboardController?.show()

fieldFocusRequester is attached to a BasicTextField composed in this recomposition; LaunchedEffect bodies drain on AndroidUiDispatcher ahead of measure/layout, so the request can hit an unplaced node and runCatching eats the failure. Before the latch that cost you a keyboard; now it also means hasFocused never flips true, so the field sits composed-but-unfocused for the whole session — no IME (keyboardController?.show() is a no-op without focus), no onPreviewKeyEvent (nothing focused to dispatch to), and Back is the only way out. The restore path at L75 has the same problem for the same reason, made worse by stopEditing having just removed the node that held focus. All four existing focus restores in tv/ guard this way — TvEmptyState.kt:42, TvNowPlayingScreen.kt:181, TvTabBar.kt:69, TvEpisodeListFocus.kt:98. Fix this →

2. TvSearchField.kt:115-129 — the key handler returns true even when moveFocus fails. moveFocus returns a Boolean that's discarded. The categories row and the divider sit behind categories.isNotEmpty() (TvSearchScreen.kt:71), so while the fetch is in flight — or permanently after it fails, given the one-shot init load — there is nothing focusable below the field and Down is swallowed rather than falling through. Returning the result is free and honest; folding the repeated KeyEventType.KeyDown check into an early return also matches TvNowPlayingScreen.kt:225.

Also still open, previously commented

  • TvSearchViewModel.kt:39 — hardcoded categories_v2.json, one-shot init load, no TODO recording why it bypasses the feed. Interacts with finding 2 above: a failed fetch leaves the field as the only focusable thing on the screen for the life of the process.
  • TvSearchField.kt:196 — only query = "" is previewed; the non-empty resting branch (L185-191) has no preview coverage, and it's the branch that was broken until two commits ago.
  • TvSearchField.kt:185 — the resting query Text hardcodes textPrimary while the Icon uses contentColor, so at rest with a query set it's a grey magnifying glass beside white text and only the icon takes part in the grey→white focus transition. Plus pressedContentColor is unspecified in the CardDefaults.colors at L143-149.
  • TvCategoryTile.kt:39-41 — optional: the MutableInteractionSource + collectIsFocusedAsState here could become the CardDefaults.colors + LocalContentColor route TvSearchField now uses, leaving one focus-colour mechanism in the PR instead of two.
  • TvSearchViewModelTest.ktany() pins nothing about the endpoint; the CancellationException rethrow at L29-30 is uncovered.

Checked this pass, nothing to report

  • @geekygecko's interaction request stays satisfied: autoFocus is gone from the codebase entirely (this commit removes the last call site), so left/right through the tab bar no longer composes a keyboard that steals focus. Down highlights the field, Enter opens the IME, Down again continues into Browse categories.
  • The hasFocused latch (L82) is correctly scoped — declared inside the if (editing) branch, so its group is disposed when editing ends and each session starts at false. Exiting on focus loss without setting restoreRestFocus (L111-113) is right: D-pad Down hands focus to a tile and the resting TvTile composes without yanking it back.
  • Column(verticalScroll) + TvRow's LazyRow is a cross-axis nesting, not an unbounded-height one — no infinite-constraint crash, and focus-driven bringIntoView scrolls the column as the user moves down.
  • TvTile's scale = CardDefaults.scale(focusedScale = 1f) and shape = CardDefaults.shape(RectangleShape) (L141-142) are both necessary, not cosmetic: the default 1.1f would grow the field on focus and the default 12dp rounding would clip a wrap-content row's edges via Surface's shape clip.
  • TvRow(title, items, contentPadding, key) matches TvRow.kt; DiscoverCategory.id is a non-null Int, so key = { it.id } is safe.
  • Imports are consistent and alphabetical; mutableStateOf/getValue/setValue all still used. spotlessCheck should be clean.
  • Security: unchanged. One GET against an existing static endpoint, no credentials, no user data, no new permissions; text input is handled by the platform IME rather than custom code.
  • Performance: nothing new — no per-frame allocations, no extra recomposition scopes beyond the two-branch swap.

What I couldn't verify

./gradlew is blocked by this job's tool permissions, so :tv:compileDebugKotlin, :tv:testDebugUnitTest and spotlessCheck are unverified — everything above is from reading the code. CI is the real check. This is also a shallow checkout with no merge base against origin/main, so I read the files at head rather than diffing; the "unchanged from the last pass" claim is based on the file contents matching the line anchors from that review, not on a byte diff against the pre-squash SHAs.
· branch feat/tv-search-discover-extract

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants