[TV] Add the Your Podcasts tab - #5680
Conversation
|
@claude please review this |
|
Claude finished @sztomek's task in 4m 56s —— View job Code Review
Nice, tight PR — it slots cleanly into the patterns the Playlists/Up Next tabs established, the deliberate deviations are all called out in the description, and the 🟠 1. Sorting runs on the main thread on every DB emission
val uiState: StateFlow<TvYourPodcastsUiState> = podcastManager.findSubscribedFlow()
.map { podcasts ->
val sorted = podcasts.sortedWith(PodcastsSortType.NAME_A_TO_Z.podcastComparator)
...
}
.stateIn(viewModelScope, ...)Room's flow has its own // PodcastsSortType.kt:16-18, :58
podcastComparator = compareBy { cleanStringForSortInternal(it.title) }
private fun cleanStringForSortInternal(value: String) =
value.lowercase(Locale.getDefault()).replaceFirst(replaceTheRegex, "").removeAccents()
Worth noting every other Cheapest fix is .map { podcasts ->
val sorted = podcasts
.map { it to PodcastsSortType.cleanStringForSort(it.title) }
.sortedBy { (_, key) -> key }
.map { (podcast, _) -> podcast }
if (sorted.isEmpty()) TvYourPodcastsUiState.Empty else TvYourPodcastsUiState.Loaded(sorted)
}
.flowOn(Dispatchers.Default)
.stateIn(...)🟠 2.
|
|
@claude review again pls |
|
Claude finished @sztomek's task in 3m 12s —— View job Code Review (re-run after
|
| # | Item | Status |
|---|---|---|
| 1 | Sorting on the main thread | ✅ @DefaultDispatcher injected + .flowOn(defaultDispatcher) before stateIn (TvYourPodcastsViewModel.kt:23,37). Injecting the dispatcher rather than hardcoding Dispatchers.Default also makes the test deterministic — nice. |
| 2 | FocusRequester crash on off-screen item |
✅ rememberLazyGridState() hoisted, lastFocusedUuid replaces the index, and onEnter only requests focus when the target index is in visibleItemsInfo, else falls back to the first visible item (TvYourPodcastsScreen.kt:130-146). The index >= 0 guard on indexOfFirst is there too. |
| 3 | Empty-state triplication | ⬜ Not done — still three copies (see nit A). |
| 4 | Index vs uuid identity | ✅ Covered by the item-2 fix. |
| 5 | Empty state steals focus | ⬜ Kept, consistent with UpNextEmpty. Fine as a deliberate choice. |
| 6 | String naming | ✅ tv_your_podcasts_empty_* (strings.xml:274-276). |
| 7 | imageModifier default |
✅ Now Modifier, with Modifier.width(123.dp) pushed to the call sites. |
| 8 | Heading reuses tab string | ⬜ Kept. Fine. |
| 9 | GRID_COLUMNS placement |
⬜ Still at :196, between the composables and the previews. Cosmetic. |
| 10 | Tests | ✅ Loaded → Empty transition test added (TvYourPodcastsViewModelTest.kt:60-74). No Loading preview (nit D). |
One correction to my own item 1: I suggested decorate-sort-undecorate via PodcastsSortType.cleanStringForSort — that helper is private (PodcastsSortType.kt:16), so it isn't reachable without widening its visibility. compareBy still re-runs lowercase + regex + removeAccents() on every comparison, but it's off the main thread now, which was the part that mattered. Not worth exposing internals for.
Nits (all optional)
A. The empty state is now three identical composables. TvYourPodcastsEmpty (TvYourPodcastsScreen.kt:156-194) and UpNextEmpty (TvUpNextScreen.kt:182-218) are byte-identical apart from the three string ids; TvPlaylistsEmpty (TvPlaylistsScreen.kt:269-298) is the same shape minus the focusRequester and the subtitle widthIn. component/ is the obvious home for a TvEmptyState(title, subtitle, actionLabel, onAction) — and it would incidentally settle the focus-stealing divergence in one place. Happy to see it as a separate cleanup PR.
B. Modifier.width(123.dp) is now repeated at four call sites — TvHomeScreen.kt:150, TvTabPlaceholder.kt:83,97, and the TvPodcastTile preview. Moving it out of the default was the right call, but a named constant (TvPodcastTileDefaults.RowImageWidth, or a size: Dp param) would keep the magic number in one place and make the sharp edge discoverable — with the default now plain Modifier, a caller who forgets imageModifier gets a zero-width tile rather than a wrong-but-visible one.
C. TvPlaylistsScreen.kt:189-201 still has the pattern you just fixed here (rememberSaveable(playlists) index + unguarded requestFocus()). It's latently safe there because a 3-column playlist grid doesn't scroll, but now the two siblings diverge and the next grid will get copied from whichever one the author opens first. Worth either back-porting the uuid+visibility approach or extracting it as a rememberGridFocusRestorer(...) helper in component/ — as a follow-up, not in this PR.
D. No Loading preview to go with the grid/empty ones — one more @Preview with TvYourPodcastsUiState.Loading for the checklist item.
E. TvScaffold.kt:46-51 — navigateToDiscover is allocated inside the tab-content lambda, so it's recreated for every tab including the ones that don't take it. Deduplicating the Home/UpNext copies was a good change; hoisting the val above the TvScaffoldContent( call would be marginally tidier.
What's good in the new revision
@DefaultDispatcherinjection over a hardcoded dispatcher — testable, and consistent with the repo'sDispatcherModule.- The
onEnterfix handles both failure modes (missing uuid →-1, and present-but-scrolled-out) with a sensible fallback rather than atry/catch. rememberSaveableon the uuid is no longer keyed on the list at all, so focus memory now survives sync churn and process death.- The new
Loaded → Emptytest covers the transition thecontentKey/AnimatedContentpath depends on.
Reviewed statically: ./gradlew :tv:testDebugUnitTest and spotlessCheck need approval in this environment, so I couldn't run them — CI still needs to be the source of truth there.
--- • Branch: feat/tv-your-podcasts •
7705ebe to
ff84572
Compare
Description
Adds the Your Podcasts tab to the TV app, mirroring the Apple TV
PodcastsView. This PR covers the two states in scope — the empty view and the populated podcasts grid. Folders are handled as a follow-up (stacked on this branch).LazyVGridof 6 fixed columns. Focus scales the tile and the last-focused position is restored when re-entering the grid (the same focus pattern as the Playlists tab).PodcastManager.findSubscribedFlow()and sorts withPodcastsSortType.NAME_A_TO_Z— the article-aware A→Z order tvOS uses (HomeGridDataHelper.gridItems(orderedBy: .titleAtoZ)). The list is kept fresh reactively from the DB (synced at sign-in); see the note below on why the tab doesn't force its own network refresh.ContentUnavailableViewaction (tabRouter.selectedTab = .home).TvPodcastTilewith animageModifier(defaulting to the current fixed 123.dp used by the Home rows) so the same component fills a grid cell here — this also gives each tile a proper artwork content description.Scope / deliberate deviations (consistent with the rest of the TV module):
podcastsListShownevents would need an EventHorizon schema change and would be inconsistent with the sibling tabs.Fixes PCDROID-696 https://linear.app/a8c/issue/PCDROID-696/podcasts-grid-and-empty-view.
Designs: Ftk3KwnfqaK4g57yCN63p0-fi-2052_15345.
Stacked on
feat/tv-up-next(#5679) — merge bottom-up.Testing Instructions
./gradlew :tv:installDebugProd) and sign in with an account that follows several podcasts.Screenshots or Screencast
Screen_recording_20260729_185640.mp4
Checklist
./gradlew spotlessApplyto automatically apply formatting/linting)TvYourPodcastsViewModelTest)modules/services/localization/src/main/res/values/strings.xml