Skip to content

[TV] Home parity: banners, categories and sponsored podcasts - #5730

Merged
sztomek merged 11 commits into
mainfrom
feat/tv-home-discover-parity
Aug 14, 2026
Merged

[TV] Home parity: banners, categories and sponsored podcasts#5730
sztomek merged 11 commits into
mainfrom
feat/tv-home-discover-parity

Conversation

@sztomek

@sztomek sztomek commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes feature-parity gaps between the Apple TV and Android TV apps on the Home feed.

What this PR adds

Banner row (iOS DiscoverRowSection.makeBannerRow + BannerRow.swift) — a banner / inline_banner feed item keyed by id (create_account / discover_more). New TvBannerRow:

  • CTA pill, title/subtitle, focus scale (1.05×), and the design's focused/unfocused CTA colours.
  • Routes discover_more → Search tab, create_account → create-account flow.

Category rows + detail screen (iOS DiscoverCategoriesRow + DiscoverPodcastsListView) — the Home feed now renders the category pills row, and pills open a new TvCategoryPodcastsScreen ("Most Popular in {category}", 6-column grid matching iOS) via the established TvDetailOverlay focus-stacking pattern, reusing TvPodcastGridScaffold / TvPodcastTile.

Sponsored parity (tvOS has no dedicated ad row — sponsorship is per-podcast). TvDiscoverFeedLoader now:

  • Retitles a sponsored single_podcast row to "Pocket Casts recommends".
  • Injects sponsored_podcasts into normal lists at their server positions.
  • Merges a category's sponsored ad podcasts (category_id-tagged rows) into the category page at slot 5.
  • TvPodcastTile / TvSinglePodcastTile / TvFeaturedTile render the "Sponsored" label (matching iOS DiscoverPodcastCell / DiscoverSinglePodcastCell).

Data model: new TvDiscoverRow.Banner / .Categories, TvDiscoverBanner enum, TvCategorySelection saver, and region-token resolution for category sources. No new components were invented where an existing one fit. No analytics (separate workstream); the callbacks are structured for later hookup.

Notes on the live feed

  • The signed-in TV feed on staging currently ships no sponsored rows; production has 3 sponsored podcasts, all category_id-tagged, so they surface only on their category pages (Business / Kids & Family / Music) — matching iOS, which filters categoryID != nil out of the home feed.
  • listShowAllTapped / "show all" is phone-only, not a TV parity gap.

Fixes PCDROID-722 https://linear.app/a8c/issue/PCDROID-722/discover-banner
Figma: Ftk3KwnfqaK4g57yCN63p0-fi-2373_1041

Testing Instructions

  1. Sign out, open Home — if the feed has the create_account banner it renders with artwork near the top; pressing it opens create-account.
  2. Sign in, open Home — the discover_more banner (artwork + gradient) routes to Search; focus it to see the 1.05× scale and CTA colour change.
  3. Focus the Browse categories pills → press a pill → "Most Popular in {category}" grid; open a podcast; Back restores focus to the pill.
  4. (Production, signed in) open the Business / Kids & Family / Music category page to see a sponsored podcast at slot 5 with the "Sponsored" label.

Screenshots or Screencast

Screen_recording_20260812_204515.mp4

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

@dangermattic

dangermattic commented Aug 12, 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 changed the title [TV] Add the Home discover banner and category pills [TV] Update home feed to support banners and categories Aug 12, 2026
@sztomek
sztomek force-pushed the feat/tv-home-discover-parity branch from 0e2bd23 to e3b2a38 Compare August 12, 2026 18:40
@sztomek sztomek changed the title [TV] Update home feed to support banners and categories [TV] Home discover parity: banner artwork, categories and sponsored podcasts Aug 12, 2026
@sztomek sztomek mentioned this pull request Aug 12, 2026
10 tasks
@sztomek sztomek added this to the 8.19 milestone Aug 12, 2026
@sztomek sztomek added [Type] Feature Adding a new feature. [Area] TV labels Aug 12, 2026
@sztomek
sztomek marked this pull request as ready for review August 12, 2026 18:55
@sztomek
sztomek requested a review from a team as a code owner August 12, 2026 18:55
@sztomek
sztomek requested review from geekygecko and removed request for a team August 12, 2026 18:55
@sztomek

sztomek commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@claude please review this

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Code review

  • Gather context (diff vs feat/tv-search-filter)
  • Review feed loader / sponsored merge logic
  • Review new Compose components (banner, category screen, tiles)
  • Review home screen wiring & tests
  • Post review

Overall this is clean, well-factored work — the row/overlay patterns match what's already in the TV module (TvDetailOverlay focus stacking, suspend (String) -> T loader callbacks à la TvFolderDetailScreen, TvPodcastGridScaffold reuse), and the ViewModel test coverage for the new feed-loader branches is genuinely good. A few things worth addressing before merge, one of which is a potential crash.


🔴 insertSponsored can produce duplicate uuids → LazyRow duplicate-key crash

TvDiscoverFeedLoader.kt:158-165 inserts the sponsored podcast unconditionally, after the base list has already been de-duplicated:

val basePodcasts = feed.podcasts.orEmpty()
    .distinctBy(DiscoverPodcast::uuid)   // line 122
    ...
val podcasts = insertSponsored(basePodcasts, insertionsDeferred.await())

The row is then rendered with key = TvDiscoverPodcast::uuid (TvDiscoverRows.kt:98), which reaches itemsIndexed(items, key = …) in TvRow.kt:94. If a sponsored source resolves to a podcast that is also organically present in the host list (perfectly plausible — a promoted show in "Trending"), Compose throws IllegalArgumentException: Key "<uuid>" was already used and the Home screen crashes.

Note mergeCategorySponsored (line 80-86) already guards against exactly this by filtering matching uuids out of rest; insertSponsored should do the same (drop the pre-existing copy, then insert at the server position — or at minimum distinctBy(uuid) after insertion). Fix this →


🟠 Sponsored ad lands at slot 0 in short categories

TvDiscoverFeedLoader.kt:84:

val position = if (rest.size > SPONSORED_CATEGORY_POSITION) SPONSORED_CATEGORY_POSITION else 0

For any category with ≤ 5 organic podcasts the ad is promoted to the very first grid cell rather than sitting after the organic content — the opposite of the intended "slot 5" behaviour. addAll(index, …) accepts index == size, so minOf(SPONSORED_CATEGORY_POSITION, rest.size) gives "position 5, or appended at the end if the list is shorter", which is almost certainly what you want. The existing test only covers the 7-podcast case, so this branch is untested. Fix this →


🟡 Opening a category refetches the whole discover feed

loadCategorySponsoredPodcasts (line 61-63) calls getLoggedIn/OutDiscoverFeed() again purely to find the category_id-tagged sponsored rows — a feed the Home screen has already loaded and parsed. That's a full extra round-trip on the critical path of every category open (it's awaited alongside the podcast list, so it can gate the screen's first paint). Worth caching the last-loaded Discover (or just the sponsored rows) in TvDiscoverFeedLoader, or threading the already-known ad source through TvOpenedCategory.

Related, smaller: buildRows filters out authenticated rows when logged out (line ~98), but loadCategorySponsoredPodcasts doesn't apply that filter — the request fails softly (ListRepository.getListFeed wraps everything in runCatching), so it's only a wasted call plus a Timber error. Also note the phone loads sponsored sources with authenticated = false unconditionally (DiscoverViewModel.loadCarouselSponsoredPodcasts) whereas line 146 here passes row.authenticated — worth confirming that's intentional.


🟡 Category pills in Search are still dead

TvSearchScreen.kt:250 renders the same TvCategoryTile pills with onClick = {}. After this PR, pressing a pill on Home opens a grid but pressing the visually identical pill in Search does nothing — and this PR does touch that path (loadCategories now region-resolves those sources, line 41). Now that TvCategoryPodcastsScreen exists, wiring it up there is small. If it's deliberately out of scope, a short TODO or a note in the PR description would help.


Smaller notes

  • Sponsored tile geometry (TvPodcastTile.kt:38-54): the sponsored branch adds padding(horizontal = 18.dp, vertical = 9.dp) around the artwork plus a label below, so in the 6-column category grid the sponsored tile's artwork is noticeably smaller than its neighbours in the same row, and in a LazyRow the tile is both wider and taller than its siblings. If that matches Figma, ignore; otherwise consider reserving the label space on all tiles so artwork stays aligned.
  • Banner copy is local-only (TvBannerRow.kt): TvDiscoverRow.Banner.title is populated from row.title but never rendered — the UI always uses the hardcoded string resources. That's the right call for localisation, but the unused title is dead weight and server-side copy changes silently won't apply; a one-line comment explaining why would save the next reader a lookup.
  • Banner detection is a string match on ListType.Unknown("banner") (line 105). Fine and covered by a test, but if other clients will need it, promoting Banner into the ListType sealed class in servers would be more durable than a TV-local constant.
  • Unchecked cast (line 219-221): transformWithReplacements(replacements, resources) as DiscoverCategory is safe today only because DiscoverCategory.transformWithReplacements returns copy(...). as? DiscoverCategory ?: this would be free insurance.
  • Naming: the file is TvCategorySelection.kt (and the PR description says "TvCategorySelection saver") but the type is TvOpenedCategory / TvOpenedCategorySaver. Worth aligning.
  • Tests: nice coverage of the new branches. Gaps that map to the findings above — duplicate-uuid sponsored insertion, the ≤ 5 category-merge position, and includeHomeSections = false (i.e. banner/category rows not appearing in the search feed, which is the one behaviour distinguishing the two buildRows call sites).
  • The PR checklist leaves CHANGELOG.md unchecked for what is a user-facing change — intentional for TV-only work?

I did not build or run the test suite here (Gradle isn't available in this environment), so the findings are from reading the diff and the surrounding TV/discover code.
· branch feat/tv-home-discover-parity

@sztomek sztomek changed the title [TV] Home discover parity: banner artwork, categories and sponsored podcasts [TV] Home parity: banners, categories and sponsored podcasts Aug 12, 2026
@sztomek
sztomek force-pushed the feat/tv-search-filter branch 3 times, most recently from 4cdc38e to eb644a0 Compare August 14, 2026 09:05
Base automatically changed from feat/tv-search-filter to main August 14, 2026 09:15
@sztomek
sztomek force-pushed the feat/tv-home-discover-parity branch from 74ab725 to f1b6b25 Compare August 14, 2026 09:53
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Re-review after Address PR comments (f1b6b25)

  • Diff the latest push vs. my previous review
  • Check which earlier findings were addressed
  • Review new/changed code for fresh issues
  • Post updated review

The follow-up commit resolves everything I flagged as blocking, and the two review comments from @geekygecko. Re-reading the full diff against origin/main, I found one new small issue and three carry-overs that are judgement calls rather than blockers.


✅ Resolved since the last review

Finding Fix
🔴 duplicate uuid → LazyRow duplicate-key crash insertSponsored now filters the pre-existing copy out before inserting (TvDiscoverFeedLoader.kt:159-160), plus the sponsored podcast already present in the row is not duplicated test
🟠 sponsored ad landing at slot 0 in short categories minOf(SPONSORED_CATEGORY_POSITION, rest.size) (:83), plus the …appends the sponsored ad when the category is short test
🟡 auth filter missing on category sponsored rows && (isLoggedIn || it.authenticated != true) (:73)
unchecked cast as? DiscoverCategory ?: this (:220)
file/type name mismatch renamed to TvOpenedCategory.kt
includeHomeSections = false untested banner rows are excluded from the search discover feed in TvSearchViewModelTest
banner text overlapping artwork Row constrained to fillMaxWidth(0.82f / 0.68f)
sponsored label padding bottom = 9.dp, matching the artwork's vertical = 9.dp

🟡 New: unused string added to localization

tv_search_searching ("Searching…") is added in strings.xml:260 but has no references anywhere in the repo. Unused strings still go out to GlotPress for translation — worth removing unless a follow-up is about to use it. (inline) · Fix this →

🟡 New: banner text can be squeezed out by long translations

TvBannerRow.kt:92-112 — the CTA pill and the title/subtitle Column are both un-weighted children of the Row, so the pill is measured first and takes whatever width it needs; the Column gets only the remainder of the 68%/82%. With a long localised CTA ("Create a free account" grows ~30-40% in de/fr/ru) the pill plus the fixed 48dp spacer can consume the constrained width and the title/subtitle get measured at zero width — they vanish rather than ellipsise. Weighting both children (weight(1f, fill = false) on the pill, weight(1f) on the Column) makes the split proportional and bounded. (inline) · Fix this →


Carry-overs (not blocking, your call)

  • Category open still costs an extra discover-feed request. loadCategorySponsoredPodcasts (:62) calls listRepository.getHomeDiscoverFeed(isLoggedIn), and ListRepository does no caching — it goes straight to listWebService.getLoggedIn/OutDiscoverFeed. Since it's awaited alongside the podcast list (:57), it gates the category screen's first paint on a second full feed fetch. Caching the last-loaded Discover (or just its sponsored rows) in TvDiscoverFeedLoader would remove it.
  • Search-tab category pills are still onClick = {} (TvSearchScreen.kt:257) while the visually identical Home pills now open a grid. TvCategoryPodcastsScreen exists now, so wiring it is small — or a TODO / note in the description if it's a deliberate follow-up.
  • TvDiscoverRow.Banner.title is still populated from row.title and never rendered (the UI uses string resources). Correct for localisation, but the field is dead weight; a one-line comment would save the next reader the lookup.
  • Minor, unchanged: loadSponsoredInsertions passes row.authenticated where the phone (DiscoverViewModel.loadCarouselSponsoredPodcasts) passes false unconditionally — worth confirming that's intentional; and the CHANGELOG checkbox is still unticked.

Focus handling for the new nested overlay (categoryRestoreTrigger vs restoreFocusTrigger in TvHomeScreen.kt:96-119) reads correctly to me: closing a podcast opened from a category restores the grid, closing one opened from Home restores the row.

I could not run Gradle in this environment (the sandbox blocks it), so the build, spotlessCheck and the unit tests are unverified here — findings are from reading the diff and surrounding TV/discover code.
· branch feat/tv-home-discover-parity

<string name="tv_banner_create_account_title">One account. Every screen</string>
<string name="tv_banner_create_account_subtitle">Your follows and progress exactly where you left them</string>
<string name="tv_banner_create_account_action_title">Create a free account</string>
<string name="tv_banner_discover_more_title">You haven\'t seen the half of it</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tv_search_searching has no references anywhere in the repo (grep -rn "tv_search_searching" only hits this file). Unused strings still get shipped to GlotPress and translated, so it's worth dropping it unless a follow-up PR is about to use it.

Comment on lines +92 to +112
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(if (banner.hasGradient) 0.82f else 0.68f)
.padding(horizontal = 48.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(if (isFocused) MaterialTheme.tvColors.backgroundActive else MaterialTheme.tvColors.backgroundActive20)
.padding(horizontal = 24.dp, vertical = 12.dp),
) {
Text(
text = banner.actionTitle(),
style = MaterialTheme.tvTypography.caption1,
color = if (isFocused) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.backgroundActive,
)
}
Spacer(modifier = Modifier.width(48.dp))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This fixes the overlap, but the two children of the Row are both un-weighted, so the CTA pill is measured first and takes whatever it wants; the text Column only gets what's left of the 68%/82%. All three banner strings are localised, and "Create a free account" is one of the longer-growing kinds of string (de/fr/ru commonly +30-40%) — with a long enough translation the pill plus the fixed 48dp spacer can consume the whole constrained width and the title/subtitle get measured at zero width, i.e. silently disappear rather than ellipsise.

Giving both children weights makes the split proportional and bounded regardless of translation length, e.g. Modifier.weight(1f, fill = false) on the pill Box and Modifier.weight(1f) on the Column. A short comment on where 0.82f/0.68f come from (gradient stop / artwork start) would also help the next reader.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Re-review after 56a2e85 + 6c27451

  • Diff the new commits vs. my last review
  • Check status of previously-raised findings
  • Review the new banner layout / sponsored label changes
  • Post updated review

The push is a focused pair of changes: the create-account banner is re-laid-out with left artwork on a black card (plus a re-exported asset), and the sponsored tile label drops to caption2. Both of geekygecko's review comments are now addressed. One new (minor) issue, and my localisation note from the last round is only half-fixed.


✅ Resolved in this push

Item Fix
Banner text overlapping artwork (geekygecko) CreateAccount now puts artwork at CenterStart with the text Row aligned CenterEnd at 0.72f, on a black card; asset re-exported with the fade baked in (TvBannerRow.kt:67-110)
Sponsored label vertical padding (geekygecko) bottom = 9.dp matching the artwork's vertical = 9.dp, and now caption2 (TvPodcastTile.kt:50-52)
Banner layout duplication Extracted BannerText / BannerActionPill — good call, the two orderings read much better than the previous inline block

🟡 New: BackgroundLift() at line 69 is dead paint

TvBannerRow.kt:69 — the first BackgroundLift() is never visible in either branch:

  • CreateAccount: tv_banner_create_account.webp is lossy VP8 with no alpha channel (2796×1224, fully opaque). At requiredHeight(240.dp) + FillHeight it draws ~548dp wide on a ~860dp banner ≈ 63% of the width, aligned CenterStart. The lift spans 0f → 0.55f — entirely behind opaque pixels.
  • DiscoverMore: the mask below paints opaque black from 0f to 0.82f, covering the whole lift, and then BackgroundLift() is drawn again on top at line 92 — that second one is the visible one.

So only line 92 has any effect; line 69 is a full-size gradient draw per frame for nothing. Drop it, or move it above the Image if it was meant to lift the area under the artwork. (inline) · Fix this →

Related: CreateAccount has hasArtworkMask = false, so text legibility depends entirely on the fade baked into the re-exported asset. A one-line comment on hasArtworkMask saying that would stop a future asset re-crop silently reintroducing the overlap geekygecko caught.

🟡 Long-translation squeeze is only half-fixed

TvBannerRow.kt:103-109BannerText now has weight(1f, fill = false), but BannerActionPill is still un-weighted. In a Row, un-weighted children are measured first against the full remaining width, so a long localised "Create a free account" (de/fr/ru commonly +30-40%) can still take the whole 72%/82% and leave the weighted text Column at ~0dp — the pill's Text has no maxLines, so it wraps to two or three lines rather than yielding. Adding Modifier.weight(1f, fill = false) to the pill's Box too makes the split proportional and bounded either way. (Arrangement.spacedBy(48.dp) replacing the fixed Spacer is a genuine improvement — that 48dp is now deducted before distribution.)

Smaller

  • containerColor/focusedContainerColor are both hardcoded Color.Black (:59-60), so the banner is the one TV surface that bypasses tvColors and focus no longer changes the card colour — it's conveyed by the 1.05× scale and the pill only. Presumably deliberate to match the artwork's baked-in black; worth a comment if so, since the next person theming the TV app will read it as an oversight.

Carry-overs from the previous review (unchanged, your call)

  • tv_search_searching is still unused — added at strings.xml:260 by this PR, no references anywhere in the repo. It'll be sent to GlotPress and translated regardless.
  • Category open still costs an extra discover-feed request (TvDiscoverFeedLoader.kt:62), awaited alongside the podcast list, so it gates first paint.
  • Search-tab category pills are still onClick = {} (TvSearchScreen.kt:257) while the identical Home pills now open a grid.
  • TvDiscoverRow.Banner.title is populated from row.title and never rendered.
  • loadSponsoredInsertions passes row.authenticated where the phone passes false unconditionally; and the CHANGELOG checkbox is still unticked.

Nothing here blocks merge — the dead BackgroundLift() is a one-line delete and the rest are judgement calls. Gradle is blocked in this environment, so the build, spotlessCheck and the unit tests are unverified here; findings are from reading the diff, the asset headers, and the surrounding TV code.
· branch feat/tv-home-discover-parity

) {
val artworkAlignment = if (banner.artworkAtStart) Alignment.CenterStart else Alignment.CenterEnd
Box(modifier = Modifier.fillMaxSize().clipToBounds()) {
BackgroundLift()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This first BackgroundLift() never ends up visible in either branch, so it's a full-size gradient draw per frame for nothing:

  • CreateAccount — the artwork is aligned CenterStart and tv_banner_create_account.webp is lossy VP8 with no alpha channel (fully opaque), drawn ~548dp wide (240dp × 2796/1224) on a ~860dp banner ≈ 63% of the width. The lift only spans 0f → 0.55f, so it's entirely behind opaque pixels.
  • DiscoverMore — the mask below paints opaque black from 0f to 0.82f, which covers the whole 0f → 0.55f lift, and then BackgroundLift() is drawn again on top at line 92 (that one is the visible one).

So the effect only comes from line 92. Either drop line 69, or if it was meant to lift the area under a semi-transparent artwork, it needs to move above the Image for the CreateAccount case.

Related: CreateAccount has hasArtworkMask = false, so text legibility on the left→right transition depends entirely on the fade baked into the asset (which is what the re-export in 6c27451 did). Worth a comment on hasArtworkMask saying so — otherwise a future asset re-crop silently puts title text over the collage again.

@sztomek
sztomek force-pushed the feat/tv-home-discover-parity branch from 6c27451 to ce416b5 Compare August 14, 2026 10:29
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Re-review after ce416b5

  • Diff the new push vs. my last review
  • Check status of previously-raised findings
  • Review changed code for fresh issues
  • Post updated review

This push is scoped to one file plus one asset: the banner card is now flat black in both focus states, artworkHeight / contentWidthFraction / hasArtworkMask are split per banner, and tv_banner_create_account.webp was re-exported. The extraction of BannerText / BannerActionPill / BackgroundLift reads well, and Arrangement.spacedBy(48.dp) is a genuine improvement over the fixed Spacer. One new finding on the asset itself, plus one carry-over that is still only half-fixed.

Correction to my previous round: I described the CreateAccount artwork as CenterStart-aligned and the first BackgroundLift() as dead in that branch. Both are wrong — the artwork has been CenterEnd throughout, and with the current geometry that lift is visible for CreateAccount.


🟠 New: the create-account artwork is horizontally mirrored

tv_banner_create_account.webp (re-exported in this commit, 29,858 → 84,958 bytes) is flipped. Everything inside the phone mockup reads backwards — "Good Times", "Play all", "Smart Rules", "Search", "24 episodes · 19h 48m", the 10:41 / LTE status bar — the play glyph in the "Play all" button points left (), the podcast covers in the grid (GOOD HANG, Bald & Beautiful, Dish, The Book Review) are mirrored, and the phone's volume buttons sit on the right-hand edge.

It isn't a rendering artefact: tv_banner_discover_more.webp in the same folder reads correctly. My guess is the frame was flipped to get the phone onto the end edge rather than re-composed. At requiredHeight(150.dp) it draws ~343dp wide (2796×1224 → 2.28:1), so the phone is roughly the right 155dp — small, but the reversed button label and cover art are noticeable at TV distance. (inline) · Fix this →

🟡 Long-translation squeeze is still half-fixed

TvBannerRow.kt:94-105BannerText is weighted, BannerActionPill isn't. Un-weighted Row children are measured first against the full remaining width, so a long localised CTA can still consume the whole 0.68f/0.82f and leave the text Column at ~0dp. The pill's Text also has no maxLines, so it wraps to two or three lines and grows the pill inside the 132dp card rather than yielding. weight(1f, fill = false) on the pill plus weight(2f) on the text bounds both sides while keeping the text the larger share; maxLines = 1 + ellipsis on the pill text makes the overflow graceful. (inline)

🟡 BackgroundLift() is still drawn twice for DiscoverMore

:69 is now live for CreateAccount, but in the DiscoverMore branch it sits entirely under the mask's opaque black (0f → 0.82f) and is repainted at :92 — two full-size gradient draws per frame where one does. Hoisting a single call below the if block is equivalent in both branches at the current sizes (the artwork starts around 0.60f, past the lift's 0.55f transparent stop). Suggestion diff in the inline comment.

Related: hasArtworkMask = false for CreateAccount only works because that asset is composited on opaque black, and the hardcoded Color.Black at :60-61 is the one TV surface bypassing tvColors for the same reason. Neither is obvious from the code — two short comments would stop a future asset re-crop silently reintroducing the text-over-artwork overlap @geekygecko caught.


Carry-overs (unchanged, your call)

  • tv_search_searching is unused — added at strings.xml:263, no references anywhere in the repo; it ships to GlotPress and gets translated regardless.
  • Category open costs a second discover-feed fetchloadCategorySponsoredPodcasts (TvDiscoverFeedLoader.kt:62) re-requests the whole home feed and is awaited alongside the podcast list, so it gates the category screen's first paint.
  • Search-tab category pills are still onClick = {} (TvSearchScreen.kt:257) while the identical Home pills now open a grid.
  • TvDiscoverRow.Banner.title is populated from row.title and never rendered.
  • loadSponsoredInsertions passes row.authenticated where the phone passes false unconditionally; the CHANGELOG checkbox is still unticked; and the sponsored tile's inset artwork (TvPodcastTile.kt:45) still makes it narrower and taller than its neighbours in a grid row — ignore if that matches Figma.

Everything I flagged as blocking in earlier rounds stays fixed. The mirrored asset is the only thing here I'd want changed before merge; the rest are judgement calls.

Gradle is blocked in this environment, so the build, spotlessCheck and the unit tests are unverified — findings come from reading the diff, decoding the two banner assets, and the surrounding TV code.
· branch feat/tv-home-discover-parity


@DrawableRes
private fun TvDiscoverBanner.artwork(): Int = when (this) {
TvDiscoverBanner.CreateAccount -> IR.drawable.tv_banner_create_account

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 tv_banner_create_account.webp re-exported in ce416b5 is horizontally mirrored. Everything inside the phone mockup reads backwards:

  • the screen labels — "Good Times", "Play all", "Smart Rules", "Search", "24 episodes · 19h 48m", and the status bar 10:41 / LTE
  • the play glyph in the "Play all" button points left ()
  • the podcast covers in the grid (GOOD HANG, Bald & Beautiful, Dish, The Book Review, Waitrose) are all flipped
  • the phone's volume/action buttons sit on the right-hand edge instead of the left

It isn't the renderer: tv_banner_discover_more.webp in the same folder reads correctly. Given the commit moved the artwork to the end edge and the file grew 29,858 → 84,958 bytes, my guess is the frame was flipped to get the phone onto the right rather than re-composed.

At requiredHeight(150.dp) the artwork draws ~343dp wide (2796×1224 → 2.28:1), of which the phone is roughly the right 155dp — small, but the reversed "Play all" and the backwards cover art are visible at TV viewing distance on a 1080p panel. Worth re-exporting with the mockup composed on the right rather than mirrored.

Comment on lines +68 to +93
Box(modifier = Modifier.fillMaxSize().clipToBounds()) {
BackgroundLift()
Image(
painter = painterResource(banner.artwork()),
contentDescription = null,
contentScale = ContentScale.FillHeight,
alignment = Alignment.CenterEnd,
modifier = Modifier
.align(Alignment.CenterEnd)
.requiredHeight(banner.artworkHeight),
)
if (banner.hasArtworkMask) {
// Opaque black over the text side so the bright collage only shows on the end edge.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
0f to Color.Black,
0.82f to Color.Black,
1f to Color.Transparent,
),
),
)
BackgroundLift()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Moving the artwork onto black fixed the case I flagged last round — with the image at CenterEnd this first BackgroundLift() is now genuinely visible in the CreateAccount branch (the artwork covers roughly 0.60f → 1f of an ~864dp banner, and the lift is transparent past 0.55f).

It's still redundant in the DiscoverMore branch though: the mask below paints opaque black across 0f → 0.82f, which fully covers the 0f → 0.55f lift, and then line 92 repaints it on top. So that branch does two full-size gradient draws per frame where one suffices. Hoisting a single call below the if gives the same result in both branches — for CreateAccount the lift would then paint over the artwork, but it's already fully transparent by the point the artwork starts:

Suggested change
Box(modifier = Modifier.fillMaxSize().clipToBounds()) {
BackgroundLift()
Image(
painter = painterResource(banner.artwork()),
contentDescription = null,
contentScale = ContentScale.FillHeight,
alignment = Alignment.CenterEnd,
modifier = Modifier
.align(Alignment.CenterEnd)
.requiredHeight(banner.artworkHeight),
)
if (banner.hasArtworkMask) {
// Opaque black over the text side so the bright collage only shows on the end edge.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
0f to Color.Black,
0.82f to Color.Black,
1f to Color.Transparent,
),
),
)
BackgroundLift()
}
Box(modifier = Modifier.fillMaxSize().clipToBounds()) {
Image(
painter = painterResource(banner.artwork()),
contentDescription = null,
contentScale = ContentScale.FillHeight,
alignment = Alignment.CenterEnd,
modifier = Modifier
.align(Alignment.CenterEnd)
.requiredHeight(banner.artworkHeight),
)
if (banner.hasArtworkMask) {
// Opaque black over the text side so the bright collage only shows on the end edge.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
0f to Color.Black,
0.82f to Color.Black,
1f to Color.Transparent,
),
),
)
}
BackgroundLift()

Separately, worth a one-line comment on hasArtworkMask (line 179) noting that CreateAccount needs no mask because its artwork is composited on opaque black — otherwise a future re-export with a transparent or light background silently puts the title text back over the mockup, which is the overlap @geekygecko caught. Same for the hardcoded Color.Black container at lines 60-61: it's the one TV surface bypassing tvColors, and the reason (both assets are baked on black) isn't obvious from the code.

Comment on lines +94 to +105
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(48.dp),
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.fillMaxWidth(banner.contentWidthFraction)
.padding(horizontal = 48.dp),
) {
BannerActionPill(banner, isFocused)
BannerText(banner, modifier = Modifier.weight(1f, fill = 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.

Arrangement.spacedBy(48.dp) replacing the fixed Spacer is a real improvement — the 48dp now comes off the top before distribution. But the weighting is still one-sided: BannerText is weighted, BannerActionPill isn't, and in a Row un-weighted children are measured first against the full remaining width. So a long localised CTA can still take the whole 0.68f/0.82f and leave the text Column at ~0dp. The pill's Text (line 153) also has no maxLines, so instead of yielding it wraps to two or three lines and grows the pill vertically inside the 132dp card.

Weighting both bounds it either way, e.g. Modifier.weight(1f, fill = false) on the pill Box and Modifier.weight(2f) on the text — the asymmetric weights keep the text the bigger share while capping the pill at a third, since fill = false means the leftover isn't redistributed. maxLines = 1 + TextOverflow.Ellipsis on the pill's Text would make the failure mode graceful rather than a reflowing pill.

"Create a free account" is the risky one — de/fr/ru commonly run 30-40% longer.

@sztomek
sztomek force-pushed the feat/tv-home-discover-parity branch from ce416b5 to f4ee971 Compare August 14, 2026 10:34
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Re-review after f4ee971 (banners on black, right-aligned artwork)

  • Diff the new commit vs. my last review
  • Check status of previously-raised findings
  • Review the new banner layout and the re-exported asset
  • Post updated review

The refactor in this push is a clear improvement — BannerText / BannerActionPill extracted, Arrangement.spacedBy(48.dp) replacing the fixed Spacer (the 48dp is now deducted before distribution), and per-banner artworkHeight / contentWidthFraction instead of the one-size-fits-all constants. Both of @geekygecko's review comments are addressed. One finding here is worth fixing before merge; the rest are minor.


🔴 The re-exported tv_banner_create_account.webp is horizontally mirrored

I opened the asset — everything inside the phone mockup reads backwards:

  • the screen copy: "Good Times", "Play all", "Smart Rules", "Search", "24 episodes · 19h 48m", and the status bar 10:41 / LTE
  • the play glyph in the "Play all" button points left ()
  • the cover art in the grid (GOOD HANG, Bald & Beautiful, Dish, The Book Review, Waitrose) is all flipped
  • the phone's volume / action buttons sit on the right-hand edge instead of the left

It isn't the renderer — tv_banner_discover_more.webp in the same folder reads correctly. Given the commit moved the artwork to the end edge and the file grew 29,858 → 84,958 bytes, my guess is the frame was flipped to get the phone onto the right rather than re-composed.

At requiredHeight(150.dp) the artwork draws ~343dp wide (2796×1224 → 2.28:1), of which the phone is roughly the right ~200dp. Small, but the reversed "Play all" and the backwards cover art are legible at TV viewing distance on a 1080p panel. Worth re-exporting with the mockup composed on the right rather than mirrored. (inline)

🟡 BackgroundLift() is drawn twice in the DiscoverMore branch

TvBannerRow.kt:69 + :92 — moving the artwork onto black fixed the case I flagged last round: with the image at CenterEnd the line-69 lift is now visible for CreateAccount (the artwork covers ~0.60f → 1f of an ~864dp banner, and the lift is transparent past 0.55f).

It's still redundant for DiscoverMore though: the mask paints opaque black across 0f → 0.82f, fully covering the 0f → 0.55f lift, and then line 92 repaints it on top. That branch does two full-size gradient draws per frame where one suffices. Hoisting a single call to after the if gives the same result in both branches — for CreateAccount the lift would then paint over the artwork, but it's already fully transparent by the point the artwork starts. (inline) · Fix this →

🟡 Long-translation squeeze is still one-sided

TvBannerRow.kt:103-104BannerText is weighted, BannerActionPill isn't, and in a Row un-weighted children are measured first against the full remaining width. So a long localised CTA can still consume the whole 0.68f/0.82f and leave the text Column at ~0dp. The pill's Text (:153) also has no maxLines, so rather than yielding it wraps to two or three lines and grows the pill inside the 132dp card.

Weighting both bounds it either way — e.g. Modifier.weight(1f, fill = false) on the pill Box and Modifier.weight(2f) on the text (fill = false means the leftover isn't redistributed, so the asymmetric weights keep the text the bigger share while capping the pill). maxLines = 1 + TextOverflow.Ellipsis on the pill would make the failure mode graceful. "Create a free account" is the risky one — de/fr/ru commonly run 30-40% longer. (inline)

Smaller

  • Undocumented invariants around the black card. containerColor / focusedContainerColor are both hardcoded Color.Black (:60-61) — the one TV surface that bypasses tvColors, so focus no longer changes the card colour (it's carried by the 1.05× scale and the pill). And hasArtworkMask = false for CreateAccount (:179) means its text legibility depends entirely on the asset being composited on opaque black. Both are correct given the current assets, but neither reason is visible from the code — a one-line comment on each would stop a future re-export silently reintroducing the overlap @geekygecko caught.
  • Sponsored tile geometry (TvPodcastTile.kt:41-46): unchanged from my earlier note — imageModifier.padding(horizontal = 18.dp, vertical = 9.dp) shrinks the artwork inside the tile bounds, so in the 6-column category grid the sponsored tile's artwork is visibly smaller than its neighbours. The Apple TV screenshot @geekygecko posted insets it the same way, so this looks intentional; flagging only so it's a decision rather than an accident.

Carry-overs (unchanged, your call)

  • tv_search_searching is still unused — added at strings.xml:260, grep finds no references anywhere in the repo. It'll go to GlotPress and get translated into every locale regardless. (inline)
  • Opening a category costs a second full discover-feed fetch. loadCategorySponsoredPodcasts (TvDiscoverFeedLoader.kt:62) calls getHomeDiscoverFeed again, and ListRepository does no caching. It's awaited alongside the podcast list (:57), so it gates the category screen's first paint on a round-trip for a feed Home already parsed.
  • Search-tab category pills are still onClick = {} (TvSearchScreen.kt:255) while the visually identical Home pills now open a grid. TvCategoryPodcastsScreen exists, so wiring it is small — or a TODO if it's a deliberate follow-up.
  • TvDiscoverRow.Banner.title is populated from row.title (:107) and never rendered; the UI uses string resources. Right call for localisation, but the field is dead weight.
  • loadSponsoredInsertions passes row.authenticated where the phone (DiscoverViewModel.loadCarouselSponsoredPodcasts) passes false unconditionally — worth confirming that's intentional. And the CHANGELOG checkbox is still unticked.

Everything I flagged as blocking in the earlier rounds stays fixed — insertSponsored dedup, minOf(SPONSORED_CATEGORY_POSITION, rest.size), the auth filter on category sponsored rows, the safe cast, and the includeHomeSections = false test. The nested-overlay focus handling still reads correctly: the category overlay's BackHandler stays registered under the podcast one, and LIFO dispatch means Back closes the podcast first, with categoryRestoreTrigger handing focus back to the grid.

Gradle is blocked in this environment, so the build, spotlessCheck and the unit tests are unverified here — findings come from reading the diff, decoding the two banner assets, and the surrounding TV/discover code.
· branch feat/tv-home-discover-parity

@sztomek
sztomek merged commit 9fcd2b2 into main Aug 14, 2026
23 checks passed
@sztomek
sztomek deleted the feat/tv-home-discover-parity branch August 14, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants