Skip to content

[TV] Add a playlist details screen to the TV app - #5667

Open
sztomek wants to merge 4 commits into
feat/tv-create-playlistfrom
feat/tv-playlist-details-screen
Open

[TV] Add a playlist details screen to the TV app#5667
sztomek wants to merge 4 commits into
feat/tv-create-playlistfrom
feat/tv-playlist-details-screen

Conversation

@sztomek

@sztomek sztomek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

Adds the playlist details screen to the TV app, mirroring the Apple TV implementation: opening a playlist from the Playlists tab shows a left info pane (artwork collage, playlist type, title, "N episodes · total duration", and a Play all episodes button) next to the scrolling episode list. The screen renders inside the app scaffold so the top tab bar stays visible, and back returns to the grid.

  • New reusable TvEpisodeRow (artwork, relative date, video indicator, 2-line title, duration/time-left with a progress bar for in-progress and a checkmark for played, archived rows dimmed) and TvArtworkImage components; the header collage reuses the shared PlaylistArtwork composable.
  • TvPlaylistDetailsViewModel observes PlaylistManager.manualPlaylistFlow/smartPlaylistFlow (assisted-inject with uuid + type, keyed per playlist, replay cache dropped when the screen closes) and resolves to a NotFound state that auto-closes the screen when the playlist is deleted while open.
  • In-tab navigation with saveable state and BackHandler; pressing LEFT from any episode row focuses Play all episodes.
  • Play all episodes and episode row clicks are intentionally no-ops — playback wiring comes in follow-up PRs, as do sorting and the archived filter (next two PRs of this stack).

Fixes PCDROID-693 https://linear.app/a8c/issue/PCDROID-693/playlist-details.
Designs: Ftk3KwnfqaK4g57yCN63p0-fi-3258_12136.

Stacked on #5658 (feat/tv-create-playlist) — merge bottom-up.

Testing Instructions

  1. Install the TV app (./gradlew :tv:installDebug) on an Android TV device or emulator, signed in with playlists synced.
  2. Open the Playlists tab and select a playlist.
  3. ✅ Verify the header shows the artwork collage, playlist type, title, and "N episodes · duration".
  4. Move through the episode list with the d-pad and check date, title, duration, and played/progress indicators.
  5. ✅ Press LEFT from an episode row — focus should jump to Play all episodes.
  6. Press back to return to the grid. Optionally delete the open playlist from a phone and verify the TV screen closes after sync.

Screenshots or Screencast

pr1-details

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 (ViewModel unit tests added)
  • 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 — follow-up)

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 2m 22s —— View job


PR Review — TV Playlist Details Screen

Nice, well-factored PR. The TvArtworkImage extraction removes duplication with TvPlaylistCard, the assisted-inject ViewModel is clean, WhileSubscribed(stopTimeout = 300ms, replayExpiration = ZERO) is a good fit for a per-screen flow, and the NotFound → onClose auto-close is handled well and covered by a test. Compose previews are present and the saveable OpenedPlaylist navigation is a tidy in-tab approach.

I left three inline comments. Summary below.

Findings

1. Episode-count inconsistency can blank the right pane (TvPlaylistDetailsScreen.kt:102)
totalEpisodeCount = playlist.episodes.size (counts PlaylistEpisode.Unavailable too), while uiState.episodes = toPodcastEpisodes(), which drops every Unavailable entry. A manual playlist whose episodes are all currently unavailable would take the non-empty branch (totalEpisodeCount > 0) yet render an empty LazyColumn — no rows, no "No episodes" message — while the header shows "0 episodes". The empty-branch decision and the summary text should be driven off the same collection so they can't disagree.
Fix this →

2. Initial focus on screen open (TvPlaylistDetailsScreen.kt:88)
Nothing calls playAllFocusRequester.requestFocus() when the details screen opens (unlike TvPlaylistsGrid's onEnter handler). Worth confirming on the emulator that focus reliably lands on Play all episodes/the first row on entry; a LaunchedEffect(Unit) { … requestFocus() } would make it deterministic if it currently relies on the host scaffold.

3. Minor — use durationMs (TvPlaylistDetailsScreen.kt:226)
BaseEpisode already exposes durationMs; episodes.sumOf { it.durationMs.toLong() } reads cleaner than the manual duration * 1000 conversion and matches TvEpisodeRow.

Nits / observations (non-blocking)

  • NotFound renders LoadingView before onClose fires — fine in practice since it's momentary, just noting the spinner briefly shows during teardown.
  • episodeSummaryText recomputes getTimeDurationShortString on every recomposition; negligible here, but could be wrapped in remember(episodes) if it ever matters.
  • Tests cover load + delete paths well. Since manual playlists are the source of the unavailable-episode edge case, a test asserting the count/empty behavior with a mix of Available/Unavailable episodes would guard finding Update issue templates #1.

No security concerns. The no-op onClicks for playback are called out in the description as intentional follow-ups.
· feat/tv-playlist-details-screen

Comment on lines +102 to +110
if (uiState.totalEpisodeCount == 0) {
NoEpisodes(modifier = Modifier.weight(1f).fillMaxHeight())
} else {
EpisodeList(
episodes = uiState.episodes,
playAllFocusRequester = playAllFocusRequester,
modifier = Modifier.weight(1f),
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Inconsistent episode counting can produce a blank right pane.

totalEpisodeCount is playlist.episodes.size (includes PlaylistEpisode.Unavailable), but uiState.episodes is playlist.episodes.toPodcastEpisodes(), which mapNotNulls away every Unavailable entry (see PlaylistEpisode.toPodcastEpisode() returning null).

For a manual playlist whose episodes are all currently unavailable (present in the playlist but no synced PodcastEpisode row):

  • totalEpisodeCount > 0 → this branch takes the else and renders EpisodeList(episodes = emptyList()) → an empty LazyColumn, so the right pane is blank (no rows, no "No episodes" message).
  • Meanwhile PlaylistInfo shows episodeSummaryText(episodes) = "0 episodes" and hides the Play all episodes button.

So the header says 0 while the branch logic thinks it's non-empty. The two sides disagree. Consider driving both the empty-branch decision and the summary text off the same collection (either consistently episodes or consistently totalEpisodeCount) so they can't diverge.

return if (episodes.isEmpty()) {
countText
} else {
val totalDurationMs = episodes.sumOf { episode -> episode.duration * 1000 }.toLong()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: BaseEpisode already exposes a durationMs computed property ((duration * 1000.0).toInt()), which TvEpisodeRow uses. For consistency you can write episodes.sumOf { it.durationMs.toLong() } instead of the manual episode.duration * 1000 conversion.

Comment on lines +88 to +112
is TvPlaylistDetailsUiState.Loaded -> {
val playAllFocusRequester = remember { FocusRequester() }
Row(
horizontalArrangement = Arrangement.spacedBy(80.dp),
modifier = Modifier
.fillMaxSize()
.padding(start = 32.dp, top = 16.dp, end = 32.dp),
) {
PlaylistInfo(
playlist = uiState.playlist,
episodes = uiState.episodes,
playAllFocusRequester = playAllFocusRequester,
modifier = Modifier.width(ArtworkSize),
)
if (uiState.totalEpisodeCount == 0) {
NoEpisodes(modifier = Modifier.weight(1f).fillMaxHeight())
} else {
EpisodeList(
episodes = uiState.episodes,
playAllFocusRequester = playAllFocusRequester,
modifier = Modifier.weight(1f),
)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a TV form factor, opening the details screen should land focus on a concrete element so the first d-pad press does something. Here nothing calls playAllFocusRequester.requestFocus() on entry (contrast with TvPlaylistsGrid, which uses focusProperties { onEnter = { ...requestFocus() } }). Have you verified on the emulator that focus reliably lands on Play all episodes (or the first row) when the screen opens? If it depends on the host scaffold, a LaunchedEffect(Unit) { playAllFocusRequester.requestFocus() } would make it deterministic.

@sztomek sztomek added this to the 8.18 milestone Jul 28, 2026
@dangermattic

dangermattic commented Jul 28, 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 marked this pull request as ready for review July 28, 2026 19:38
@sztomek
sztomek requested a review from a team as a code owner July 28, 2026 19:38
@sztomek
sztomek requested review from geekygecko and removed request for a team July 28, 2026 19:38
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

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


PR Review — TV Playlist Details Screen

Reviewed against origin/feat/tv-create-playlist. Overall this is clean, well-factored work: the TvArtworkImage extraction removes the duplication with TvPlaylistCard, the assisted-inject ViewModel with key = playlistUuid is tidy, WhileSubscribed(stopTimeout = 300ms, replayExpiration = ZERO) fits a per-screen flow well, and the NotFound → onClose auto-close is handled and tested. Compose previews and the saveable OpenedPlaylist navigation are nice touches.

Findings below — one is worth fixing before merge; the rest are minor.

1. Episode-count inconsistency can render a blank right pane — TvPlaylistDetailsScreen.kt:102

I confirmed this against the model layer: PlaylistEpisode.Unavailable.toPodcastEpisode() returns null (PlaylistEpisode.kt:26), so uiState.episodes = playlist.episodes.toPodcastEpisodes() drops every Unavailable entry, while totalEpisodeCount = playlist.episodes.size (TvPlaylistDetailsViewModel.kt:66) counts them.

For a manual playlist whose episodes are all currently unavailable:

  • uiState.totalEpisodeCount > 0 → takes the else branch → EpisodeList(episodes = emptyList()) → an empty LazyColumn, so the right pane is blank (no rows, no "No episodes" message).
  • Meanwhile PlaylistInfo shows "0 episodes" and hides the Play all episodes button.

The header and the branch logic disagree. Drive both the empty-branch decision and the summary text off the same collection so they can't diverge — simplest is to gate on uiState.episodes.isEmpty() (consistent with what the list and summary already use). Worth a ViewModel test with a mix of Available/Unavailable episodes to lock in the behavior.

Fix this →

2. No initial focus on screen open — TvPlaylistDetailsScreen.kt:88-89

Nothing calls playAllFocusRequester.requestFocus() when the details screen opens, and (unlike TvPlaylistsGrid, which uses focusProperties { onEnter = { …requestFocus() } } at TvPlaylistsScreen.kt:199) there's no onEnter here either. On a TV the first d-pad press should always do something. Please confirm on the emulator that focus reliably lands on Play all episodes/the first row on entry; if it depends on the host scaffold, a LaunchedEffect(Unit) { playAllFocusRequester.requestFocus() } (or an onEnter on the Row) makes it deterministic. Note the requester is only attached when episodes.isNotEmpty() (:208), so any explicit request must handle the empty case.

3. Minor — use durationMsTvPlaylistDetailsScreen.kt:226

BaseEpisode already exposes durationMs, which TvEpisodeRow uses. episodes.sumOf { it.durationMs.toLong() } reads cleaner than the manual episode.duration * 1000 conversion and keeps the two files consistent.

Nits / observations (non-blocking)

  • NotFound renders LoadingView before onClose fires — fine in practice (momentary), just noting the spinner briefly shows during teardown.
  • episodeSummaryText recomputes getTimeDurationShortString on every recomposition; negligible here, could be remember(episodes) if it ever matters.
  • Tests cover the load + delete paths well; the smart-playlist path and the unavailable-episode edge case (finding Update issue templates #1) aren't covered.
  • No security concerns. The no-op onClicks for playback / Play all episodes are called out in the description as intentional follow-ups.

These findings match the earlier inline comments, which still apply — the code is unchanged since. Finding #1 is the only one I'd treat as a blocker.


  • Read changed files and gather context
  • Analyze for bugs, quality, performance, security
  • Post review
    · feat/tv-playlist-details-screen

@sztomek sztomek changed the title Add a playlist details screen to the TV app [TV] Add a playlist details screen to the TV app Jul 28, 2026
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

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


PR Review — TV Playlist Details Screen (re-review of 0d5d40e)

Re-reviewed against origin/feat/tv-create-playlist. The "Address PR comments" commit resolves all three findings from the earlier review:

  • Read changed files & the latest "Address PR comments" commit
  • Verify prior findings were addressed
  • Analyze for new bugs / quality / perf / security
  • Post review

Prior findings — all resolved ✅

  1. Episode-count inconsistencytotalEpisodeCount was removed from Loaded, and the empty-branch now gates on uiState.episodes.isEmpty() (TvPlaylistDetailsScreen.kt:102), consistent with episodeSummaryText/PlaylistInfo. The header and the branch logic can no longer diverge. 👍
  2. Initial focus on openEpisodeList now attaches a firstEpisodeFocusRequester to index 0 and requests it in LaunchedEffect(Unit) (:125-128, :144). First d-pad press now has a deterministic target.
  3. durationMsepisodeSummaryText now uses episodes.sumOf { it.durationMs.toLong() } (:231), matching TvEpisodeRow.

One minor caveat on the focus fix (non-blocking)

LaunchedEffect(Unit) { firstEpisodeFocusRequester.requestFocus() } requests focus on a FocusRequester attached to a LazyColumn item. In some Compose versions, requesting focus before the target node has been placed/laid out throws IllegalStateException ("FocusRequester is not initialized") or is silently dropped — a known flakiness with focus-in-lazy-lists. Since you've verified it on the emulator this is likely fine in practice; if you ever see it not land (or a rare crash), wrapping the call in a runCatching { … } or driving it off the LazyListState first-visible item makes it robust.

Also worth a quick confirm: initial focus now lands on the first episode row rather than the Play all episodes button. That's a reasonable choice (LEFT from the row jumps to Play all), just flagging it's a deliberate difference from "focus the primary action" in case the designs expected the button to be focused first.

Remaining note from before (unchanged, non-blocking)

  • The unavailable-episode edge case is now handled correctly by gating on episodes, but there's still no ViewModel test mixing Available/Unavailable episodes to lock the behavior in. Optional, but it would guard against a regression on this exact bug. The smart-playlist path is also still untested.

Nice work addressing the feedback — the PR looks good to merge from my side (pending the human reviewer). The no-op playback onClicks remain intentional follow-ups per the description.

@geekygecko geekygecko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks great!

The one slight difference with the Apple TV version is that the playlist page no longer has the header navigation.

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