Skip to content

[TV] Local folder results in search (Plus) - #5747

Draft
sztomek wants to merge 3 commits into
feat/tv-search-parityfrom
feat/tv-search-folders
Draft

[TV] Local folder results in search (Plus)#5747
sztomek wants to merge 3 commits into
feat/tv-search-parityfrom
feat/tv-search-folders

Conversation

@sztomek

@sztomek sztomek commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

Adds local folder results to Android TV Search. Stacked on the TV search parity PR (#5746).

Note — this goes beyond Apple TV. Apple TV Search has no folders at all. This mirrors the phone's behaviour instead (SearchHandler): folder search is local (never from the server) and Plus/Patron-gated.

Behaviour

  • When a Plus/Patron user searches, their local folders whose name matches the term (case-insensitive) are surfaced, each with its podcasts' artwork.
  • Non-Plus users get nothing here — the folder lookup is skipped entirely (no DB hit).
  • Folders appear as a Folders carousel in Top Results, ordered Featured → Episodes → Folders → Podcasts; tapping one opens the folder in TvFolderDetailScreen (reusing the exact overlay/nav pattern from the Your Podcasts tab, including focus-restore and podcast-opened-from-folder returning to the folder).

Implementation

  • TvSearchViewModel injects FolderManager + UserManager. searchFolders() gates on SignInState.isSignedInAsPlusOrPatron, filters folderManager.getAll() by name, attaches each folder's podcasts. It runs in a runCatching { … } async concurrently with the network search, so a (rare) local-DB failure degrades to "no folders" rather than failing the whole search, and it never delays the remote results.
  • folders added to TvSearchState.Results (+ the NoResults emptiness check). folderPodcasts(uuid) feeds the detail screen.
  • UI reuses the existing TvFolderCard and TvFolderDetailScreen components.

Reuses existing infra throughout — no shared-module changes, tv-only.

Notes

  • Device UI verification pending — the TV emulator is hard to drive precisely and isn't logged in as Plus, so the carousel + folder-open focus behaviour should be eyeballed on a real Plus device. Screenshots to follow.
  • An independent review flagged that SearchOpenedFolder/its saver duplicate the private equivalents in TvYourPodcastsScreen; deferred as a small shared-extraction cleanup (would touch the unrelated Your Podcasts screen).

Testing Instructions

  1. Sign in as Plus/Patron with at least one folder whose name matches a query.
  2. ./gradlew :tv:installDebug; open Search, type the folder's name → a Folders row appears in Top Results; open a folder → its podcasts; open a podcast → back returns to the folder.
  3. As a free/signed-out user, the Folders row never appears.
  4. ./gradlew :tv:testDebugUnitTest — covers Plus surfaces folders, non-Plus skips the lookup, folder-alone counts as a result.

Screenshots or Screencast

Pending device verification (needs a Plus account) — will attach.

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md — N/A (TV, pre-release)
  • 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 localization — reuses existing folders
  • 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 — N/A (no analytics change)

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Code review

  • Gather context (diff vs feat/tv-search-parity)
  • Review TvSearchViewModel.kt
  • Review TvSearchScreen.kt
  • Review tests
  • Post review

The shape of this is good — gating on isSignedInAsPlusOrPatron, running the folder lookup concurrently with the network search, and reusing TvFolderCard / TvFolderDetailScreen all mirror the existing patterns closely. The overlay wiring (folder overlay inactive while a podcast is open, folderRestoreTrigger in the podcast overlay's onHide) matches TvYourPodcastsScreen exactly, including the podcast-opened-from-folder return path. Two things I'd want fixed before merge, both in the "device verification pending" blind spot.


🔴 The Folders row will not lay out correctly

TvSearchScreen.kt:604TvFolderCard is placed in a TvRow with no width modifier.

A LazyRow item nested inside a LazyColumn item is measured with unbounded constraints on both axes. TvFolderCard has no intrinsic size: it derives coverSize, coverSpacing, padding(top = …) and Arrangement.spacedBy(…) from BoxWithConstraints.maxWidth, and Modifier.fillMaxWidth().aspectRatio(1f) can't anchor to anything under unbounded constraints, so maxWidth resolves to Dp.Infinity and every derived dimension degenerates. It works in TvYourPodcastsGrid only because the grid passes a sizing itemModifier.

Every other tile in a TvRow sets its own width — TvPodcastTile (RowImageWidth), TvSearchEpisodeCard (SearchEpisodeCardWidth), TvVideoTile (323.dp), TvFeaturedTile (642.dp). Details and a suggested fix are in the inline comment.

Related: TvSearchScreenPreview only renders TvSearchState.Idle, so no preview exercises the new row — which is why this wasn't caught. The checklist item about preview coverage isn't really satisfied yet.

Fix this →

🟠 getSignInState() per search can stall the results

TvSearchViewModel.kt:182userManager.getSignInState().firstOrError().await() subscribes to a cold chain on every debounced search. That chain (UserManager.kt:111-145):

  • re-triggers notificationScheduler.setupTrendingAndRecommendationsNotifications() / cancelScheduledWorksByTag(...) per subscription,
  • calls analyticsController.refreshMetadata() per emission,
  • falls into fetchSubscriptionForSignIn() — a network call, 3 attempts × 10s timeout — when settings.cachedSubscription is empty.

Since line 162 awaits foldersSearch before emitting the terminal state, the third case can withhold the entire result set (episodes included) for up to ~30s. runCatching doesn't cover it — it's a hang, not a throw. The phone deliberately avoids this by subscribing once with a seed value (SearchHandler.kt:67: getSignInState().startWith(SignInState.SignedOut)); hoisting it into a StateFlow on the ViewModel and reading .value would make the gate free.

Fix this →

Smaller notes

  • runCatching swallows cancellation (TvSearchViewModel.kt:137) — a cancelled child resolves to a "successful" emptyList(). Harmless in practice, but inconsistent with the explicit catch (CancellationException) { throw } pattern used everywhere else in this file.
  • Missing test for the graceful-degradation path — commit 49df96f's whole point (a folder lookup failure shouldn't fail the search) isn't asserted. Suggested test in the inline comment.
  • Remote failure discards matched folders — if fullSearch.await().getOrThrow() throws, the state becomes Error even for a Plus user whose local folders matched. Since folder search is purely local, those results could still be shown. Arguably out of scope, but it's a behaviour the folder feature newly makes visible.
  • Cover artwork ordering — the card uses podcastManager.findPodcastsInFolder (raw DB order) while the detail screen uses findFolderPodcastsSorted. Matches the phone, so fine for parity; just means a folder's four covers can differ between Search and Your Podcasts.
  • DuplicationSearchOpenedFolder/its saver and FOLDER_COVER_COUNT now exist twice (here and TvYourPodcastsScreen.kt:100-105, :187). Already acknowledged in the description; agreed it's a fine follow-up. Trivial nit: the saver uses ?: emptyList() where the original uses .orEmpty().

Nothing security-relevant here — the Plus gate is a UI-surfacing decision over data already on-device, and there's no new network surface.

I did not build or run the tests (no Gradle run in this environment), so the layout finding is from reading the constraint flow rather than from a screenshot — but it lines up with the note in the PR that device verification is still pending.
• branch feat/tv-search-folders

@dangermattic

Copy link
Copy Markdown
Collaborator
1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

Comment on lines +604 to +608
TvFolderCard(
folder = folderItem.folder,
coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) },
onClick = { onOpenFolder(folderItem) },
)

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 folder card has no width here, so this row will not lay out correctly.

Every other tile used inside TvRow carries an explicit width, because a LazyRow item nested in a LazyColumn item is measured with unbounded constraints on both axes:

  • TvPodcastTileimageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth) (TvSearchScreen.kt:711)
  • TvSearchEpisodeCardModifier.width(SearchEpisodeCardWidth)
  • TvVideoTile.width(323.dp), TvFeaturedTile.width(642.dp)

TvFolderCard has no intrinsic size — it derives everything from BoxWithConstraints:

BoxWithConstraints(Modifier.fillMaxWidth().aspectRatio(1f)) {
    val cardWidth = maxWidth
    val coverSize = cardWidth * COVER_SIZE_RATIO
    …
}

With unbounded constraints fillMaxWidth() is a no-op, aspectRatio(1f) finds no finite constraint to anchor to and passes the original constraints through, so maxWidth == Dp.Infinity. Every derived value (coverSize, coverSpacing, the padding(top = …), Arrangement.spacedBy(coverSpacing)) becomes Dp.Infinity and the card degenerates. In TvYourPodcastsGrid this never surfaces because the grid passes a sizing itemModifier (TvYourPodcastsScreen.kt:181).

Suggested fix — give it the same footprint as the podcast tiles in the row:

Suggested change
TvFolderCard(
folder = folderItem.folder,
coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) },
onClick = { onOpenFolder(folderItem) },
)
TvFolderCard(
folder = folderItem.folder,
coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) },
onClick = { onOpenFolder(folderItem) },
modifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth),
)

Worth adding a @Preview with a TvSearchState.Results containing folders — the existing TvSearchScreenPreview only renders Idle, so nothing currently exercises this row.

Comment on lines +181 to +188
private suspend fun searchFolders(term: String): List<FolderItem.Folder> {
if (!userManager.getSignInState().firstOrError().await().isSignedInAsPlusOrPatron) {
return emptyList()
}
return folderManager.getAll()
.filter { it.name.contains(term, ignoreCase = true) }
.map { folder -> FolderItem.Folder(folder = folder, podcasts = podcastManager.findPodcastsInFolder(folder.uuid)) }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Subscribing to getSignInState() per search can stall the whole result set, and has side effects.

UserManager.getSignInState() is a cold chain that, on every subscription (UserManager.kt:111-145):

  1. fires notificationScheduler.setupTrendingAndRecommendationsNotifications() when logged in, or cancelScheduledWorksByTag(...) when logged out — so every debounced search re-triggers WorkManager scheduling;
  2. calls analyticsController.refreshMetadata() on each emission;
  3. when settings.cachedSubscription is empty, falls into fetchSubscriptionForSignIn(), which is a network call retried SUBSCRIPTION_FETCH_MAX_ATTEMPTS = 3 × SUBSCRIPTION_FETCH_TIMEOUT_MS = 10_000.

Because the terminal state assignment awaits it (val folders = foldersSearch.await() on line 162), case 3 means the final Results — including all the remote episodes — can be withheld for up to ~30s on a cold subscription cache. runCatching doesn't help here; it's a hang, not a throw. That contradicts the "never delays the remote results" intent in the PR description.

The phone avoids this by subscribing once and seeding a value (SearchHandler.kt:67):

private val signInStateObservable = userManager.getSignInState().startWith(SignInState.SignedOut).toObservable()

Suggest mirroring that — hoist the sign-in state into a StateFlow on the ViewModel (userManager.getSignInState().asFlow().stateIn(viewModelScope, SharingStarted.Eagerly, SignInState.SignedOut)) and read .value in searchFolders, so the gate is a cheap in-memory check.

Two smaller notes on this block:

  • runCatching { searchFolders(term) } (line 137) also swallows CancellationException, turning a cancelled child into a "successful" emptyList(). Harmless today since the parent is being cancelled anyway, but it's inconsistent with the explicit catch (exception: CancellationException) { throw exception } pattern used everywhere else in this file. A catch (e: Exception) after re-throwing cancellation would match the house style.
  • The cover artwork uses podcastManager.findPodcastsInFolder (raw DB order) while the detail screen uses folderManager.findFolderPodcastsSorted. This matches the phone, so it's fine for parity, but the four covers on a card can differ from what the same folder shows in Your Podcasts.

Comment on lines +497 to +506
whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList())
whenever(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState()))
whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar")))
whenever { podcastManager.findPodcastsInFolder(any()) }.thenReturn(emptyList())

val viewModel = createViewModel()
viewModel.onQueryChange("sugar")
advanceUntilIdle()

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 headline behaviour of commit 49df96f ("degrade gracefully on failure") isn't covered. Worth one more case asserting that a folder-lookup failure doesn't take the search down:

@Test
fun `a folder lookup failure does not fail the search`() = runTest {
    whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
    whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1")))
    whenever(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState()))
    whenever { folderManager.getAll() }.thenThrow(RuntimeException("db"))

    val viewModel = createViewModel()
    viewModel.onQueryChange("sugar")
    advanceUntilIdle()

    val state = viewModel.searchState.value as TvSearchState.Results
    assertEquals(listOf("podcast-1"), state.podcasts.map { it.uuid })
    assertTrue(state.folders.isEmpty())
}

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants