Skip to content

[TV] Add the Up Next tab - #5679

Open
sztomek wants to merge 5 commits into
feat/tv-playlist-episode-actionsfrom
feat/tv-up-next
Open

[TV] Add the Up Next tab#5679
sztomek wants to merge 5 commits into
feat/tv-playlist-episode-actionsfrom
feat/tv-up-next

Conversation

@sztomek

@sztomek sztomek commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Adds the Up Next tab to the TV app, mirroring the Apple TV implementation. The tab shows the queued episodes (the currently-playing episode is excluded, matching tvOS's allUpNextEpisodes().dropFirst()) as a vertical list, reusing the same episode row + focus-driven actions menu as the playlist details screen.

  • Reuse: extracted the row-with-actions from the playlist details screen into a shared TvEpisodeListItem (episode row + the button that slides in on focus + the actions modal), now used by both the playlist details screen and Up Next.
  • Data: observes UpNextQueue.changesObservable. Because the TV app has no PlaybackManager, the queue relay was never seeded from the DB, so TvApplication now calls upNextQueue.setupBlocking() once at startup (the mobile-startup analog). UpNextQueue is a singleton, so the tab reflects the real queue and updates reactively.
  • Server refresh: when the tab is shown it enqueues UpNextSyncWorker (the Android analog of tvOS refreshManager.syncUpNext()), so changes made on other devices show up without a restart.
  • Empty state: "Nothing queued up" with an Add episodes button that jumps to the Home tab, matching tvOS's ContentUnavailableView action.
  • Header: the row below the "Up Next" title shows the episode count and time left — we kept it to align with the Apple TV app, which renders the same summary line (the Figma omits it, but tvOS is the source of truth here).
  • Rows/header span ~75% of the screen width, per the design.

Scope / deliberate deviations (consistent with the rest of the TV module):

  • UserEpisodes (uploaded files) are filtered out — the whole TV module's episode UI is PodcastEpisode-only. tvOS shows them; rendering them here would mean generalizing the shared row. Follow-up if needed.
  • Episode tap and the actions-menu buttons are no-ops (no playback is wired anywhere on TV yet), and there's no analytics (the TV module has none) — both matching the sibling screens.

Fixes PCDROID-578 https://linear.app/a8c/issue/PCDROID-578/up-next-tab.
Designs: Ftk3KwnfqaK4g57yCN63p0-fi-2021_5935.

Stacked on feat/tv-playlist-episode-actions (#5676) — merge bottom-up.

Testing Instructions

  1. Install the TV app (./gradlew :tv:installDebugProd) and sign in with an account that has episodes in Up Next.
  2. Open the Up Next tab — verify the queued episodes appear (excluding the currently-playing one), rows reach ~75% width, and the first row is focused.
  3. Verify the header shows "Up Next" with a "N episodes · X left" summary line below it.
  4. Focus a row with the d-pad — the button slides in from the right; press RIGHT then select to open the actions modal, and back to dismiss.
  5. Empty state: with an empty Up Next queue, verify "Nothing queued up" with a focused Add episodes button; selecting it switches to the Home tab.
  6. Add or remove an episode from Up Next on another device (or the mobile app), then re-open the TV Up Next tab — the list refreshes (server sync on show).

Screenshots or Screencast

Screenshot_20260729_163000

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 (added TvUpNextViewModelTest)
  • 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 added this to the 8.18 milestone Jul 29, 2026
@sztomek
sztomek marked this pull request as ready for review July 29, 2026 14:33
@sztomek
sztomek requested a review from a team as a code owner July 29, 2026 14:33
@sztomek
sztomek requested review from geekygecko and removed request for a team July 29, 2026 14:33
@sztomek

sztomek commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@claude please review this PR

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

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


Review: [TV] Add the Up Next tab

  • Read the PR diff (origin/feat/tv-playlist-episode-actions...HEAD)
  • Review TvUpNextScreen, TvUpNextViewModel, TvEpisodeListItem, TvApplication, TvScaffold
  • Cross-check against UpNextQueue/UpNextQueueImpl, UpNextSyncWorker, TvTabBar, existing TV screens
  • Post review

Overall this is clean and well-factored — extracting TvEpisodeListItem was the right call (nice touch making both focus requesters nullable rather than forcing a dummy), the state mapping is small and tested, and both states have previews. I verified the two claims in the description: UpNextQueue.State.Loaded.queue does exclude the current episode, and the UpNextQueue binding is @Singleton (RepositoryModule.kt:163). A few things worth addressing before merge:


1. Auto-focus will likely fight the tab bar (focus trap) — worth verifying on a device

TvTabBar.kt:76 selects a tab on focus, not on click:

Tab(selected = index == selectedTabIndex, onFocus = { onTabSelect(index) }, …)

So merely moving the d-pad onto the "Up Next" tab composes TvUpNextScreen, which then unconditionally pulls focus down into the content:

  • tv/…/upnext/TvUpNextScreen.kt:107-109 → first episode row
  • tv/…/upnext/TvUpNextScreen.kt:187-189 → the "Add episodes" button

Notably, no other tab-level screen does this — TvHomeScreen and TvPlaylistsScreen never call requestFocus(); only sub-screens (TvPlaylistDetailsScreen, which is navigated into) and modals do. If this reproduces, traversing the tab row left/right gets stuck at Up Next because focus is yanked out of the row, and in the empty case the auto-focused Add episodes button means a stray SELECT bounces the user to Home. Please d-pad across the whole tab row (Home → Playlists → Up Next → …) and confirm you can keep going. If it is a problem, gate the request on the content actually owning focus (or drop the auto-focus for the tab-level screen and rely on focusRestorer, like TvTabBar does).

2. Loading is effectively unreachable, and the empty state can flash on cold start

UpNextQueueImpl.kt:55-58 seeds the relay with State.Empty at creation:

override val changesObservable by lazy {
    val relay = BehaviorRelay.create<UpNextQueue.State>().toSerialized()
    relay.accept(UpNextQueue.State.Empty)
    …
}

So the first emission the ViewModel sees is always Empty, before setupBlocking() has read the DB. Consequences: TvUpNextUiState.Loading is essentially dead code (replaced within a frame), and on a cold start where the user reaches the tab before setupBlocking() finishes, they see "Nothing queued up" (with focus grabbed by the CTA) and then a jump to the populated list — a visible flash plus a second focus grab. Since the seeding is fire-and-forget on a background dispatcher (TvApplication.kt:31-34), this is a genuine race, not just theory. Options: expose a "queue loaded" signal and keep Loading until then, or drive the initial load from the ViewModel so Loading covers it.

3. Blocking DB work on Dispatchers.Default

tv/…/TvApplication.kt:26-34setupBlocking() does blocking Room reads (upNextDao.findAllEpisodesSortedBlocking(), podcastDao.findByUuidBlocking()) plus Rx blockingFirst(). That belongs on Dispatchers.IO, not Default (TV devices are core-poor, so occupying a Default thread is more costly than on a phone). Also, the repo already has a shared application scope — @ApplicationScope CoroutineScope (modules/services/coroutines/.../CoroutinesModule.kt:17, used by PocketCastsApplication.kt:117); injecting that instead of hand-rolling one would match convention (it needs projects.modules.services.coroutines added to tv/build.gradle.kts). Fix this →

Related, worth a code comment: PlaybackManager.kt:292 also calls setupBlocking(). UpNextQueueImpl.disposables is never cleared, so if a PlaybackManager is ever constructed on TV the queue would end up with two sendToServerBlocking subscriptions. A short comment on the TV call site ("TV has no PlaybackManager; this is the only setup call") would protect the invariant.

4. Unused import — should fail spotlessCheck

tv/…/component/TvEpisodeListItem.kt:11 imports androidx.compose.foundation.layout.Arrangement, which the file never uses. ktlint's standard no-unused-imports isn't in the disabled list in the root spotless {} block, so ./gradlew spotlessCheck should flag this (I couldn't run Gradle in this sandbox to confirm, and the Buildkite result isn't visible from GitHub Actions). Fix this →

5. Product question: the head of the queue is invisible on TV

Dropping the current episode matches tvOS, but on tvOS that episode is still on screen (player / now-playing). On Android TV there's no player yet, so the top item of the user's Up Next simply doesn't appear anywhere, and the "N episodes" count is one lower than what mobile shows. With 1 queued episode the tab reads "Nothing queued up". Reasonable to defer until playback lands, but probably worth a design/product confirmation rather than an implicit deviation.

6. episodeSummaryText is now duplicated, with a couple of rough edges

TvUpNextScreen.kt:158-166 and TvPlaylistDetailsScreen.kt:418-426 are near-identical private composables that diverged (total duration vs. remaining). Given you just extracted the row into component/, this seems like a natural second extraction. Two details in the new copy:

  • TimeHelper.getTimeDurationShortString(remainingMs, context) defaults emptyString = "-", so an all-finished (or zero-duration) queue renders "- left". TimeHelper.getTimeLeft passes emptyString = "0" for exactly this case.
  • The arithmetic can use the existing helpers: episode.durationMs - episode.playedUpToMs (both Int ms on BaseEpisode) instead of ((duration - playedUpTo).coerceAtLeast(0.0) * 1000).toLong().

7. Smaller notes

  • TvScaffold.kt:50-57: the callback is named onNavigateToDiscover but selects the Home tab; onNavigateToHome would read truer. The indexOfFirst/if (homeIndex >= 0) guard could also just be TvTab.entries.indexOf(TvTab.Home) (or a selectTab(tab) overload) since the tab list is static.
  • onShown() fires on every composition of the tab. Combined with focus-based tab selection, sweeping the d-pad across the tab row can enqueue several syncs, and ExistingWorkPolicy.APPEND_OR_REPLACE chains rather than dedupes them. KEEP (or gating in the ViewModel) would be cheaper.
  • TvUpNextContent: the Loading/Empty branches get Modifier.fillMaxSize() but UpNextList(episodes = …) gets no modifier — inconsistent. Also fillMaxWidth(ROW_WIDTH_FRACTION) then padding(start = 32.dp) makes the actual content 75% − 32dp; worth double-checking against the 75% in the design.
  • Tests look good. Two easy additions: an explicit assertion that the current episode is absent from Loaded (currently only implicit), and a case for a queue that becomes empty after being loaded (the Loaded → Empty transition, which is what drives the focus/CTA path in point 2).

None of these are blockers except possibly #1 and #4 (lint gate). Nice work overall.
· branch feat/tv-up-next

@sztomek sztomek mentioned this pull request Jul 29, 2026
6 tasks
@sztomek
sztomek force-pushed the feat/tv-playlist-episode-actions branch from 2a17eb9 to 029d17f Compare August 3, 2026 11:24
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

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


Review: [TV] Add the Up Next tab (re-review after Address PR comments)

  • Read the PR diff (origin/feat/tv-playlist-episode-actions...HEAD)
  • Check which points from the previous review were addressed
  • Re-verify the open items against UpNextQueueImpl, UpNextSync, UpNextSyncWorker, PlaybackManager, TvTabBar
  • Post review (6 inline comments + summary below)

Addressed since the last pass ✅

  • TvApplication now launches setupBlocking() on Dispatchers.IO instead of Dispatchers.Default.
  • The unused Arrangement import is gone from TvEpisodeListItem.
  • episodeSummaryText now uses episode.durationMs - episode.playedUpToMs rather than the Double-seconds arithmetic.

I also re-checked the extraction diff for TvPlaylistDetailsScreen — the move to TvEpisodeListItem is faithful, and passing leftFocusRequester = playAllFocusRequester unconditionally is safe because EpisodeList only renders when episodes.isNotEmpty() (which is also when the Play All button owns that requester).

Still open

1. Focus steal on a tab-level screeninline. Confirmed TvTabBar.kt:76 selects on focus (onFocus = { onTabSelect(index) }), so d-padding onto the Up Next tab composes the screen, which unconditionally yanks focus into the list (TvUpNextScreen.kt:107-109) or onto Add episodes (:187-189). No other tab-level screen does this. Please verify d-pad traversal across the whole tab row on a device — this is the one item I'd call a potential blocker.

2. Loading is unreachable and the empty state can flash on cold startinline. UpNextQueueImpl.kt:55-58 seeds the relay with State.Empty before setupBlocking() reads the DB, and that seeding is fire-and-forget from TvApplication. Real race, and it compounds #1 (two focus grabs).

3. Routing the refresh through UpNextSyncWorker drags the playback graph onto TVinline (new finding). UpNextSync injects PlaybackManager non-lazily (UpNextSync.kt:46), so showing the tab constructs PlaybackManagerFocusManager/AudioNoisyManager/MediaSessionManager/CastManager/… — notable given TvSignOutManager deliberately uses Lazy<PlaybackManager>. Good news for my earlier concern: PlaybackManager.setup() is never called on TV, so there's no duplicate sendToServerBlocking subscription — but that invariant is now load-bearing and undocumented. Also: doWork() swallows construction failures into a log-only Result.failure(), so if any of that graph can't be built on a real TV device, "refresh on show" silently no-ops. Worth confirming UpNextSyncWorker - finished shows up in the logs.

Related and also still open: onShown() fires on every composition, and enqueue uses ExistingWorkPolicy.APPEND_OR_REPLACE, which chains rather than dedupes.

4. emptyString = "0" is unreachable — and my earlier note on it was wronginline. getTimeDurationShortString hits the secs >= 0 && mins == 0L branch for a zero total and returns "0s", so output is never empty once the sum is coerced non-negative. An all-finished queue renders "0s left", never "- left". The argument is harmless but dead; you can drop it. episodeSummaryText is also still duplicated between TvUpNextScreen.kt:171 and TvPlaylistDetailsScreen.kt:427, and the two have diverged a second way (the playlist copy special-cases the empty list).

5. onNavigateToDiscover selects Homeinline, with a suggestion. Naming + the no-op indexOfFirst/>= 0 guard.

6. TvApplication scope + invariant commentinline. Suggest injecting the existing @ApplicationScope CoroutineScope rather than hand-rolling one.

Unchanged from last time, still just for discussion

  • Head of the queue is invisible on TV. Dropping the current episode matches tvOS, but tvOS still shows it in the player; here it appears nowhere and the "N episodes" count reads one lower than mobile (a 1-item queue shows "Nothing queued up"). Reasonable to defer until playback lands, but probably worth an explicit product sign-off rather than an implicit deviation.
  • Layout nits. UpNextList(episodes = uiState.episodes) gets no modifier while the Loading/Empty branches get fillMaxSize(); and fillMaxWidth(0.75f) then padding(start = 32.dp) makes the content 75% − 32dp — worth double-checking against the design's 75%.
  • Tests. Good coverage of the mapping. Two cheap additions: assert explicitly that the current episode is absent from Loaded, and cover the Loaded → Empty transition (the path that drives the focus/CTA behaviour in Updating the issue URL #2).

I couldn't run Gradle in this sandbox, so spotlessCheck/lint/tests are unverified here — the visible GitHub Actions runs are only the Gradle-wrapper validation, and Buildkite results aren't reachable from this job.
· branch feat/tv-up-next

Comment on lines +107 to +109
LaunchedEffect(Unit) {
firstEpisodeFocusRequester.requestFocus()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Focus steal on a tab-level screen. TvTabBar.kt:76 selects a tab on focus (onFocus = { onTabSelect(index) }), so merely d-padding onto the "Up Next" tab composes this screen, which then immediately pulls focus down into the list. Same for the empty state at lines 187-189.

No other tab-level screen does this — TvHomeScreen/TvPlaylistsScreen don't request focus; only navigated-into subscreens (TvPlaylistDetailsScreen) and modals do. Please d-pad across the full tab row on a device and confirm you can traverse past Up Next. In the empty case the auto-focused Add episodes button also means a stray SELECT bounces the user to Home.

If it reproduces, either drop the auto-focus here and rely on TabRow's focusRestorer(), or gate the request on the content already owning focus.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — dropped the auto-focus in both UpNextList and UpNextEmpty, so d-padding onto the Up Next tab (which selects on focus) no longer yanks focus into the list/button. Focus now relies on the TabRow/traversal like the other tab-level screens (TvHomeScreen, TvPlaylistsScreen).

Comment on lines +41 to +45
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeout = 300.milliseconds, replayExpiration = Duration.ZERO),
TvUpNextUiState.Loading,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TvUpNextUiState.Loading is effectively dead in production, and the empty state can flash on cold start.

UpNextQueueImpl.kt:55-58 seeds the relay with State.Empty when changesObservable is first created:

val relay = BehaviorRelay.create<UpNextQueue.State>().toSerialized()
relay.accept(UpNextQueue.State.Empty)

So the first emission is always Empty, before setupBlocking() has read the DB. Since that seeding is fire-and-forget from TvApplication (applicationScope.launch { upNextQueue.setupBlocking() }), a user who reaches this tab quickly sees "Nothing queued up" and then a jump to the populated list. Combined with the auto-focus in TvUpNextScreen, that's also two focus grabs in a row.

Options: expose a "queue loaded" signal from UpNextQueue and keep Loading until it fires, or drive the initial DB read from this ViewModel so Loading actually covers it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deferring — the Empty-flash/Loading-dead issue is real (the relay seeds State.Empty before setupBlocking reads the DB), but the clean fix needs a "queue loaded" signal exposed from the shared UpNextQueue, which touches non-TV code. Tracking it as a follow-up rather than special-casing it in this ViewModel.

)

fun onShown() {
UpNextSyncWorker.enqueue(syncManager, context)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two things about routing the refresh through UpNextSyncWorker:

  1. It instantiates the whole playback graph on TV. UpNextSync injects PlaybackManager non-lazily (UpNextSync.kt:46), so showing this tab builds PlaybackManagerFocusManager, AudioNoisyManager, MediaSessionManager, CastManager, SleepTimer, … Note TvSignOutManager deliberately takes Lazy<PlaybackManager> to avoid exactly this. The good news is PlaybackManager.setup() (which calls upNextQueue.setupBlocking(), PlaybackManager.kt:302-304) is never called on TV, so there's no duplicate sendToServerBlocking subscription — but that invariant is now load-bearing and undocumented. Worth a short comment at the TvApplication call site.

    The failure mode to check: if anything in that graph can't be constructed on a real TV device, UpNextSyncWorker.doWork() swallows it (catch (e: Exception) … Result.failure(), log-only), so testing step 6 would silently do nothing. Please confirm on-device that you see UpNextSyncWorker - finished in the logs.

  2. Repeated enqueues chain rather than dedupe. onShown() fires on every composition of the tab, and because the tab bar selects on focus, sweeping the d-pad across the row can trigger several. UpNextSyncWorker.enqueue uses ExistingWorkPolicy.APPEND_OR_REPLACE, so those queue up sequentially instead of collapsing. Gating in the ViewModel (e.g. only sync once per ViewModel lifetime, or on lifecycle RESUMED) would be cheaper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed point 2 — onShown() now guards on a hasSynced flag so the sync enqueues once per ViewModel lifetime instead of on every tab composition, so sweeping the d-pad across the row no longer chains APPEND_OR_REPLACE work. For point 1, added a one-line comment at the TvApplication.setupBlocking() call site documenting that it is the only such call on TV (no PlaybackManager.setup()), so a second call would double-subscribe. On-device confirmation of UpNextSyncWorker - finished is on the device-test checklist.

Comment on lines +170 to +179
@Composable
private fun episodeSummaryText(episodes: List<PodcastEpisode>): String {
val context = LocalContext.current
val countText = pluralStringResource(LR.plurals.episode_count, episodes.size, episodes.size)
val remainingMs = episodes.sumOf { episode ->
(episode.durationMs - episode.playedUpToMs).coerceAtLeast(0).toLong()
}
val timeLeftText = stringResource(LR.string.time_left, TimeHelper.getTimeDurationShortString(remainingMs, context, emptyString = "0"))
return "$countText · $timeLeftText"
}

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 arithmetic fix reads much better now. Two follow-ups:

  • emptyString = "0" is unreachable. getTimeDurationShortString falls into secs >= 0 && mins == 0L for a zero total and returns "0s", so output is never empty once remainingMs is coerced non-negative — the empty queue renders "0s left", not "-". (My earlier note claiming "- left" was wrong.) You can drop the argument.
  • This is still a near-duplicate of TvPlaylistDetailsScreen.kt:427, and the two have now diverged in a second way: the playlist copy special-cases episodes.isEmpty() to omit the duration, this one doesn't. Given TvEpisodeListItem just moved to component/, a shared helper there taking a remaining: Boolean (or two named functions) seems like the natural next extraction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed the first point — dropped the unreachable emptyString = "0" argument. On the second (shared summary helper): agreed it is now a near-duplicate that has diverged, but extracting a shared component/ helper also touches TvPlaylistDetailsScreen and needs a decision on the remaining vs total-duration split, so tracking it as the natural follow-up now that TvEpisodeListItem lives there.

Comment on lines +50 to +57
is TvTab.UpNext -> TvUpNextScreen(
onNavigateToDiscover = {
val homeIndex = uiState.tabs.indexOfFirst { it is TvTab.Home }
if (homeIndex >= 0) {
viewModel.selectTab(homeIndex)
}
},
)

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 callback is named onNavigateToDiscover but selects the Home tab — onNavigateToHome would read truer (also in TvUpNextScreen's signature). And since TvTab.entries is static, the indexOfFirst/>= 0 guard is doing nothing useful:

Suggested change
is TvTab.UpNext -> TvUpNextScreen(
onNavigateToDiscover = {
val homeIndex = uiState.tabs.indexOfFirst { it is TvTab.Home }
if (homeIndex >= 0) {
viewModel.selectTab(homeIndex)
}
},
)
is TvTab.UpNext -> TvUpNextScreen(
onNavigateToHome = { viewModel.selectTab(TvTab.entries.indexOf(TvTab.Home)) },
)

(Or add a selectTab(tab: TvTab) overload on the ViewModel if you'd rather keep index resolution out of the composable — note this assumes uiState.tabs is always TvTab.entries, which it is today.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — renamed the callback to onNavigateToHome (screen signature + call site) and simplified the resolution to TvTab.entries.indexOf(TvTab.Home), dropping the no-op indexOfFirst/>= 0 guard.

Comment on lines +32 to +34
applicationScope.launch {
upNextQueue.setupBlocking()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for moving this to Dispatchers.IO. Two smaller notes:

  • The repo already has a shared @ApplicationScope CoroutineScope (modules/services/coroutines/.../CoroutinesModule.kt:17, used by PocketCastsApplication.kt:117); injecting it instead of hand-rolling a scope would match convention. It needs implementation(projects.modules.services.coroutines) in tv/build.gradle.kts.
  • Worth a one-line comment that this is the only setupBlocking() call on TV because there's no PlaybackManager.setup() here — UpNextQueueImpl.disposables is never cleared, so a second call would double-subscribe the debounced sendToServerBlocking pipeline. See the note on TvUpNextViewModel.onShown(): the sync worker does now construct a PlaybackManager on TV, it just never calls setup().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the one-line comment at the setupBlocking() call site (the second note). Deferring the first — injecting the shared @ApplicationScope is the right convention but pulls in projects.modules.services.coroutines as a new tv dependency, so grouping it with a small follow-up rather than adding the module edge in this PR.

@sztomek
sztomek force-pushed the feat/tv-playlist-episode-actions branch from 029d17f to eac20ce Compare August 3, 2026 11:56
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

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


Re-review: no code changes since the last pass

  • Diff the PR against origin/feat/tv-playlist-episode-actions
  • Compare against the state reviewed at 11:25 UTC
  • Report

The synchronize event fired, but the tree is unchanged: HEAD is still 22c96ec Address PR comments, the base is still eac20ce, and all 8 changed files are byte-identical to the revision I reviewed 30 minutes ago (most likely a force-push that rebased onto the same base). So there's nothing new to review — my 6 inline comments all still apply as written.

Open items, in priority order (details in the inline threads):

  1. Focus steal on a tab-level screenTvUpNextScreen.kt:107-109 and :187-189. TvTabBar.kt:76 selects on focus, so d-padding onto the tab composes this screen, which yanks focus into the list / onto Add episodes. The one item I'd still call a potential blocker; needs a d-pad traversal check on a device.
  2. Loading is unreachable and the empty state can flashTvUpNextViewModel.kt:45. UpNextQueueImpl.kt:55-58 seeds the relay with State.Empty before TvApplication's fire-and-forget setupBlocking() reads the DB.
  3. UpNextSyncWorker drags the playback graph onto TVTvUpNextViewModel.kt:48. UpNextSync injects PlaybackManager non-lazily; doWork() swallows construction failures into a log-only Result.failure(). Also onShown() fires per composition and enqueue uses APPEND_OR_REPLACE (chains rather than dedupes).
  4. Cosmetic — dead emptyString = "0" argument and the still-duplicated episodeSummaryText (thread); onNavigateToDiscover selecting Home (thread, with a suggestion); injecting the existing @ApplicationScope CoroutineScope plus the invariant comment (thread).
  5. Discussion, unchanged — the head of the queue being invisible on TV (a 1-item queue reads "Nothing queued up") probably wants explicit product sign-off; UpNextList gets no modifier while the sibling branches get fillMaxSize(); fillMaxWidth(0.75f) + padding(start = 32.dp) yields 75% − 32dp; and two cheap test additions (assert the current episode is absent from Loaded, cover the Loaded → Empty transition).

I didn't run Gradle in this sandbox, so spotlessCheck/lint/tests remain unverified from here — Buildkite isn't reachable from this job.
· branch feat/tv-up-next

@wpmobilebot wpmobilebot modified the milestones: 8.18, 8.19 Aug 3, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 8.18 has now entered code-freeze, so the milestone of this PR has been updated to 8.19.

@sztomek
sztomek force-pushed the feat/tv-playlist-episode-actions branch from eac20ce to 6700c37 Compare August 3, 2026 13:07
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

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