Skip to content

[TV] Now Playing analytics - #5770

Merged
sztomek merged 6 commits into
mainfrom
feat/tv-analytics-now-playing
Aug 27, 2026
Merged

[TV] Now Playing analytics#5770
sztomek merged 6 commits into
mainfrom
feat/tv-analytics-now-playing

Conversation

@sztomek

@sztomek sztomek commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Description

Brings the Android TV Now Playing / Player screen to analytics parity with the Apple TV app (Pocket Casts TV App/UI/Player/NowPlayingView.swift). The TV player previously fired no screen analytics.

Apple TV is the source of truth for which events fire and when; each maps to the existing generated EventHorizon Kotlin class (no schema change — all events already include the android platform).

Stacked on #5769 ([TV] Search analytics) — review/merge that first.

Events

Event Properties Trigger
player_shown The player (Loaded state) becomes visible
player_dismissed The player goes away (switch tabs / queue empties)
playback_effect_speed_changed speed, settings, source, content_type A playback speed is selected
playback_effect_volume_boost_toggled enabled, settings, source, content_type Volume boost is toggled
playback_effect_trim_silence_amount_changed amount, settings, source, content_type A trim-silence mode is selected (incl. Off)
  • player_shown/player_dismissed are bare events, wired via a DisposableEffect in TvNowPlayingScreen keyed on the Loaded state (placed above the podcast-details early-return so opening a podcast from the player does not over-fire). Injects EventHorizon into TvNowPlayingViewModel.
  • The three effect events reuse the shared playbackManager.trackPlaybackEvent(SourceView.PLAYER_PLAYBACK_EFFECTS) helper — the same path the phone's EffectsFragment uses — which supplies source + content_type (audio/video); settings (global vs local) is derived from podcast.overrideGlobalEffects. They fire from the single updateEffects choke point, only after the effect is actually applied.

Fixes PCDROID-725 https://linear.app/a8c/issue/PCDROID-725/now-playing-analytics

Notable parity decisions

  • playback_effect_trim_silence_toggled is deliberately not fired — the TV UI collapses on/off and amount into one trim-mode list, and Apple TV does the same: its NowPlayingView.swift menu (built from TrimSilenceAmount.allCases, which includes off) only ever fires trimSilenceAmountChanged, never trimSilenceToggled. TV enable/disable therefore shows up as amount_changed with amount=off/non-off, on both platforms — queries built on the toggled event won't see TV traffic.

Testing Instructions

  1. Build & install the TV debug app: ./gradlew :tv:installDebug.
  2. Play an episode to open Now Playing; watch logcat (adb logcat -s Analytics) for the 🔵 events (debug builds log every EventHorizon event).
  3. Verify:
    • Opening the player logs player_shown; leaving it (switch tabs) logs player_dismissed.
    • Opening a podcast from the player and coming back does not re-fire shown/dismissed.
    • Changing speed logs playback_effect_speed_changed; toggling volume boost logs playback_effect_volume_boost_toggled; picking a trim mode logs playback_effect_trim_silence_amount_changed — each with source=player_playback_effects, the right content_type, and settings=global (or local for a podcast with custom effects).

Screenshots or Screencast

logcat_nowplaying_analytics

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md — n/a, analytics only
  • Ensure the linter passes (./gradlew spotlessApply)
  • I have considered whether it makes sense to add tests for my changes — added VM unit tests for every new event
  • All strings that need to be localized are in modules/services/localizationn/a, no new strings
  • Any jetpack compose components I added or changed are covered by compose previews — n/a, no new composables
  • I have updated (or requested that someone edit) the Event Horizon schema — n/a, all events already include the android platform

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewing PR #5770

  • Gather context (diff vs origin/feat/tv-analytics-search)
  • Review TvNowPlayingScreen.kt (DisposableEffect lifecycle)
  • Review TvNowPlayingViewModel.kt (tracking wiring)
  • Review tests
  • Post review

View job runfeat/tv-analytics-now-playing

@sztomek sztomek added [Type] Feature Adding a new feature. [Area] Analytics Analytics related issues [Area] Playback Episode playback issue [Area] TV labels Aug 25, 2026
@sztomek sztomek added this to the 8.20 milestone Aug 25, 2026
@sztomek
sztomek marked this pull request as ready for review August 25, 2026 15:54
@sztomek
sztomek requested a review from a team as a code owner August 25, 2026 15:54
@sztomek
sztomek requested review from geekygecko and removed request for a team August 25, 2026 15:54
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

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


Review: PR #5770 — [TV] Now Playing analytics

  • Gather context (diff vs origin/feat/tv-analytics-search)
  • Review TvNowPlayingScreen.kt (DisposableEffect lifecycle)
  • Review TvNowPlayingViewModel.kt (tracking wiring)
  • Review tests
  • Post review

Solid, well-scoped analytics PR. No correctness bugs found — I traced the two areas most likely to break and both hold up. Everything below is a parity question, a readability suggestion, or a test-coverage note. Details are in 5 inline comments.

What I verified

The DisposableEffect shown/dismissed pairing is correct. onDispose closes over the isPlayerVisible value from the composition that created the effect, so a true → false key change disposes with the captured true and emits exactly one player_dismissed; the new false-keyed effect is a no-op. Placement above the podcast-details early return (TvNowPlayingScreen.kt:124) does prevent over-firing as described.

I also checked the hosting for spurious pairs: tabs render inside a Crossfade (TvScaffold.kt:172). Rapidly switching away and back before the 300 ms animation completes collapses currentlyVisible back to the single retained entry rather than disposing and recomposing, so no phantom dismissed/shown pair. One ordering consequence worth knowing: the outgoing player stays composed for the animation, so switching tabs emits the destination's *_shown before player_dismissed.

The effect events are wired at the right point. track() fires inside effectsMutex, after the persist and updatePlayerEffects, and after the stale-episode return@withLock — so a skipped write tracks nothing (covered by the new test). The overridingPodcast refactor (?.takeIf { it.overrideGlobalEffects }) is behaviour-preserving and correctly doubles as the settings source; SettingType.Local/Global matches the phone's PlaybackEffectsSettingsTab mapping (PlayerViewModel.kt:844-851).

No no-op events from the UI either — TvPlayerEffectsControls already guards if (option != currentSpeed) / if (mode != trimMode), and volume boost toggles !isVolumeBoosted. Speed values come from (5..30).map { it / 10.0 }, so they're already 1-decimal doubles and don't need the phone's roundedSpeed().

Findings

# Severity Where Issue
1 Question TvNowPlayingViewModel.kt:120 playback_effect_trim_silence_toggled is never fired. The phone fires it alongside amount_changed; TV's single mode list collapses both, so TV won't appear in trim enable/disable metrics. Correct if Apple TV behaves the same — worth confirming.
2 Suggestion TvNowPlayingViewModel.kt:111-142 Three levels of lambda indirection to build one event. The phone's flat trackPlaybackEffectsEvent(event: (SourceView, PlaybackContentType, SettingType) -> Trackable) signature (PlayerViewModel.kt:796) would collapse it, delete trackEffectEvent, and get line 124 (152 chars) back under 120.
3 Nit TvNowPlayingViewModel.kt:151 track: (SettingType) -> Unit = {} — the default is unused by all three call sites; making it required forces future callers to state their analytics intent.
4 Nit TvNowPlayingScreen.kt:103-113 The load-bearing "must stay above the early return" constraint is undocumented, and the double if (isPlayerVisible) reads worse than a conditional DisposableEffect(Unit) (the form used at TvPodcastDetailsScreen.kt:176). Suggestion inline.
5 Test TvNowPlayingViewModelTest.kt:370 The riskiest logic — the DisposableEffect lifecycle, including the "opening a podcast doesn't re-fire" invariant — is untested; the two new tests only assert one-line delegation.
6 Test TvNowPlayingViewModelTest.kt:71-80 The stub hardcodes PlaybackContentType.Audio, making the contentType assertions tautological (unavoidable, but easy to misread as video coverage). Also doAnswer{}.whenever(...) + @Before would match the file's style better than whenever on a Unit function inside init.

None of these block merge. #1 is the only one that could affect the data itself; the rest are polish.

Not verified

I could not run the test suite or spotlessCheck — Gradle invocations require approval in this environment. Worth confirming CI is green, particularly for the whenever void-stubbing in the test init block. Note that lines over 120 chars already exist in this package (TvPlayerEffectsControls.kt:55, TvNowPlayingWaveform.kt:70), so line 124 is unlikely to fail the linter — the suggestion there is readability only.
feat/tv-analytics-now-playing

Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingScreen.kt Outdated
@sztomek
sztomek force-pushed the feat/tv-analytics-search branch 2 times, most recently from c071478 to b06170b Compare August 27, 2026 07:14
@sztomek
sztomek force-pushed the feat/tv-analytics-now-playing branch from 0d2e801 to 5939bc3 Compare August 27, 2026 07:17
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

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


Review: new commit since last review (5939bc3)

The follow-up commit addresses findings 2, 3, 4 and 6 — updateEffects now takes a flat event: (SettingType, SourceViewType, PlaybackContentType) -> Trackable with no default, trackEffectEvent is gone, and the conditional DisposableEffect(Unit) is behaviourally identical to the previous keyed form (enter on Loaded, onDisposeplayer_dismissed) with the load-bearing placement now documented. The test stub switch to doAnswer{}.whenever(...) is the correct API for a void method and the PlaybackContentType.Audio caveat is noted in a comment. No new findings.

Non-blocking

  • TvNowPlayingViewModel.kt:118 — still open from the previous round: playback_effect_trim_silence_toggled is never fired, so TV won't show up in trim enable/disable metrics built on that event. Fine if Apple TV behaves the same; see the existing thread on that line.
    feat/tv-analytics-now-playing

@sztomek
sztomek force-pushed the feat/tv-analytics-search branch from b06170b to 4b494a4 Compare August 27, 2026 07:23
@sztomek
sztomek force-pushed the feat/tv-analytics-now-playing branch from 5939bc3 to e814bc2 Compare August 27, 2026 07:23
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

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


Review: no new content since last review

This synchronize is a rebase — the commits were rewritten (d8306a8, e814bc2) but the resulting tree is byte-identical to the state I reviewed on Aug 27 (all three file blobs match: 61dc48d, 619d343, 5e81693). Nothing new to review.

Non-blocking

  • tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingViewModel.kt:118 — still open: playback_effect_trim_silence_toggled is never fired, so TV won't appear in trim enable/disable metrics built on that event. See the existing thread on that line.
    feat/tv-analytics-now-playing

Base automatically changed from feat/tv-analytics-search to main August 27, 2026 08:03
@sztomek
sztomek force-pushed the feat/tv-analytics-now-playing branch from e814bc2 to 6fc31e8 Compare August 27, 2026 08:22
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

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


Review: new commit since last review (6fc31e8)

The only new content is TvDiscoverFeedAnalyticsTest.kt — 16 tests exercising TvDiscoverFeedAnalytics directly. I checked each assertion against TvDiscoverFeedAnalytics.kt and the fixtures against TvDiscoverModels.kt / TvOpenedCategory.kt / DiscoverCategory: constructor args, the localRowIds and banner/categories null-listId paths, the featured-vs-sponsored else if branch, and the region/id/name values in both DiscoverAdCategoryTappedEvent call sites all match the implementation, and the mock setup mirrors the existing TvHomeViewModelTest/TvSearchViewModelTest style. Nothing blocking.

Non-blocking

  • tv/src/test/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedAnalyticsTest.kt — this is discover coverage in a Now Playing PR, and it re-tests what TvHomeViewModelTest.kt:869-990 already covers through the ViewModel (featured tap, sponsored ad event, category-sponsored, local row, episode play, impression, banner/categories no-impression, banner tap, pill tap). Direct unit tests on the shared class are the better home for these, so consider deleting the now-duplicated ViewModel tests rather than maintaining both.
  • tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingViewModel.kt:118 — resolved: @sztomek confirmed Apple TV is the reference and the omission of playback_effect_trim_silence_toggled is deliberate.
    feat/tv-analytics-now-playing

@sztomek
sztomek merged commit 7685e28 into main Aug 27, 2026
19 checks passed
@sztomek
sztomek deleted the feat/tv-analytics-now-playing branch August 27, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] Analytics Analytics related issues [Area] Playback Episode playback issue [Area] TV [Type] Feature Adding a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants