[TV] Show archived and sort options on podcast details - #5684
Conversation
Generated by 🚫 Danger |
5a85b46 to
2f266da
Compare
|
@claude review this pls |
|
Claude finished @sztomek's task in 5m 21s —— View job Review
Solid PR overall — the extraction into 1. Unused import will fail
|
6dece50 to
5e328f7
Compare
c897efe to
81930b0
Compare
5e328f7 to
90232e9
Compare
81930b0 to
ea2f98d
Compare
90232e9 to
9cbef22
Compare
|
Claude finished @sztomek's task in 4m 21s —— View job Review (re-review after
|
| # | Item | Status |
|---|---|---|
| 1 | Unused IR import in TvPlaylistDetailsScreen |
✅ removed — I re-checked every import in all changed files, none are now dead |
| 2 | Episodes query re-subscribed on every podcasts write |
✅ distinctUntilChangedBy { it.episodesSortType } before flatMapLatest, Pair plumbing gone |
| 3 | Blocking Room write on Default |
✅ @IoDispatcher injected and used in changeSortType |
| 7 | No coverage for the sort re-query | ✅ new a sort order change re-runs the episodes query test (see #E below for a tightening) |
| 4, 5, 6, 8 | plural string, AllEpisodesArchived duplication, focus transitions, prefs-file rename |
Not addressed — carried forward |
I also verified the two things the PR description asserts about persistence: PodcastDao.updateEpisodesSortTypeBlocking sets episodes_sort_order_modified + sync_status = 0, so sort really does sync; and findByPodcastOrderTitleAscFlow & friends don't filter on archived, so archivedEpisodeCount counts the full set correctly.
New findings
A. No scroll reset after a sort change — the list lands at an arbitrary offset 🟡
TvPodcastDetailsScreen.kt:255-259 uses a bare LazyColumn with no LazyListState. TvPlaylistDetailsScreen.kt:141-144 deliberately handles exactly this case:
val listState = rememberLazyListState()
LaunchedEffect(uiState.playlist.settings.sortType) {
listState.scrollToItem(0)
}Without it, and with key = { _, episode -> episode.uuid } on the items, LazyColumn re-anchors on the previously-first-visible episode's key after the reorder. Concretely: scroll 40 episodes down in Newest→Oldest, open the sort menu (focus is now on the sort button, so there's no focused item to re-anchor on either), pick Title A–Z — the viewport jumps to wherever that one episode landed alphabetically rather than the top of the newly sorted list. Same feature, sibling screen, opposite behaviour.
B. podcastFlow is collected twice → two Room subscriptions for the same row
TvPodcastDetailsViewModel.kt:63-67: podcastFlow is passed to combine and consumed by episodesFlow. It's a cold Room @Query flow, so that's two independent query executions plus two invalidation observers on the podcasts table for the same single row. Not a correctness problem and far cheaper than what #2 fixed, but a shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1) (or restructuring so episodesFlow is derived inside the combine) collapses it to one.
C. combine briefly pairs the new podcast with the old episode order
Also TvPodcastDetailsViewModel.kt:68-77: on a sort change, podcastFlow emits the updated podcast immediately, while episodesFlow has to re-subscribe and re-run the query. combine therefore emits one intermediate Loaded where the sort menu's checkmark has already moved but episodes is still in the old order. It self-corrects within a frame or two — flagging it because it's the kind of thing that looks like a bug on a slow TV device, and because with A's scrollToItem(0) keyed on episodesSortType the scroll reset would fire against the old list.
D. Selecting the already-active sort option writes to the DB and dirties sync
TvEpisodeListControls.kt:124-127 calls onSelect(option) unconditionally, so re-picking the current sort runs updateEpisodesSortTypeBlocking → episodes_sort_order_modified = now, sync_status = 0, queuing a pointless sync for a no-op. A if (option != selected) guard in TvSortButton fixes it for both call sites (the playlist screen has the same behaviour today via playlistManager.updateSortType, so this is a pre-existing wart the extraction inherits rather than something this PR introduces).
E. The new sort test doesn't pin the sort type
TvPodcastDetailsViewModelTest.kt:157 asserts verify(episodeManager, times(2)).findEpisodesByPodcastOrderedFlow(any()). That confirms a re-subscription happened but not that it was driven by the sort type — the same assertion passes if the distinctUntilChangedBy key is wrong. verify(episodeManager).findEpisodesByPodcastOrderedFlow(argThat { episodesSortType == EPISODES_SORT_BY_TITLE_ASC }) would pin the actual contract. The complementary negative case is the more valuable one and is still missing: emit a podcast that differs in some other field (e.g. latestEpisodeUuid) and assert findEpisodesByPodcastOrderedFlow is not called again — that's the regression guard for #2, and nothing currently covers it.
Carried forward from the previous round
podcast_no_episodes_all_archivedisn't a plural (TvPodcastDetailsScreen.kt:289) — renders "All 1 episodes of this podcast have been archived". The siblingtv_playlist_all_archivedis a proper plural with a dedicatedoneform. Still a copy/i18n nit rather than a regression (matches mobile), but inconsistent within the same feature.AllEpisodesArchivedis duplicated —TvPodcastDetailsScreen.kt:280-296is identical toTvPlaylistDetailsScreen.kt:182-198apart from the string. Given the PR's premise is extracting the shared controls, it belongs next to them with atext: Stringparam. Fix this →- Focus on the empty ↔ non-empty transitions still wants a device check.
LaunchedEffect(Unit) { firstEpisodeFocusRequester.requestFocus() }now lives inside theepisodes.isNotEmpty()branch. Show-archived on an all-archived podcast yanks focus from the dropdown to the first episode (probably fine). The reverse is the one to verify: focus an episode, then hide archived on an all-archived podcast — the focused node leaves composition and nothing in theAllEpisodesArchivedbranch claims focus, so the D-pad may go dead. Testing step 5 only exercises dropdown → dropdown. - The
tv_playlists→tv_preferencesrename isn't data-preserving — keys are unchanged, only the file moved, so existing installs lose their per-playlist archived flag and the orphanedtv_playlists.xmlis no longer wiped byclearAll(). A non-issue if nothing has shipped. - No
@Previewfor the newly-sharedTvArchivedFilterButton/TvSortButton, though the checklist claims preview coverage. - Package placement —
TvPreferencessits inau.com.shiftyjelly.pocketcasts.preferences, the same package as:modules:services:preferences;TvSignOutManagernow importsSettingsandTvPreferencesfrom two different modules under one package name.…tv.preferencesreads better.
Notes
EpisodesSortTypeLabel.displayLabel()is exhaustive over all sixEpisodesSortTypeentries with noelse, so a new sort type would be a compile error rather than a silent fallthrough. 👍- The hardcoded
PodcastSortOptionsordering remains justified (entrieshasDATE_ASCbeforeDATE_DESC). - No build/lint/test job has run on this PR — CI shows only Validate Gradle Wrapper ×2 and Claude Code Review. I verified imports and read the code but did not run
./gradlew spotlessCheckor thetvunit tests here, so nothing has actually compiled the branch.
Nothing here is a blocker. A is the one I'd fix before merge — it's a visible behavioural divergence from the sibling screen in the very interaction this PR adds.
· feat/tv-podcast-details-sort-archived
ea2f98d to
4eb61a2
Compare
9cbef22 to
6261d5c
Compare
|
Claude finished @sztomek's task in 2m 0s —— View job Review (re-review after force-push
|
| Item | Where | |
|---|---|---|
| B | podcastFlow is collected twice (by combine and by episodesFlow) → two Room query executions + two invalidation observers for the same row. A shareIn(viewModelScope, WhileSubscribed(), replay = 1) collapses it. |
TvPodcastDetailsViewModel.kt:63-68 |
| C | On a sort change combine emits one intermediate Loaded pairing the new podcast with the old episode order (checkmark moves before the list does). Self-corrects in a frame; interacts with A's scrollToItem(0) if that's keyed on episodesSortType. |
TvPodcastDetailsViewModel.kt:68-75 |
| D | Re-picking the already-selected sort option still calls onSelect → updateEpisodesSortTypeBlocking sets sync_status = 0 for a no-op. An if (option != selected) guard in TvSortButton fixes both call sites. (Pre-existing on the playlist side; inherited by the extraction.) |
TvEpisodeListControls.kt:124-127 |
| E | verify(episodeManager, times(2)).findEpisodesByPodcastOrderedFlow(any()) confirms a re-subscription, not that the sort type drove it — passes even with a wrong distinctUntilChangedBy key. The negative case (podcast differing only in e.g. latestEpisodeUuid must not re-subscribe) is the actual regression guard and is still missing. |
TvPodcastDetailsViewModelTest.kt:157 |
| 4 | podcast_no_episodes_all_archived isn't a plural → "All 1 episodes … archived". TvPlaylistDetailsScreen.kt:191 uses a proper pluralStringResource. |
TvPodcastDetailsScreen.kt:289 |
| 5 | AllEpisodesArchived duplicated byte-for-byte (modulo the string) across both screens — belongs in TvEpisodeListControls.kt with a text: String param, given the PR's premise. |
TvPodcastDetailsScreen.kt:280-296 / TvPlaylistDetailsScreen.kt:182-198 |
| 6 | Focus on empty ↔ non-empty transitions still wants a device check: hide-archived on an all-archived podcast removes the focused episode from composition and nothing in the AllEpisodesArchived branch claims focus. Testing step 5 only covers dropdown → dropdown. |
TvPodcastDetailsScreen.kt:247-250 |
| 8 | tv_playlists → tv_preferences isn't data-preserving (keys unchanged, only the file moved), and the orphaned tv_playlists.xml is no longer wiped by clearAll(). Non-issue if nothing shipped. |
TvPreferences.kt:13 |
| — | No @Preview for the newly-shared TvArchivedFilterButton / TvSortButton; TvPreferences shares the …pocketcasts.preferences package name with :modules:services:preferences (…tv.preferences reads better). |
CI
Still no build, lint, or test job on this PR — CI shows only Validate Gradle Wrapper ×2 and Claude Code Review. I verified imports and read the code but did not compile the branch or run the tv unit tests, so nothing has actually built it.
· feat/tv-podcast-details-sort-archived
|
Version |
4eb61a2 to
4622561
Compare
6261d5c to
01dfe9b
Compare
|
Claude finished @sztomek's task in 3m 9s —— View job Review (re-review after
|
| Item | Where | |
|---|---|---|
| B | podcastFlow is collected twice (by combine and by episodesFlow) → two Room query executions + two invalidation observers for the same row. A shareIn(viewModelScope, WhileSubscribed(), replay = 1) collapses it. |
TvPodcastDetailsViewModel.kt:63-68 |
| C | On a sort change combine emits one intermediate Loaded pairing the new podcast with the old episode order (the checkmark moves before the list does). Self-corrects in a frame; interacts with A if the scrollToItem(0) is keyed on episodesSortType. |
TvPodcastDetailsViewModel.kt:68-75 |
| D | Re-picking the already-selected sort option still calls onSelect → updateEpisodesSortTypeBlocking sets sync_status = 0 for a no-op. An if (option != selected) guard in TvSortButton fixes both call sites. (Pre-existing on the playlist side; inherited by the extraction. Note TvArchivedFilterButton does guard this — TvEpisodeListControls.kt:69,79 — so the asymmetry is within one new file.) |
TvEpisodeListControls.kt:124-127 |
| E | verify(episodeManager, times(2)).findEpisodesByPodcastOrderedFlow(any()) confirms a re-subscription, not that the sort type drove it — it passes even with a wrong distinctUntilChangedBy key. The negative case (a podcast differing only in e.g. latestEpisodeUuid must not re-subscribe) is the actual regression guard and is still missing. |
TvPodcastDetailsViewModelTest.kt:157 |
| 4 | podcast_no_episodes_all_archived isn't a plural → "All 1 episodes … archived". TvPlaylistDetailsScreen.kt:209 uses a proper pluralStringResource. |
TvPodcastDetailsScreen.kt:289 |
| 5 | AllEpisodesArchived duplicated byte-for-byte (modulo the string) across both screens — belongs in TvEpisodeListControls.kt with a text: String param, given the PR's premise. |
TvPodcastDetailsScreen.kt:280-296 / TvPlaylistDetailsScreen.kt:199-215 |
| 8 | tv_playlists → tv_preferences isn't data-preserving (keys unchanged, only the file moved), and the orphaned tv_playlists.xml is no longer wiped by clearAll(). Non-issue if nothing shipped. |
TvPreferences.kt:14 |
| — | No @Preview for the newly-shared TvArchivedFilterButton / TvSortButton; TvPreferences shares the …pocketcasts.preferences package name with :modules:services:preferences, so TvSignOutManager imports Settings and TvPreferences from two modules under one package (…tv.preferences reads better). |
CI
Still no build, lint, or test job on this PR — CI shows only Validate Gradle Wrapper ×2 plus this review job. I read the code and verified imports and the upstream DAO/manager behaviour, but did not compile the branch or run the tv unit tests, so nothing has actually built it.
Recommendation: F and A are one edit (both need a hoisted rememberLazyListState) and both are divergences from the sibling screen in the exact interaction this PR adds — I'd do that before merge. Everything else is optional polish.
· feat/tv-podcast-details-sort-archived
4622561 to
486c24c
Compare
01dfe9b to
d854324
Compare
|
Claude finished @sztomek's task in 4m 56s —— View job Review (re-review after the latest push)
This push has real changes this time (unlike the last two, which were rebases): the scroll reset, the hardened initial-focus effect, and the no-op sort guard all landed. Note the base branch also moved — Fixed
I also re-checked the A/C interaction I was worried about: on a sort change New this roundG. The scroll reset is unguarded, unlike the sibling screen's 🟢
LaunchedEffect(podcast.episodesSortType) {
listState.scrollToItem(0)
}vs var lastSortType by remember { mutableStateOf(sortType) }
LaunchedEffect(sortType) {
if (sortType != lastSortType) { lastSortType = sortType; listState.scrollToItem(0) }
}
For this screen the net effect is arguably an improvement rather than a bug: the focus effect waits on H.
|
| Item | Where | |
|---|---|---|
| B | podcastFlow is collected twice (by combine and by episodesFlow) → two Room query executions + two invalidation observers for the same row. shareIn(viewModelScope, WhileSubscribed(), replay = 1) collapses it. |
TvPodcastDetailsViewModel.kt:63-68 |
| C | combine emits one intermediate Loaded pairing the new podcast with the old episode order (checkmark moves before the list does). Self-corrects in a frame; see the note above on why it doesn't defeat A. |
TvPodcastDetailsViewModel.kt:68-75 |
| E | verify(episodeManager, times(2)).findEpisodesByPodcastOrderedFlow(any()) confirms a re-subscription, not that the sort type drove it — it passes even with a wrong distinctUntilChangedBy key. The negative case (a podcast differing only in e.g. latestEpisodeUuid must not re-subscribe) is the actual regression guard for the fix in the previous round, and is still missing. |
TvPodcastDetailsViewModelTest.kt:157 |
| 4 | podcast_no_episodes_all_archived is a %d string, not a plural → "All 1 episodes of this podcast have been archived". The sibling uses plurals/tv_playlist_all_archived. Needs a new plural, which conflicts with the "no strings added" checkbox — your call. |
TvPodcastDetailsScreen.kt:304 |
| 5 | AllEpisodesArchived still duplicated byte-for-byte (modulo the string) across both screens — belongs in TvEpisodeListControls.kt with a text: String param, given the PR's premise is exactly this kind of extraction. |
TvPodcastDetailsScreen.kt:295 / TvPlaylistDetailsScreen.kt:199 |
| 8 | tv_playlists → tv_preferences isn't data-preserving (keys unchanged, only the file moved), and the orphaned tv_playlists.xml is no longer wiped by clearAll(). Non-issue if nothing shipped. |
TvPreferences.kt:14 |
| — | No @Preview for the newly-shared TvArchivedFilterButton / TvSortButton, though the checklist claims preview coverage. TvPreferences still shares the …pocketcasts.preferences package name with :modules:services:preferences, so TvSignOutManager imports Settings and TvPreferences from two modules under one package (…tv.preferences reads better). |
Verified in passing
- No dangling references to the deleted
TvPlaylistPreferencesanywhere in the tree; the playlist VM/test andTvSignOutManager/test all moved cleanly to the renamed methods. EpisodesSortTypeLabel.ktmatchesPlaylistEpisodeSortTypeLabel.ktexactly in module, package, annotations and shape, and is exhaustive over all six entries with noelse.- No unused imports in any changed file (the
IRalias removal fromTvPlaylistDetailsScreenfrom round 1 still holds; the podcast screen'sSpacer/width/fillMaxHeight/widthIn/MaterialTheme/TextAlignare all still referenced).
CI
Still no build, lint, or test job on this PR, and ./gradlew is blocked in this review environment (the command needs approval I don't have), so I could not run spotlessCheck or the tv unit tests myself — everything above is from reading the code. Worth getting a real build on this before merge, since nothing has compiled the branch in any of the four review rounds.
Recommendation: the two things I'd asked for before merge (A and F) are done. E is the only remaining item I'd genuinely push for — the distinctUntilChangedBy from round 2 is the kind of optimisation that silently regresses, and nothing pins it. Everything else is optional polish.
· feat/tv-podcast-details-sort-archived
| } | ||
|
|
||
| fun setPodcastShowingArchived(podcastUuid: String, isShowingArchived: Boolean) { | ||
| prefs.edit().putBoolean(podcastArchivedKey(podcastUuid), isShowingArchived).apply() |
There was a problem hiding this comment.
Do we need this preference class? We have this on the podcast entity.
486c24c to
c91b72c
Compare
d854324 to
a68910b
Compare
|
Thanks @geekygecko — on the
So |
Review (re-review after push — base is now
|
|
Round-6 bot review — treatment (rebased onto Fixed — E (the one you'd push for): added Deferring, with reasons (all the optional items):
Validation: |
a68910b to
4f623b8
Compare
|
Fixed #4 (the
<plurals name="tv_podcast_all_archived">
<item quantity="one">The only episode of this podcast has been archived.</item>
<item quantity="other">All %1$d episodes of this podcast have been archived.</item>
</plurals>and Validation: |
|
Claude finished @sztomek's task in 3m 50s —— View job Review (round 6 — rebased onto
|
| # | Item | Verified |
|---|---|---|
| E | Negative regression guard for distinctUntilChangedBy |
TvPodcastDetailsViewModelTest.kt:161-180 — emits a podcast differing only in title and asserts times(1). This is the assertion that goes red if the key regresses. 👍 |
| 4 | All 1 episodes … grammar |
plurals/tv_podcast_all_archived + pluralStringResource at TvPodcastDetailsScreen.kt:309 |
Your reasoning on #4 checks out — podcast_no_episodes_all_archived is used by PodcastAdapter.kt and is present in 19 values-* locale files, so converting it in place really would have broken mobile and reset those translations. A new TV-specific plural mirroring tv_playlist_all_archived was the right call.
Also re-verified, since the design leans on them: EpisodeDao's findByPodcastOrder*Flow queries have no archived predicate and no LIMIT (EpisodeDao.kt:200-244), so archivedEpisodeCount = episodes.count(PodcastEpisode::isArchived) counts the full set; and EpisodeManagerImpl.findEpisodesByPodcastOrderedFlow dispatches purely on podcast.episodesSortType, so distinctUntilChangedBy { it.episodesSortType } is the complete key. The three-way branch is also sound — AllEpisodesArchived is only reachable when episodes.isEmpty() && archivedEpisodeCount > 0, which implies isShowingArchived == false, so it can never claim "all archived" while archived episodes are on screen.
TvPlaylistDetailsScreen's accidental duplicate LaunchedEffect that I flagged last round is gone (fixed on main), and there are no unused imports in any changed file.
New this round
Both are minor; neither is a blocker.
I. TvPreferences keys are never pruned 🟢
TvPreferences.kt:36-38 writes one key per podcast/playlist UUID (show_archived_podcast_<uuid>) and nothing ever removes them — not on unfollow, not on playlist delete. tv_preferences.xml therefore grows monotonically for the life of the install (bounded only by how many things the user has toggled, and wiped only by clearAll() on sign-out). Not worth fixing now; worth knowing before more per-UUID flags get added to this class, since that's the point where it stops being negligible.
J. The initial preference read is a synchronous disk read on the main thread 🟢
TvPodcastDetailsViewModel.kt:49:
private val isShowingArchivedFlow = MutableStateFlow(preferences.isPodcastShowingArchived(podcastUuid))This is a property initialiser, so it runs during hiltViewModel() construction — i.e. on the main thread during composition — and SharedPreferences.getBoolean blocks on the prefs-file load. A StrictMode DiskReadViolation in the worst case (first access after process start). Exactly mirrors TvPlaylistDetailsViewModel.kt:40, so it's pre-existing rather than introduced, and the file is tiny — flagging it only because the new podcast call site doubles the surface.
Asymmetry worth being aware of (not a bug)
The podcast screen focuses index 0 unconditionally, while the playlist screen restores focus to initialFocusIndex (the previously-first-visible item, TvPlaylistDetailsScreen.kt:160-166). That's internally consistent for the podcast screen — its unguarded scrollToItem(0) (previous round's G) always puts index 0 at the top anyway — so the two decisions hang together. Just noting the pair diverges as a set, in case one screen later gets "fixed" toward the other.
Already raised inline on this push
Both posted as inline comments, repeated here for the summary:
TvSortButtoninlines the icon-button colours (TvEpisodeListControls.kt:107-112) instead of callingTvButtonDefaults.iconButtonColors(), which the deletedSortDropdownButtonused. I diffed them:TvButtonDefaults.kt:44-49is byte-for-byte those four values, so there's no visual change today — but the shared design token is now duplicated and will silently diverge the next timeTvButtonDefaultschanges.changeSortType'suiState.value as? Loaded ?: returngate (TvPodcastDetailsViewModel.kt:86) buys nothing and adds a silent no-op path:PodcastManagerImpl.updateEpisodesSortTypeBlocking(line 562-563) forwards onlypodcast.uuidto a targetedUPDATE … WHERE uuid = :uuid, so the entity is unused and the already-injectedpodcastUuidsuffices. SincestateInusesWhileSubscribed(replayExpiration = ZERO), the state reverts toLoadingonce collection stops, making the earlyreturna path where a tap is dropped.
Deferrals — agreed, with one note
Your rationale on B, C, G, H and 8 all reads right to me:
- B/C — agreed they collapse together, and C is confirmed benign for the scroll reset:
LazyListState.scrollToItemroutes throughrequestPositionAndForgetLastKnownKey, which drops the remembered first-visible key, so the reordered list arriving a frame later can't re-anchor the viewport onto the old first episode. - G — agreed it's the better behaviour here specifically, because the focus effect waits on
visibleItemsInfo.any { it.index == 0 }and would never resolve if the list restored at index 40. - H — fine to mirror the sibling. For the record the flag is load-bearing:
mutableStateOf(episodes.isEmpty())is what stops "show archived" on an all-archived podcast from yanking focus off the dropdown. - 8 — non-issue pre-release, agreed.
5 (AllEpisodesArchived duplicated across both screens) is the one I'd still nudge on, and it's cheaper now than it was: both copies are structurally identical and both now take a plural, so the shared version is text: String and two call sites. Entirely optional.
Device checks still worth doing before merge
Neither is provable by reading, and neither is covered by the testing steps:
- Focus after hiding archived on an all-archived podcast. Focus an episode, then hide archived. The focused node leaves composition,
hasRequestedInitialFocusis alreadytrue, and nothing in theAllEpisodesArchivedbranch claims focus — so the first D-pad press relies on Compose's focus search rather than moving from a known anchor. Testing step 5 only covers dropdown → dropdown. - The intermediate
(new podcast, old episodes)emission on a sort change (C) — the only path where the twocombinesources are observably out of step, and slow TV hardware is where it'd show.
CI
Still no build, lint, or test job on this PR — CI shows only Validate Gradle Wrapper ×2 plus this review job. ./gradlew is blocked in this environment, so I could not run spotlessCheck or the tv tests myself; I'm taking your reported green :tv:compileDebugKotlin / :tv:testDebugUnitTest / spotlessCheck at face value, but nothing in CI has compiled this branch across six review rounds.
Recommendation: E and 4 — the two items I'd pushed for — are done, and the code reads correct. The two inline comments are small cleanups worth taking; everything else is optional.
· feat/tv-podcast-details-sort-archived
4f623b8 to
b4acc91
Compare
|
Both of these are right — fixed. Inlined icon-button colours in the extraction. Good catch —
|
|
Claude finished @sztomek's task in 3m 2s —— View job Review (round 7 — after the two inline-comment fixes)
Both fixes landed and both are correct. Diff is 527/204 across 13 files, 8 commits, Verified fixed
I also checked the thing that would have made the updated test flaky, since Also re-confirmed on this push: no dangling references to One follow-on note on the fabricated entityNot a defect, just the reason the overload you offered is the sturdier option: the method immediately above the one you're calling in the same file does use more than
override fun updateEffectsBlocking(podcast: Podcast, effects: PlaybackEffects) {
podcastDao.updateEffectsBlocking(…, podcast.uuid)
launch { updateTrimMode(podcast, effects.trimMode) } // <- whole entity
}So "pass a synthetic Nit (new, from the extraction)
Still outstanding (all optional, unchanged)
Device checks still worth doingNeither is provable by reading and neither is in the testing steps:
CIStill no build, lint, or test job on this PR (CI shows only Validate Gradle Wrapper ×2 plus this review job), and Recommendation: everything I'd pushed for is now done and the code reads correct. The remaining items are polish; the |

Description
Brings the Apple TV podcast-details controls to Android TV: the All episodes header now hosts an episode sort order menu and a show / hide archived filter, mirroring
PodcastDetailViewon tvOS.Persistence matches Apple TV exactly:
episodesSortType(same as tvOS and mobile Android) — reflects across devices.UserDefaultsflag. It does not touch the podcast's syncedshowArchived, so toggling it on the TV doesn't change the phone. The preference is wiped on sign-out.This PR also consolidates the TV local preferences: the previous per-feature
TvPlaylistPreferences/TvPodcastPreferencesare merged into oneTvPreferencesmanager (single backing store, oneclearAll()), used by both the playlist and podcast detail view models.The controls are the same components the TV playlist details screen already uses. Those were private to that screen, so this PR extracts them into shared components (
TvArchivedFilterButton, and a genericTvSortButton<T>) and points the playlist screen at them — no behaviour change there.What changed
TvEpisodeListControls.kt(new) — sharedTvArchivedFilterButton+ genericTvSortButton<T>, extracted from the playlist screen.EpisodesSortTypeLabel.kt(new,compose) —EpisodesSortType.displayLabel(), mirroring the existingPlaylistEpisodeSortTypehelper, reusing the existingepisode_sort_*strings.TvPreferences.kt(new) — unified TV-local preference manager (replacesTvPlaylistPreferences+TvPodcastPreferences); holds the per-playlist and per-podcast archived flags; cleared on sign-out viaTvSignOutManager.TvPodcastDetailsViewModel— episodes come fromcombine(podcastByUuidFlow(uuid).filterNotNull().flatMapLatest { … }, isShowingArchivedFlow), so a sort change (synced podcast) or an archived toggle (local flow) re-filters reactively.changeSortTypepersists to the podcast;toggleArchiveFilterflips the local preference.LoadedexposesarchivedEpisodeCountandisShowingArchived.TvPodcastDetailsScreen— the “All episodes” title becomes a header row with the archived + sort controls (left-focus wired to the Follow button). When every episode is archived and the filter hides them, the controls stay reachable and an “all archived” message is shown instead of a dead-end empty state.Notes / out of scope
podcast_screen_toggle_archived/podcasts_screen_sort_order_changed).Testing Instructions
Screenshots or Screencast
Checklist
./gradlew spotlessApplyto automatically apply formatting/linting)modules/services/localization/src/main/res/values/strings.xml(reused existing strings; none added)I have tested any UI changes...