Skip to content

[TV] Show a download app modal when creating a playlist - #5658

Merged
sztomek merged 6 commits into
feat/tv-local-data-parityfrom
feat/tv-create-playlist
Aug 3, 2026
Merged

[TV] Show a download app modal when creating a playlist#5658
sztomek merged 6 commits into
feat/tv-local-data-parityfrom
feat/tv-create-playlist

Conversation

@sztomek

@sztomek sztomek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description

Implements the Create playlist action on the TV Playlists tab's empty state, mirroring the tvOS DownloadAppModal: since playlists can't be created on the TV, the button opens a modal with a QR code pointing to pocketcasts.com/downloads so the user can grab the phone app.

  • TvDownloadAppModal reuses the glassy TvModal (widened via a new width parameter) with the tvOS copy, a QR code rendered through the existing qr module, the plain-text URL, and a focused Done button.
  • The empty state's "Create a playlist" button now opens the modal; Done and back both dismiss it.

Figma: Ftk3KwnfqaK4g57yCN63p0-fi-3246_3451

Fixes PCDROID-694 https://linear.app/a8c/issue/PCDROID-694/create-playlists-modal

Testing Instructions

  1. Run the tv app with signed out state
  2. Focus Create a playlist and press select.
  3. ✅ Verify the modal matches the design (title, subtitle, QR, URL, Done) and that the QR resolves to https://www.pocketcasts.com/downloads.
  4. Verify Done and the back button both dismiss the modal, returning focus to the empty state.

Screenshots or Screencast

Empty playlist screen Create playlist modal
Screenshot_20260728_163622 Screenshot_20260728_162905

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes
  • 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.

I have tested any UI changes...

  • with different themes
  • with a landscape orientation
  • with the device set to have a large display and font size
  • for accessibility with TalkBack

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

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


Code Review

Reviewed the diff against origin/feat/tv-playlists-tab. This is a clean, well-scoped change that mirrors the existing TvProfileModal / TvSignInScreen patterns closely. Nice work — no blocking issues. A few minor notes below (two posted inline).

  • Read changed files
  • Analyze for bugs, quality, performance
  • Post review feedback

What looks good

  • width parameter on TvModal/TvModalSurface is a clean, backward-compatible extension — DefaultModalWidth keeps every existing caller unchanged.
  • State hoistingisDownloadModalVisible uses rememberSaveable, so the modal survives config changes / process death, and onCreatePlaylist is threaded through cleanly with the callback plumbed to both previews.
  • Focus handling matches the established pattern (FocusRequester + LaunchedEffect(Unit) { requestFocus() }), so the Done button is focused on open.
  • QR uses the shared rememberQrPainter (which handles bitmap recycling via awaitDispose), and both the QR + URL text give the user a fallback if the code won't scan.
  • Preview is present and renders TvModalSurface directly, consistent with TvProfileModal.

Minor suggestions (non-blocking)

  1. QR contentDescription reuses the subtitle string (TvDownloadAppModal.kt:67) → TalkBack reads the same sentence twice. A QR-specific description or null would read better. (inline)
  2. DOWNLOAD_URL_LABEL duplicates DOWNLOAD_URL (TvDownloadAppModal.kt:90-91) → the two literals can drift apart; deriving the label from the URL keeps them in sync. (inline)
  3. Analytics — the PR description leaves the Event Horizon checklist item unchecked. If opening this modal (or tapping the empty-state button) should be tracked for consistency with other TV surfaces, consider adding an AnalyticsTracker event. Worth a quick confirmation rather than a change.

Verification

  • The new/changed strings, TvTextStyles additions, and callback signatures are all consistent with existing usages; nothing else references the changed symbols. I did not run a build/lint locally, but the change is small and self-consistent.
    · branch feat/tv-create-playlist

@dangermattic

dangermattic commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is assigned to the milestone 8.18. The due date for this milestone has already passed.
Please assign it to a milestone with a later deadline or check whether the release for this milestone has already been finished.

Generated by 🚫 Danger

)
Image(
painter = rememberQrPainter(content = DOWNLOAD_URL, size = QrCodeSize),
contentDescription = stringResource(LR.string.tv_playlists_download_subtitle),

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 (accessibility): the QR image's contentDescription reuses the subtitle string, so TalkBack will read "They'll be waiting here when you're done. Scan to get the app" twice — once for the subtitle Text above and again for the image. Consider a QR-specific description (e.g. a dedicated string like "QR code linking to pocketcasts.com/downloads") or null if the adjacent URL text makes it redundant.

Comment on lines +90 to +91
private const val DOWNLOAD_URL = "https://www.pocketcasts.com/downloads"
private const val DOWNLOAD_URL_LABEL = "pocketcasts.com/downloads"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DOWNLOAD_URL_LABEL is maintained as a second literal independent of DOWNLOAD_URL, so the QR target and the visible label can silently drift apart if one is edited. TvSignInScreen derives the label from the URL (removePrefix("https://").removePrefix("http://").trimEnd('/')). Since the label here also drops the www., that exact helper won't match, but deriving it (e.g. .removePrefix("https://www.").trimEnd('/')) would keep the two in sync. Not blocking — just flagging the duplication.

@sztomek
sztomek force-pushed the feat/tv-create-playlist branch from 553e23d to 95fae55 Compare July 28, 2026 15:11
@sztomek
sztomek changed the base branch from feat/tv-playlists-tab to feat/tv-local-data-parity July 28, 2026 15:11
@sztomek
sztomek marked this pull request as ready for review July 28, 2026 15:21
@sztomek
sztomek requested a review from a team as a code owner July 28, 2026 15:21
@sztomek
sztomek requested review from geekygecko and removed request for a team July 28, 2026 15:21
@sztomek sztomek added this to the 8.18 milestone Jul 28, 2026
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

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


Code Review

  • Read changed files
  • Analyze for bugs, quality, security, performance
  • Post review feedback

Reviewed against origin/feat/tv-local-data-parity. This is a clean, well-scoped change that faithfully mirrors the existing TvSignInScreen / TvProfileModal patterns. No blocking issues and no bugs found. Details below.

What looks good

  • width param on TvModal / TvModalSurface is backward-compatible — DefaultModalWidth keeps all existing callers unchanged, and the preview passes the same 600.dp so it renders true to the real modal.
  • QR sizing is correct. rememberQrPainter(size = QrCodeSize) renders the bitmap at the same 144.dp the Image content box is sized to, so the code isn't up/down-scaled (no blur). The white background + inner padding(10.dp) gives the quiet zone scanners need.
  • State hoistingisDownloadModalVisible is rememberSaveable, so the modal survives config change / process death, and onCreatePlaylist is threaded cleanly to both previews.
  • Focus follows the established FocusRequester + LaunchedEffect(Unit) { requestFocus() } pattern, so Done is focused on open; back dismisses via the Dialog's onDismissRequest.
  • Preview renders TvModalSurface directly, consistent with the other TV modals.

Minor (non-blocking) — both still open from the earlier pass

  1. QR contentDescription reuses the subtitle string (TvDownloadAppModal.kt:67). TalkBack reads "They'll be waiting here when you're done. Scan to get the app" twice — once for the subtitle Text, again for the image. A QR-specific string (e.g. "QR code linking to pocketcasts.com/downloads") or null (the adjacent URL text makes it redundant) reads better. Fix this →
  2. DOWNLOAD_URL_LABEL duplicates DOWNLOAD_URL (TvDownloadAppModal.kt:90-91). The two literals can silently drift if one is edited. TvSignInScreen derives the label from the URL (removePrefix("https://").removePrefix("http://")); since this label also drops www., deriving it (e.g. DOWNLOAD_URL.removePrefix("https://www.").trimEnd('/')) keeps them in sync. Fix this →

Other notes

  • Analytics — the Event Horizon checklist item is unchecked and no AnalyticsTracker event is fired when the empty-state button opens this modal. If other TV surfaces track equivalent actions, worth adding one for consistency — otherwise fine to leave. Just confirming intent, not requesting a change.
  • Manifest diff bot comments (removal of DefaultPlaylistsStartupInitializer) come from the base branch feat/tv-local-data-parity, not this PR — nothing to action here.
  • No test was added, which is reasonable: this is a static, stateless modal with a preview. If you want light coverage, a Compose UI test asserting Done dismisses would be the highest-value one.

Nice work — good to go once you decide on the two minor items above.
· branch feat/tv-create-playlist

@sztomek
sztomek force-pushed the feat/tv-local-data-parity branch from 82792f4 to 7940fbb Compare July 28, 2026 15:39
@sztomek
sztomek force-pushed the feat/tv-create-playlist branch from 95fae55 to 756d773 Compare July 28, 2026 15:40
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 44s —— View job


Code Review

  • Read changed files
  • Analyze for bugs, quality, security, performance
  • Post review feedback

Re-reviewed against origin/feat/tv-local-data-parity after the latest push. Both minor items from the previous pass are now resolved in Address PR changes (756d773):

  1. QR contentDescription is now null (TvDownloadAppModal.kt:67) — TalkBack no longer reads the subtitle twice; the adjacent URL text keeps it discoverable.
  2. DOWNLOAD_URL_LABEL is now derived from DOWNLOAD_URL (TvDownloadAppModal.kt:91: DOWNLOAD_URL.removePrefix("https://www.")), so the QR target and visible label can't drift apart.

What looks good

  • width param on TvModal / TvModalSurface stays backward-compatible via DefaultModalWidth = 400.dp; the modal and its preview both pass 600.dp, so the preview renders true to the real surface.
  • QR sizing (rememberQrPainter(size = QrCodeSize) == Image box at 144.dp) avoids up/down-scaling, and the white background + padding(10.dp) quiet zone is scanner-friendly.
  • State hoistingisDownloadModalVisible uses rememberSaveable, surviving config change / process death; onCreatePlaylist is threaded cleanly and both previews pass a stub.
  • Empty-state button switched from OutlinedButton to the shared filled Button + TvButtonDefaults.filledButtonColors(), matching the design and the modal's Done button.
  • Focus follows the established FocusRequester + LaunchedEffect(Unit) { requestFocus() } pattern; back dismisses via the Dialog's onDismissRequest.
  • New TvTextStyles.ModalTitle / ModalBody are consistent with the existing style objects.

Remaining note (non-blocking)

  • Analytics — the Event Horizon checklist item is still unchecked and no AnalyticsTracker event fires when the empty-state button opens this modal. If other TV surfaces track equivalent actions, worth adding one for consistency; otherwise fine to leave. Just confirming intent, not requesting a change.

No bugs, security, or performance concerns. Nice work — this is good to go. 🚀
· branch feat/tv-create-playlist

@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.

Looks good to me. I only had some minor comments.

Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/playlists/TvDownloadAppModal.kt Outdated
@sztomek
sztomek force-pushed the feat/tv-local-data-parity branch from 7940fbb to 7bd98d2 Compare August 3, 2026 11:17
@sztomek
sztomek force-pushed the feat/tv-create-playlist branch from 756d773 to efc46be Compare August 3, 2026 11:20
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

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


Code Review

  • Read changed files
  • Analyze for bugs, quality, security, performance
  • Post review feedback (2 inline comments)

Re-reviewed against origin/feat/tv-local-data-parity after the latest push (efc46be Address PR comments). No bugs, security, or performance issues. Two minor items inline; nothing blocking.

What changed since the last pass

efc46be addressed @geekygecko's URL comment: DOWNLOAD_URL is now https://pocketcasts.com/downloads (the canonical target rather than the www. redirect source) and DOWNLOAD_URL_LABEL derives from it via removePrefix("https://"), so the QR target and the visible label can't drift. Shorter URL also means a slightly less dense QR — helpful at ErrorCorrectionLevel.H and 144.dp.

Verified correct

  • QR renderingrememberQrPainter(size = QrCodeSize) matches the Image's .size(QrCodeSize) exactly, so the bitmap isn't re-scaled. Modifier order (padding(vertical) → background → padding(10.dp) → size) puts the 10.dp quiet zone inside the white 164×164 box, which is what scanners need. QrPainter also handles bitmap recycling in awaitDispose and encodes off the main thread (Dispatchers.IO), so no jank on modal open.
  • width param on TvModal / TvModalSurface — backward compatible via DefaultModalWidth = 400.dp; the only other caller (TvProfileModal) is untouched, and the preview passes the same 600.dp so it renders true to the real surface. 600.dp ≈ the 1200px Figma frame at 1x.
  • FocusFocusRequester + LaunchedEffect(Unit) { requestFocus() } matches TvProfileModal, TvSignInScreen, TvWelcomeScreen, TvTabBar; back dismisses via the Dialog's onDismissRequest.
  • State hoistingisDownloadModalVisible is rememberSaveable, onCreatePlaylist threaded to both previews, TvPlaylistsContent stays stateless.
  • TvEmptyState OutlinedButton → filled ButtonTvPlaylistsScreen is the only caller, so no other empty state regresses; Color import is still used by the title.
  • Strings — English-only in modules/services/localization, placed with the other tv_playlists_* keys, curly apostrophes consistent with neighbours. LR.string.done already exists.
  • No analytics — I checked: AnalyticsTracker isn't referenced anywhere in the tv module, so omitting an event here is consistent with the rest of the TV app. Dropping the note I raised in earlier passes; nothing to do.

Inline comments

  1. TvTextStyles.kt:82-95 — following up on @geekygecko's Figma question: ModalTitle is identical to PlaylistCardTitle and ModalBody identical to FeaturedTileDescription apart from textAlign. Figma names the title Title 3 · 48/56 (= 24/28 at 1x), so the values already are a shared scale entry — only the name is component-scoped. Worth either consolidating or replying on the thread so it doesn't get re-raised.
  2. TvDownloadAppModal.kt:86 — the Done label doesn't use TvTextStyles.ModalButtonLabel the way TvProfileModalButton does, so the two modals' buttons render with different type.

Other observations (no action needed)

  • Vertical spacing comes from two places: TvModalSurface's Arrangement.spacedBy(16.dp) plus the QR's padding(vertical = 16.dp) and the button's padding(top = 16.dp) — so those gaps are 32.dp. Intentional-looking per the design, just non-obvious if someone later tweaks the arrangement.
  • TvDownloadAppModal lives in the playlists package but the pattern ("you can't do this on TV, here's a QR to the phone app") is generic, matching tvOS's DownloadAppModal. If a second surface needs it, promoting it to component with title/subtitle params would beat a copy. Fine to defer until there's a second caller.
  • isDownloadModalVisible isn't tied to uiState, so if a sync lands a playlist while the modal is open it floats over the grid instead of the empty state. Harmless.
  • I did not run a build or spotlessCheck locally (Gradle/Android SDK setup isn't available in this job); the change is small and self-consistent, and CI covers it.

Looks good to me — ship it once you've decided on the two inline nits. 🚀
· branch feat/tv-create-playlist

Comment on lines +82 to +95
val ModalTitle = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 28.sp,
textAlign = TextAlign.Center,
platformStyle = PlatformTextStyle(includeFontPadding = false),
)

val ModalBody = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
textAlign = TextAlign.Center,
platformStyle = PlatformTextStyle(includeFontPadding = false),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Following up on @geekygecko's Figma question: these two are exact duplicates of styles already in this file apart from textAlign.

  • ModalTitle (24sp / SemiBold / 28sp) == PlaylistCardTitle
  • ModalBody (16sp / 20sp) == FeaturedTileDescription

The Figma screenshot names the title Title 3 · 48/56, which is exactly 24/28 at 1x — so the values already match a shared type-scale entry, only the name is component-scoped. Renaming these to the Figma scale (e.g. Title3, Body) and setting textAlign at the call site (Text(textAlign = TextAlign.Center, …), as TvEmptyState already does) would collapse four near-identical styles into two reusable ones and make the next screen that needs the same type pick them up for free.

Non-blocking — but if you'd rather keep the component-scoped naming, worth saying so on the review thread so it doesn't get re-raised. Fix this →

.padding(top = 16.dp)
.focusRequester(focusRequester),
) {
Text(stringResource(LR.string.done))

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 consistency: TvProfileModalButton styles its label with TvTextStyles.ModalButtonLabel (17sp, weight 510), but this Done label falls back to the default TV Material typography. Two modals side by side will render their buttons with different type. Same applies to TvEmptyState's Text(actionLabel) now that it's a filled button.

Suggested change
Text(stringResource(LR.string.done))
Text(stringResource(LR.string.done), style = TvTextStyles.ModalButtonLabel)

@sztomek
sztomek force-pushed the feat/tv-local-data-parity branch from 7bd98d2 to 15f1088 Compare August 3, 2026 11:54
@sztomek
sztomek force-pushed the feat/tv-create-playlist branch from efc46be to 7e3afb6 Compare August 3, 2026 11:54
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

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


Code Review

  • Read changed files
  • Analyze for bugs, quality, security, performance
  • Post review feedback

Re-reviewed against origin/feat/tv-local-data-parity (now at 15f1088) after this push.

This synchronize was a rebase — no content change

All six blob SHAs are byte-identical to the state I reviewed at 11:20 (strings.xml 8b1dc7d, TvEmptyState.kt fd7cdcd, TvModal.kt 67ab479, TvDownloadAppModal.kt f4bec40, TvPlaylistsScreen.kt 577e84e, TvTextStyles.kt 71ccf05), and every commit on the branch was re-stamped at 13:54:43. So the push moved the branch onto the updated base rather than changing the diff.

I re-verified that the rebase didn't break anything the change depends on:

  • TvButtonDefaults.filledButtonColors() still exists in theme/TvButtonDefaults.kt and returns the container/focused pair the filled Button needs.
  • TvModalSurface is still internal — module-scoped in Kotlin, so the cross-package call from playlists/ to component/ is fine.
  • LR.string.done still resolves (strings.xml:45).
  • TvPlaylistsScreen.kt:237 remains the only TvEmptyState caller, so switching it to a filled Button regresses no other empty state.
  • rememberQrPainter(content, size) signature unchanged; EncodeHintType.MARGIN = 0 confirms the QR bitmap ships no quiet zone of its own, which is exactly why the modal's own padding(10.dp) inside the white box is needed. Modifier order (padding(vertical=16) → background → padding(10) → size(144)) makes the white surface 164×164 with the 144.dp code centred — correct, and no re-scaling since size matches the requested bitmap size.

Still open from the last pass (both non-blocking)

  1. TvTextStyles.kt:82-95ModalTitle (24/28/SemiBold) is identical to PlaylistCardTitle and ModalBody (16/20) identical to FeaturedTileDescription, apart from textAlign. Following up on @geekygecko's Figma question: the screenshot names the title Title 3 · 48/56 = 24/28 at 1x, so these values are a shared scale entry and only the name is component-scoped. Either consolidate into Figma-scale names with textAlign at the call site (as TvEmptyState already does), or reply on the thread so it doesn't get re-raised.
  2. TvDownloadAppModal.kt:86 — the Done label falls back to default TV Material typography, whereas TvProfileModal.kt:177 styles its button label with TvTextStyles.ModalButtonLabel (17sp / weight 510). Two modals side by side will render their buttons with different type. Same applies to TvEmptyState's Text(actionLabel) now that it's a filled button.

One small addition

  • If QR encoding fails, rememberQrPainter returns ColorPainter(Color.Gray) — the user sees a plain 144.dp grey square with no explanation. The pocketcasts.com/downloads text right below is a working fallback, so this is cosmetic-only and I'd leave it; noting it because it's the one path where the modal degrades silently.

No bugs, security, or performance concerns. Everything from the earlier reviews (state hoisting via rememberSaveable, FocusRequester pattern matching the other TV surfaces, back-dismiss via Dialog.onDismissRequest, backward-compatible width param with DefaultModalWidth = 400.dp, English-only strings in modules/services/localization, no analytics being consistent with the rest of the tv module) still holds.

I did not run a build or spotlessCheck — the Gradle/Android SDK setup isn't available in this job — but the diff is unchanged from a state CI has already exercised.

Still good to go once you've decided on the two inline nits. 🚀
· branch feat/tv-create-playlist

@sztomek
sztomek merged commit 3468f59 into feat/tv-local-data-parity Aug 3, 2026
21 checks passed
@sztomek
sztomek deleted the feat/tv-create-playlist branch August 3, 2026 12:03
@claude claude Bot mentioned this pull request Aug 3, 2026
6 tasks
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