Skip to content

[TV] Episode actions & details analytics - #5741

Open
sztomek wants to merge 2 commits into
mainfrom
feat/tv-analytics-episode-actions
Open

[TV] Episode actions & details analytics#5741
sztomek wants to merge 2 commits into
mainfrom
feat/tv-analytics-episode-actions

Conversation

@sztomek

@sztomek sztomek commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

Fifth screen-area of the Android TV → Apple TV analytics parity program (EventHorizon only): the shared episode options modal and episode details modal. Stacked on the Up Next analytics PR. Verified event-by-event against the tvOS app.

Event EventHorizon class Properties Fires when
episode_actions_shown EpisodeActionsShownEvent source the episode options modal (TvEpisodeActionsModal) appears
episode_detail_shown EpisodeDetailShownEvent source the episode details / show-notes modal (TvEpisodeInfoModal) appears

tvOS fires these bare (EpisodeRow.swift:292 .onAppear and EpisodeShowNotesView.swift:26 .task, no properties), but both Android EventHorizon classes require a source: EpisodeViewSourceType. We map it from the surface the modal is shown on:

Surface source
Podcast details podcast_screen
Playlist filters
Up Next up_next
Now Playing now_playing
Search search

Wired the established way: EventHorizon injected into TvEpisodeActionsViewModel (new trackActionsShown(source) on the TvEpisodeActions interface) and TvEpisodeInfoViewModel (new trackDetailShown(source)); each fires once from the modal's LaunchedEffect(episode.uuid) on appear. The source mapping lives in one place — the new episodeViewSource column on the TvEpisodeActionContext enum — and both modals derive it from the TvEpisodeActionContext passed at each of the 5 call sites, so the two events can't drift apart.

Two decisions worth calling out

1. episode_actions_shown from Search — intentionally fired (diverges from Apple TV).
Apple TV's Search/Discover results use a reduced menu (DiscoveryEpisodeMenuButtons) that has no tracking, so Apple TV does not emit episode_actions_shown there. Android TV reuses the full TvEpisodeActionsModal for search results, and we chose to fire the event from Search too, stamped source=search. This is a deliberate divergence for completeness of Android's own analytics — noted here so it's not mistaken for an accidental over-fire.

2. The source mapping lives only in the TvEpisodeActionContext enum.
Each context already carried a SourceView (used for playback actions); this PR adds an episodeViewSource: EpisodeViewSourceType column alongside it for the two new shown-events. Both the actions modal and the details modal read their source from the same TvEpisodeActionContext, so a screen can't report episode_actions_shown and episode_detail_shown with mismatched sources, and a future surface only has to add one enum entry. No existing playback source changes here — Search already used TvEpisodeActionContext.SearchResults (SourceView.SEARCH_RESULTS) on the base branch.

Not in scope: up_next_queue_reordered

Apple TV fires up_next_queue_reordered from the shared Play Next / Play Last helper, but only when the episode is already in Up Next (a move). Android's PlaybackManager.playNext/playLast instead unconditionally fire a different event (episode_added_to_up_next) with no move-detection, and the reorder event is fired nowhere in the Android codebase. Layering iOS's semantics on top would double-count and needs a slots value iOS never sends — so it's deliberately deferred as its own decision.

Fixes PCDROID-724 https://linear.app/a8c/issue/PCDROID-724/podcast-details-analytics

Testing Instructions

  1. ./gradlew :tv:installDebug.
  2. From Podcast details / Playlist / Up Next / Now Playing / Search, open an episode's options menu → confirm one episode_actions_shown with the matching source in adb logcat.
  3. From the options menu tap Episode details → confirm one episode_detail_shown with the same source.

Screenshots or Screencast

SCR-20260814-qgni

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
  • All strings that need to be localized are in localization — N/A
  • Any jetpack compose components I added or changed are covered by compose previews — existing previews updated (no-op tracking)
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics — existing events, no schema change needed

@sztomek
sztomek force-pushed the feat/tv-analytics-up-next branch from 1f4ed8b to 39b7a03 Compare August 14, 2026 12:19
@sztomek
sztomek force-pushed the feat/tv-analytics-episode-actions branch from a62b7eb to a6a530a Compare August 14, 2026 12:22
@sztomek
sztomek force-pushed the feat/tv-analytics-up-next branch from 39b7a03 to f0be555 Compare August 14, 2026 12:50
@sztomek
sztomek force-pushed the feat/tv-analytics-episode-actions branch from a6a530a to 6af74bc Compare August 14, 2026 12:51
@sztomek sztomek added [Type] Feature Adding a new feature. [Area] Analytics Analytics related issues [Area] TV labels Aug 14, 2026
@sztomek sztomek added this to the 8.19 milestone Aug 14, 2026
@sztomek
sztomek marked this pull request as ready for review August 14, 2026 16:37
@sztomek
sztomek requested a review from a team as a code owner August 14, 2026 16:37
@sztomek
sztomek requested review from geekygecko and removed request for a team August 14, 2026 16:37
@sztomek

sztomek commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@claude please review this

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Code review

  • Read the diff against feat/tv-analytics-up-next
  • Reviewed modal lifecycle & event-firing semantics
  • Verified the source mapping at all 5 call sites
  • Checked tests, previews and the no-op path
  • Posted inline comments

Overall this is clean and follows the conventions the earlier PRs in this series established (LaunchedEffect { viewModel.onShown() }, EventHorizon injected into the ViewModel, event equality asserted with Mockito). No correctness bugs, no security or performance concerns — two track calls on already-injected singletons. Nothing here blocks merge; the one thing I'd actually fix is the PR description.

Findings

1. PR description "decision 2" doesn't match the diffinline
The description says Search previously borrowed TvEpisodeActionContext.PodcastDetails, that a dedicated Search context with SourceView.SEARCH was added, and that existing playback events are thereby corrected from source=podcast_screen to source=search. None of that is in this diff: SearchResults(SourceView.SEARCH_RESULTS) already existed on the base branch, TvSearchScreen.kt:156 already used it, and TvEpisodeActionTypeTest.kt:29 still asserts SEARCH_RESULTS. Only the new episodeViewSource column was added, so no existing SourceView behaviour changed. Since the description is what the analytics reviewers will read, it's worth correcting (or landing the change if it was actually intended).

2. source is plumbed two different waysinline
TvEpisodeActionsModal derives the source from actionContext.episodeViewSource (one mapping table, all call sites covered automatically), but TvEpisodeInfoModal takes a raw EpisodeViewSourceType repeated at each of the 5 call sites, directly next to the matching TvEpisodeActionContext. All 5 pairs are consistent today (I checked each), but they can drift. Passing TvEpisodeActionContext into the info modal instead would keep one source of truth and drop the EventHorizon import from 5 UI files.

3. LaunchedEffect(Unit) vs LaunchedEffect(episode.uuid)inline
The two modals key their tracking effects differently. Not reachable today, but keying the actions modal on episode.uuid would be free and consistent. The placement is right: it's above the pendingConfirmation branch, so the confirm → cancel round trip doesn't double-fire.

4. Now Playing can emit an extra episode_detail_showninline
It's the only site where episode can change while the details modal is open; playback advancing re-runs LaunchedEffect(episode.uuid) and emits a second event for an episode the user never opened. Reloading the notes is correct — only the extra event is questionable. Minor.

5. Test coverage. The two new tests assert one-line pass-throughs. The part that can actually be wrong — the TvEpisodeActionContextEpisodeViewSourceType table — has no assertion. TvEpisodeActionTypeTest already checks SearchResults.source, so a mapping test over TvEpisodeActionContext.entries would slot in naturally. (There's no tv/src/androidTest, so a real "fires once on appear" Compose test isn't an option in this module.)

Verified as correct

  • All 5 TvEpisodeInfoModal call sites updated; source pairs match the actions context on every screen (podcast→PodcastScreen, playlist→Filters, up next→UpNext, now playing→NowPlaying, search→Search).
  • Tracking the actions modal from the shared component (rather than per call site) means all 5 surfaces are covered and future ones get it for free.
  • NoOpTvEpisodeActions implements the new method, so previews stay side-effect free.
  • Because TvEpisodeInfoViewModel is retained and load() early-returns on the same uuid, reopening the same episode's details still emits the event — correct, and worth knowing these events are intentionally not deduped.
  • Import placement and ordering match the surrounding files; nothing that should trip spotlessCheck.

I did not run Gradle (build/tests run on Buildkite for this repo), so the compile and unit-test results here are from reading only.
• branch feat/tv-analytics-episode-actions

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Code review

  • Gather context (diff vs origin/feat/tv-analytics-up-next)
  • Read the touched TV files & all 10 modal call sites
  • Check event-fire semantics (double-fire, lifecycle, VM scoping)
  • Verify the PR description's claims against the code
  • Post review

The wiring is clean and consistent with the pattern established in the earlier PRs of this program (EventHorizon injected into the VM, VM exposes a track… function, modal fires it from a LaunchedEffect). I traced the fire semantics and they hold up:

  • TvModal is a Dialog, so TvEpisodeActionsModalContent composes exactly once per modal appearance — LaunchedEffect(Unit) fires once. It doesn't re-fire when pendingConfirmation toggles (that branch stays inside the same composable), which was my first worry.
  • Tapping Episode details sets detailsEpisode and nulls actionsEpisode at all 5 call sites, and dismissing the details modal never re-opens the actions modal — so the "open actions → open details" flow produces exactly one of each event, as the testing instructions claim.
  • All 5 TvEpisodeInfoModal call sites are threaded; the preview uses TvEpisodeInfoModalContent so it's unaffected, and NoOpTvEpisodeActions is updated.

Four things worth addressing, one of which is in the PR description rather than the code.


1. The PR description's "Decision 2" doesn't match the diff

2. Search now uses a dedicated TvEpisodeActionContext.Search. Previously the Search screen borrowed TvEpisodeActionContext.PodcastDetails … Adding a Search context (with SourceView.SEARCH) both gives the new shown-event the right source and corrects those existing playback events to source=search.

None of that happened in this diff. On the base branch, TvEpisodeActionsViewModel.kt:19-25 already had:

SearchResults(SourceView.SEARCH_RESULTS),

and TvSearchScreen.kt:156 already passed TvEpisodeActionContext.SearchResults. This PR only adds the second constructor arg (EpisodeViewSourceType.Search) to the existing entry. No new context, no SourceView.SEARCH, and no existing playback event changes its source — they were search_results before and still are.

Since the PR body is the durable record for an analytics change, this is worth correcting so nobody later goes hunting for a source migration that never happened. (Decision 1 — deliberately firing episode_actions_shown from Search where Apple TV doesn't — is accurate and the code does exactly that.)

2. The surface → EpisodeViewSourceType mapping now lives in two places

TvEpisodeActionContext carries episodeViewSource for the actions modal, but TvEpisodeInfoModal takes a hardcoded literal at each of its 5 call sites. Every one of those call sites sits directly beside a TvEpisodeActionsModal with the equivalent context:

call site actions context details literal
TvPodcastDetailsScreen.kt:335/346 PodcastDetails PodcastScreen
TvPlaylistDetailsScreen.kt:326/338 Playlist Filters
TvSearchScreen.kt:156/171 SearchResults Search
TvUpNextScreen.kt:175/187 UpNext UpNext
TvNowPlayingScreen.kt:348/360 NowPlaying NowPlaying

Nothing stops the two halves of a pair from drifting apart — an added surface, or a copy-paste when adding a sixth screen, and episode_actions_shown and episode_detail_shown silently report different sources for the same episode row. Taking actionContext: TvEpisodeActionContext in TvEpisodeInfoModal (and using actionContext.episodeViewSource internally) makes the enum the single source of truth and makes the pair impossible to mismatch. Fix this →

3. The two modals use different LaunchedEffect keys, so they behave differently when the episode changes underneath

  • TvEpisodeActionsModal.kt:66LaunchedEffect(Unit)
  • TvEpisodeInfoModal.kt:65LaunchedEffect(episode.uuid)

For 4 of the 5 surfaces the episode is pinned by local state, so this is a distinction without a difference. But in Now Playing the episode is the live playback state (TvNowPlayingScreen.kt:344-363): if playback auto-advances while a modal is open, the details modal fires a second episode_detail_shown while the actions modal — whose displayed episode also just changed — fires nothing. Keying both on episode.uuid makes the two events agree, and uuid is the right key regardless since a progress tick produces a new PodcastEpisode instance with the same uuid.

4. The new tests assert the forwarding, not the decision

Both tests are effectively vm.trackX(source)verify(eventHorizon).track(Event(source)). That's a one-line pass-through; the part of this PR that can actually be wrong is the mapping — which surface yields which source — and that's untested on both sides (the enum table, and 5 hardcoded literals). A parameterized assertion over TvEpisodeActionContext.entries checking source/episodeViewSource pairs would be cheap and would guard the thing reviewers are being asked to verify event-by-event against tvOS. Folding in #2 would extend that coverage to the details modal for free.

Nits

  • TvEpisodeActions.trackActionsShown vs TvEpisodeInfoViewModel.trackShowntrackDetailShown would read symmetrically with the event names.
  • TvEpisodeActionsViewModel.kt:53 places the new trackActionsShown override above play, ahead of the other interface overrides which otherwise follow the interface's declaration order.

Not verified: I reviewed statically and did not run ./gradlew :tv:testDebugUnitTest or spotlessCheck in this environment — the com.automattic:eventhorizon artifact isn't in the local cache here, so I couldn't confirm the EpisodeViewSourceType member names (PodcastScreen/Search/Filters/UpNext/NowPlaying) against the library; CI compilation will.
· branch feat/tv-analytics-episode-actions

Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoModal.kt Outdated
Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingScreen.kt Outdated
@sztomek
sztomek force-pushed the feat/tv-analytics-up-next branch 2 times, most recently from 25389c3 to cc64d92 Compare August 14, 2026 18:12
Base automatically changed from feat/tv-analytics-up-next to main August 14, 2026 18:19
@sztomek
sztomek force-pushed the feat/tv-analytics-episode-actions branch from 6af74bc to e022200 Compare August 14, 2026 18:25
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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


Code review — re-review after e022200 "Address PR comments"

  • Gather context (diff vs origin/main, latest push e022200)
  • Re-check the four earlier review points
  • Read the touched files & all 10 modal call sites
  • Compare against the existing mobile implementation of the same events
  • Post review

The follow-up commit addressed the substantive points from the last round. Confirmed in the current tree:

Earlier point Status
source plumbed two different ways TvEpisodeInfoModal now takes actionContext: TvEpisodeActionContext (TvEpisodeInfoModal.kt:62) and derives actionContext.episodeViewSource internally; all 5 call sites pass the same context they already pass to the sibling actions modal, and the EventHorizon import is gone from the 5 UI files
LaunchedEffect(Unit) vs LaunchedEffect(episode.uuid) ✅ both modals now key on episode.uuid (TvEpisodeActionsModal.kt:66, TvEpisodeInfoModal.kt:67)
Mapping table untested TvEpisodeActionTypeTest.kt:11-27 asserts both columns for every entry, and assertEquals(TvEpisodeActionContext.entries.toSet(), expected.keys) makes a newly-added context fail the test rather than silently skip it — exactly the guard that was missing
trackShown naming / override placement ✅ renamed to trackDetailShown; the override now sits after removeFromUpNext, matching interface order

I was also able to confirm what the last round couldn't: the EpisodeViewSourceType member names are all real — PodcastScreen, Search, Filters, UpNext, NowPlaying are each already referenced from modules/services/model/.../EpisodeViewSource.kt.

No correctness bugs, no security or performance concerns — two track calls on an already-injected singleton. Four things left, none of them blocking.


1. The PR description's "Decision 2" is still inaccurate — and the new test now contradicts it

The body says:

Previously the Search screen borrowed TvEpisodeActionContext.PodcastDetails … Adding a Search context (with SourceView.SEARCH) both gives the new shown-event the right source and corrects those existing playback events to source=search.

The code says otherwise. TvEpisodeActionsViewModel.kt:24:

SearchResults(SourceView.SEARCH_RESULTS, EpisodeViewSourceType.Search),

SearchResults(SourceView.SEARCH_RESULTS) existed on the base branch, TvSearchScreen.kt:155 already used it, and the only change is the second column. And the new mapping test now asserts SourceView.SEARCH_RESULTS explicitly (TvEpisodeActionTypeTest.kt:15) — so the durable record and the test disagree in writing. No existing playback event changes its source; they were search_results before and still are. Worth a description edit before merge, since this is an analytics PR whose body is what reviewers verify against tvOS. (Decision 1 — deliberately firing episode_actions_shown from Search where Apple TV doesn't — is accurate.)

2. No episode_detail_dismissed counterpart (inline)

Mobile fires EpisodeDetailShownEvent and EpisodeDetailDismissedEvent as a pair (EpisodeFragment.kt:358 / :368). TV emits only the shown half, so anything pairing the two sees unmatched TV events. Probably intentional given the stated scope, but it belongs in the PR body rather than being discovered later.

3. Config-change re-fire (inline)

Mobile guards both events with if (!viewModel.isFragmentChangingConfigurations). LaunchedEffect(episode.uuid) re-runs on activity recreation with a modal open (locale change, dark-mode toggle, display change). Far rarer on TV than on a phone — flagging only because the two platforms will count it differently.

4. Now Playing still double-counts on auto-advance

Now Playing is the only surface where episode is live playback state rather than captured local state (TvNowPlayingScreen.kt:343-362), so if playback advances while a modal is open, episode.uuid changes and a second event fires for an episode the user never opened. Unchanged from the last round, and now symmetric across both modals (the actions modal keys on uuid too), which is arguably the better trade — one small skew in now_playing on both events beats an inconsistency between them. Fine to accept; capturing the episode at open time is the fix if it ever matters. Fix this →

Nit — a canonical surface → EpisodeViewSourceType mapping already exists

modules/services/model/.../EpisodeViewSource.kt already maps app surfaces to EpisodeViewSourceType (PODCAST_SCREEN, FILTERS, UP_NEXT, SEARCH, NOW_PLAYING are all there), and mobile tracks via episodeViewSource.analyticsValue. TvEpisodeActionContext now holds a second, parallel copy of five of those rows. Holding EpisodeViewSource and reading .analyticsValue would keep one table for the whole app. Not worth churn if the TV enum is expected to stay this small.


Verified as correct

  • All 5 details-modal call sites pass a context matching their sibling actions modal — PodcastDetails (TvPodcastDetailsScreen.kt:334/345), Playlist (:325/337), SearchResults (:155/170), UpNext (:175/187), NowPlaying (:347/359). With the mapping now living in the enum, these can't drift.
  • Tracking from the shared modal rather than per call site means a sixth surface gets both events for free.
  • LaunchedEffect(episode.uuid) sits above the pendingConfirmation branch in TvEpisodeActionsModal, so the confirm → cancel round trip doesn't re-fire.
  • NoOpTvEpisodeActions implements trackActionsShown, so previews stay side-effect free; TvEpisodeInfoModal's preview targets TvEpisodeInfoModalContent and is unaffected.
  • Tapping Episode details clears actionsEpisode at every call site and dismissing details never reopens actions, so the flow in the testing instructions yields exactly one of each event.
  • Reopening the same episode's details re-fires the event even though TvEpisodeInfoViewModel.load() early-returns on the same uuid — the tracking call is before load(), which is what you want.
  • Import ordering matches the surrounding files.

Not verified: I reviewed statically. Gradle isn't runnable in this environment (./gradlew is not in the allowed tools here), so :tv:compileDebugKotlin, :tv:testDebugUnitTest and spotlessCheck results are from reading only — CI will confirm.
· branch feat/tv-analytics-episode-actions


fun trackDetailShown(source: EpisodeViewSourceType) {
eventHorizon.track(EpisodeDetailShownEvent(source = source))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two things worth comparing against the existing mobile implementation of this same event, EpisodeFragment.kt:353-361:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    if (!viewModel.isFragmentChangingConfigurations) {
        eventHorizon.track(EpisodeDetailShownEvent(source = episodeViewSource.analyticsValue))
    }
}

override fun onDestroyView() {
    ...
    if (!viewModel.isFragmentChangingConfigurations) {
        eventHorizon.track(EpisodeDetailDismissedEvent(source = episodeViewSource.analyticsValue))
    }
  1. No episode_detail_dismissed counterpart. Mobile emits shown/dismissed as a pair, so anything that pairs them (dwell time, "opened details but bounced") will see TV episode_detail_shown events with no matching dismiss. If that's deliberate — tvOS doesn't fire it either, and the PR is explicitly scoped to the two shown events — it's worth a line in the PR body, since that's the record the analytics folks read. TvEpisodeInfoModal.onDismissRequest is the natural hook if it's wanted.

  2. Config-change guard. Mobile suppresses the re-fire when the fragment is recreated for a configuration change. LaunchedEffect(episode.uuid) here re-runs whenever the activity is recreated with the modal open (locale change, dark-mode toggle, display/HDMI change on TV). Much rarer on TV than on a phone, so probably fine to accept — just flagging that the two platforms count this differently.

@wpmobilebot wpmobilebot modified the milestones: 8.19, 8.20 Aug 17, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

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

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] TV [Type] Feature Adding a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants