Skip to content

[TV] Add folder support to the Your Podcasts tab - #5681

Open
sztomek wants to merge 3 commits into
feat/tv-your-podcastsfrom
feat/tv-podcast-folders
Open

[TV] Add folder support to the Your Podcasts tab#5681
sztomek wants to merge 3 commits into
feat/tv-your-podcastsfrom
feat/tv-podcast-folders

Conversation

@sztomek

@sztomek sztomek commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Adds folder support to the TV Your Podcasts tab, mirroring the Apple TV FolderCardView / FolderDetailView. Follow-up to the podcasts grid (#5680).

  • Grid: the top-level grid now shows folders alongside loose podcasts. Folders render as a square card in the folder's colour with a 2×2 grid of the folder's top-4 podcast covers and the folder name at the bottom — the same layout tvOS's FolderCardView draws (covers top, name bottom, 12dp corners).
  • Folder detail: selecting a folder opens a detail screen — the folder name over a 6-column grid of that folder's podcasts, matching tvOS FolderDetailView. Back (or the empty-state button) returns to the grid.
  • Data: the view model reuses FolderManager.getHomeFolder() (folders + podcasts-not-in-a-folder) and re-queries it reactively whenever folders or subscriptions change, re-sorting the items A→Z with PodcastsSortType.NAME_A_TO_Z to match tvOS's titleAtoZ. Folder covers and detail podcasts come from FolderManager.findFolderPodcastsSorted().
  • Colour: the card resolves the folder colour through the Compose theme (LocalColors.current.colors.getFolderColor(...)), so it uses the TV app's Extra Dark palette — the analog of tvOS AppTheme.folderColor(colorInt:).
  • Reuse: extracted the title + focus-restoring 6-column grid into a shared TvPodcastGridScaffold, now used by both the top-level grid and the folder detail. The new TvFolderCard reuses the existing TvTile + TvArtworkImage (whose placeholder already matches tvOS's empty-cover fill).
  • Empty folder: "Your folder is empty" with an OK button that returns to the grid, matching tvOS's ContentUnavailableView.

Fixes PCDROID-697 https://linear.app/a8c/issue/PCDROID-697/folder-support.
Designs: Ftk3KwnfqaK4g57yCN63p0-fi-3246_3336.

Stacked on feat/tv-your-podcasts (#5680) — merge bottom-up.

Testing Instructions

  1. Install the TV app (./gradlew :tv:installDebugProd) and sign in with an account that has at least one folder with podcasts and some podcasts outside folders.
  2. Open the Your Podcasts tab — verify folders and loose podcasts are interleaved A→Z; each folder card shows the folder colour, up to four covers in a 2×2 grid, and the folder name.
  3. Select a folder — verify the detail screen opens with the folder name and a grid of that folder's podcasts, and the first tile is focused. Press Back — verify it returns to the grid.
  4. Open a folder that has no podcasts (create one in the mobile app) — verify "Your folder is empty" with a focused OK button that returns to the grid.
  5. Add/remove a folder or move a podcast in/out of a folder on another device — re-open the tab and verify the grid reflects the change.

Screenshots or Screencast

Screen_recording_20260729_190824.mp4

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md (TV app is unreleased — skipped)
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes (extended TvYourPodcastsViewModelTest)
  • 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. (analytics deliberately excluded — matches the rest of the TV module)

@dangermattic

dangermattic commented Jul 29, 2026

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

@sztomek
sztomek force-pushed the feat/tv-podcast-folders branch 3 times, most recently from 2c70c60 to c7aa413 Compare July 29, 2026 16:09
@sztomek sztomek added this to the 8.18 milestone Jul 29, 2026
@sztomek sztomek added [Area] TV [Type] Feature Adding a new feature. labels Jul 29, 2026
@sztomek
sztomek marked this pull request as ready for review July 29, 2026 17:09
@sztomek
sztomek requested a review from a team as a code owner July 29, 2026 17:09
@sztomek
sztomek requested review from geekygecko and removed request for a team July 29, 2026 17:09
@sztomek

sztomek commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@claude please review this

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

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


Code review

  • Read the PR diff against feat/tv-your-podcasts
  • Review new components (TvFolderCard, TvFolderDetailScreen)
  • Review screen + view model changes
  • Review tests
  • Post review

Nice change overall — the grid scaffold extraction is a good call, sorting via PodcastsSortType.NAME_A_TO_Z.folderComparator is the right analog of tvOS titleAtoZ, the folder colour goes through the theme correctly (the TV root wraps AppTheme(EXTRA_DARK) in TvOnboardingNavHost.kt:21, so LocalColors resolves to the Extra Dark palette as claimed), and the new UI is preview-covered. Comments below, roughly in priority order.


1. Folder covers never refresh — the grid is reactive, the cards aren't

FolderGridItem keys the cover load on folder.uuid only:

// TvYourPodcastsScreen.kt:181-184
var coverUrls by remember(folder.uuid) { mutableStateOf(emptyList<String>()) }
LaunchedEffect(folder.uuid, getFolderCoverUuids) {
    coverUrls = getFolderCoverUuids(folder.uuid).map(PodcastImage::getMediumArtworkUrl)
}

The view model re-runs getHomeFolder() when folders or subscriptions change, but a folder's uuid never changes, so the effect never re-runs. Move a podcast into/out of a folder (or let a sync do it) while the tab is open and the 2×2 artwork stays stale, even though the surrounding grid did update. TvFolderDetailScreen.kt:197-201 has the same one-shot shape — the detail list is a snapshot for the lifetime of the screen. Testing step 5 ("re-open the tab") quietly documents this.

2. Data loading via suspend (String) -> … params deviates from the module's own pattern

Both new lambdas (getFolderCoverUuids, getFolderPodcasts) push data access into composables. The TV module already established two conventions for exactly these two cases:

  • Detail screen → its own assisted-injected VM: TvPlaylistDetailsScreen.kt:73-76 does hiltViewModel<TvPlaylistDetailsViewModel, Factory>(key = playlistUuid, creationCallback = { it.create(playlistUuid, playlistType) }) and exposes uiState: StateFlow<…>. A TvFolderDetailViewModel(@Assisted folderUuid) would be reactive, testable, and drop the Loading/Empty/Loaded state juggling from the composable.
  • Per-card data → a flow from the VM: TvPlaylistsViewModel.kt:31-41 exposes getArtworkUuidsFlow(uuid): StateFlow<List<String>?> + refreshArtworkUuids(uuid).

Better still for the covers: enrich in TvYourPodcastsViewModel so the grid items arrive complete. Mobile already does this — PodcastsViewModel.kt:105-122 builds FolderItem.Folder(folder, podcasts) from observeFolders().flatMapLatest { … podcastManager.observePodcastsSortedByUserChoice(folder) … }. That fixes #1, removes the two lambda params (the previews currently pass { emptyList() }, so they can never show real covers), and removes the empty-placeholder pop-in on first paint.

Worth a comment either way: FolderManagerImpl.getHomeFolder() always returns FolderItem.Folder(podcasts = emptyList()) (FolderManagerImpl.kt:179), which is the non-obvious reason the extra per-folder query exists at all.

Fix this →

3. getHomeFolder() re-runs on every podcasts table write

// TvYourPodcastsViewModel.kt:28-33
combine(folderManager.observeFolders(), podcastManager.findSubscribedFlow()) { _, _ -> }
    .mapLatest { folderManager.getHomeFolder()… }

findSubscribedFlow() is a Room flow, so it re-emits the full podcast list on any write to podcasts (sync, settings, playback bookkeeping), and each emission runs getHomeFolder() (2+ queries). Consider .distinctUntilChanged() on a cheap projection of what you actually care about, e.g. map { list -> list.map { it.uuid to it.folderUuid } }.distinctUntilChanged(), before triggering the re-query.

4. Returning from a folder loses the grid's focus and scroll position

Swapping the whole subtree in TvYourPodcastsScreen.kt:74-94 tears down the grid, and nothing wraps the branches in a SaveableStateHolder, so lastFocusedIndex (rememberSaveable inside TvPodcastGridScaffold) and the internal LazyGridState are both discarded. Back from a folder lands on tile 0 at the top of the list rather than on the folder card you came from — tvOS restores it. Hoisting lastFocusedIndex + a LazyGridState above the if/else, or wrapping both branches in rememberSaveableStateHolder().SaveableStateProvider(key), would fix it. (TvPlaylistsScreen has the same shape, so this could be a shared follow-up rather than blocking here.)

5. TvPodcastGridScaffold focus effect

// TvYourPodcastsScreen.kt:211-215
if (autoFocusFirstItem) {
    LaunchedEffect(focusRequesters) { focusRequesters.firstOrNull()?.requestFocus() }
}
  • focusRequesters is re-created whenever itemKeys.size changes, so the effect re-fires and yanks focus back to tile 0 on any count change. Not reachable today (detail data is a snapshot), but it becomes a bug the moment Update issue templates #1/Updating the issue URL #2 make it reactive. LaunchedEffect(Unit) matches the rest of the module (TvUpNextScreen.kt:107, TvPlaylistDetailsScreen.kt:312).
  • Usual caveat: requestFocus() throws if the target hasn't been placed yet. The existing usages are on LazyColumns and behave; this is the first on a LazyVerticalGrid behind an AnimatedContent fade, so worth a device check with a large folder / slow load.

6. TvFolderCard nits

  • maxLines = 1 with no overflow (TvFolderCard.kt:99-108) clips long folder names mid-glyph. The shared FolderImage uses TextOverflow.Ellipsis — same here, plus softWrap = false.
  • The layout ratios at TvFolderCard.kt:74-77 and :83/:106 (0.32f, 0.024f, 0.096f, 0.064f) are unexplained magic numbers. FolderImage.kt:51-52 names its equivalents (IMAGE_SIZE_RATIO = 38f / 120f, PADDING_IMAGE_RATIO), which also documents the design spec they came from — worth copying that style. The one absolute value in the mix (padding(horizontal = 16.dp)) is inconsistent with the rest.

7. Empty state: copy + duplication

  • tv_folder_empty_message — "Edit your folder in the mobile app, they'll be waiting here when you're done." — "they" has no antecedent (the subject is "your folder") and it's a comma splice. This goes to GlotPress, so it's cheaper to tighten now: e.g. "Add podcasts to this folder in the mobile app and they'll show up here."
  • TvFolderDetailEmpty (TvFolderDetailScreen.kt:258-296) is a near-verbatim copy of TvYourPodcastsEmpty (TvYourPodcastsScreen.kt:250-287), and there are two more instances in TvPlaylistsScreen.kt:291 and TvUpNextScreen.kt:209. Since this PR is already in extract-the-shared-bits mode, a TvEmptyState(title, message, actionLabel, onAction) would pay for itself.

8. Placement

TvPodcastGridScaffold is internal and shared by two screens but lives inside TvYourPodcastsScreen.kt. Other shared TV UI lives under component/ — moving it there (or at least its own file) makes it discoverable.

9. Tests

Good that the existing cases were carried over. Gaps:

  • The headline VM behaviour — re-querying when folders change — isn't covered. observeFolders() is stubbed with flowOf(emptyList()), so the reactive path never fires more than once. A MutableSharedFlow<List<Folder>> plus a second emission with a changed homeFolder would assert it.
  • folderCoverUuids() is untested, including the take(4) cap, and folderPodcasts() is untested.
  • createViewModel() had to become suspend just to stub a suspend function; folderManager.stub { onBlocking { getHomeFolder() } doAnswer { homeFolder } } keeps it a plain function.
  • homeFolder = emptyList() in the first test is redundant — it's already the initial value.

Minor

  • OpenedFolder caches the folder name, so a rename while the detail is open shows the old title. Matches OpenedPlaylist, so consistent; just noting it.
  • TvPodcastTile(onClick = {}) in the detail grid is a no-op, same as the top-level grid — assuming podcast detail is a later PR.

Nothing here is a correctness blocker; #1 is the one I'd want resolved before merge (or explicitly deferred), and #2 is the cheapest way to get there.

CI: 6 workflows passed, 0 failed. I didn't run Gradle locally (build/lint run on Buildkite).
· branch feat/tv-podcast-folders

@sztomek
sztomek force-pushed the feat/tv-podcast-folders branch from c7aa413 to 2929681 Compare July 30, 2026 08:03
@sztomek

sztomek commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@claude review again pls

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

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


Code review (round 2)

  • Diff 2929681 against the previously reviewed state
  • Re-check each prior finding
  • Review the new/changed code fresh
  • Post review

2929681 addressed most of the last round. The grid covers are now enriched in the view model, so the 2×2 artwork is reactive — the main thing I wanted resolved. Below: status of the old points, then what's new.


Status of previous findings

# Finding Status
1 Folder covers never refresh ✅ grid fixed (enriched in VM) — detail screen still one-shot
2 Data loading via suspend (String) -> … 🟡 getFolderCoverUuids gone; getFolderPodcasts remains
3 getHomeFolder() on every podcasts write ✅ projection + distinctUntilChanged — but see A below
4 Focus/scroll lost returning from a folder ❌ not addressed
5 LaunchedEffect(focusRequesters) ✅ now LaunchedEffect(Unit)
6 TvFolderCard nits 🟡 overflow fixed, ratios named — one naming slip, 16.dp still absolute
7 Empty-state copy / duplication ✅ copy fixed · ❌ still 4 copies of the empty state
8 Scaffold placement ✅ moved to component/TvPodcastGridScaffold.kt
9 Tests ✅ reactive + enrichment cases added, createViewModel() no longer suspend

A. Folder contents are sorted by the mobile sort setting, not A→Z

This is the one I'd want a decision on. findFolderPodcastsSorted() honours folder.podcastsSortType (FolderManagerImpl.kt:199-210), which is whatever the user picked in the mobile app — Date Added, Episode Release Date, Recently Played. So:

  • Covers (TvYourPodcastsViewModel.kt:43TvYourPodcastsScreen.kt:154) show the first four in that order.
  • Detail grid (TvFolderDetailScreen.kt:57) is ordered that way too.

Meanwhile the top-level grid is deliberately forced to NAME_A_TO_Z "to match tvOS's titleAtoZ" (TvYourPodcastsViewModel.kt:47). So a folder whose mobile sort is "Date Added" renders inside a strictly A→Z grid with non-alphabetical contents. If tvOS's FolderDetailView uses titleAtoZ, the folder contents should be forced the same way for consistency; if it intentionally honours the folder's own sort, then the top-level grid arguably should too. Right now it's split.

B. The enrichment is N+1, and getHomeFolder() does work that's then thrown away

// TvYourPodcastsViewModel.kt:40-47
val items = folderManager.getHomeFolder()
    .map { item ->
        when (item) {
            is FolderItem.Folder -> item.copy(podcasts = folderManager.findFolderPodcastsSorted(item.folder.uuid))
            …

Two costs stacked here:

  1. getHomeFolder() branches on settings.podcastsSortType.value (FolderManagerImpl.kt:158-176) — if the user's mobile sort is Episode Release Date or Recently Played it runs the expensive join (findPodcastsOrderByLatestEpisode / …RecentlyPlayedEpisode), and line 47 immediately discards that ordering by re-sorting A→Z.
  2. findFolderPodcastsSorted() is 2 more queries per folder (findByUuid + the podcast query). 20 folders → ~42 queries per emission, re-run on every folder/subscription change.

The whole thing collapses if you build the items from the two flows you already collect, with no suspend queries at all:

val uiState = combine(
    folderManager.observeFolders(),
    podcastManager.findSubscribedFlow(),
) { folders, podcasts ->
    val byFolder = podcasts.groupBy(Podcast::folderUuid)
    val items = folders.map { FolderItem.Folder(it, byFolder[it.uuid].orEmpty()) } +
        byFolder[null].orEmpty().map(FolderItem::Podcast)
    …items.sortedWith(PodcastsSortType.NAME_A_TO_Z.folderComparator)
}

findSubscribedFlow() already returns all subscribed podcasts (in-folder ones included) ordered by clean_title with a leading "the " stripped (PodcastDao.kt:46-59) — i.e. exactly the A→Z order this screen wants, so folder contents come out A→Z for free and A above resolves itself. observeFolders() already filters deleted = 0. That's zero DB round-trips per emission, no N+1, and the map { it.uuid to it.folderUuid } projection + distinctUntilChanged on line 36-38 becomes unnecessary (which also fixes the fact that the projection drops titles today, so a podcast renamed by sync won't re-sort the grid). It's also closer to mobile's PodcastsViewModel.

Trade-off: you lose getHomeFolder()'s "place the folder at the position of its most recent podcast" behaviour — irrelevant here since the list is re-sorted A→Z anyway.

Fix this →

C. The detail screen can now read from the grid state

Since every FolderItem.Folder already carries its full podcast list, TvFolderDetailScreen's getFolderPodcasts lambda (TvYourPodcastsScreen.kt:74, TvFolderDetailScreen.kt:51-59) is a second query for data the parent already has. Having OpenedFolder hold the uuid and resolving the item out of uiState (or passing the FolderItem.Folder through) would delete the lambda, the Loading/Empty/Loaded state machine in the composable, the duplicate query, and the remaining staleness (rename or membership change while the detail is open). This is the cheapest version of old point #2 now that the VM does the enrichment.

D. Focus/scroll still lost on the way back from a folder (old #4)

lastFocusedKey is now key-based and rememberSaveable — good — but TvYourPodcastsScreen.kt:66-85 still swaps the whole subtree, and rememberSaveable only survives that if something retains the saved value. Nothing wraps the branches in a SaveableStateHolder, so both lastFocusedKey (TvPodcastGridScaffold.kt:46) and the LazyGridState are discarded and Back lands on the first visible tile at the top. Fine as a shared follow-up with TvPlaylistsScreen — flagging that it's still open rather than fixed.

E. coverCornerRadius uses the spacing constant

// TvFolderCard.kt:59-60
val coverSpacing = cardWidth * COVER_SPACING_RATIO
val coverCornerRadius = cardWidth * COVER_SPACING_RATIO

Same number as before the refactor, so no visual change, but naming the constant made the coincidence look intentional — the corner radius now reads as being the spacing. COVER_CORNER_RADIUS_RATIO = 0.024f alongside keeps them independently tunable. (Also still padding(horizontal = 16.dp) at :91 amid four ratios.)

Minor

  • FOLDER_COVER_COUNT = 4 lives in TvYourPodcastsScreen.kt:202 but the 2×2 layout it must match is repeat(2)/repeat(2) in TvFolderCard.kt:68-70. Doing the take inside TvFolderCard would keep them from drifting.
  • tv_folder_empty_message ("Add podcasts to this folder in the mobile app and they'll show up here.") is very close to the existing podcasts_empty_folder_summary ("Add podcasts to your folder and they'll appear here.", strings.xml:617). If the "in the mobile app" hint isn't essential, reusing the existing string saves a translation round.
  • folderPodcasts() is still untested — moot if C lands.
  • Test name folder items are enriched with their cover podcasts slightly over-promises now that the take(4) cap lives in the composable; it asserts full enrichment.

Nothing here is a correctness blocker. A is the one that needs a call (behavioural, user-visible), B collapses A/#3 and a chunk of the code at once, C and D are the leftovers from last round.

CI: 6 workflows passed, 0 failed. I didn't run Gradle locally (build/lint/tests run on Buildkite).
· branch feat/tv-podcast-folders

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

Labels

[Area] TV [Type] Feature Adding a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants