Skip to content

[TV] Make the app a first-class media-session citizen - #5740

Open
sztomek wants to merge 4 commits into
mainfrom
feat/tv-media-session
Open

[TV] Make the app a first-class media-session citizen#5740
sztomek wants to merge 4 commits into
mainfrom
feat/tv-media-session

Conversation

@sztomek

@sztomek sztomek commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

The Android TV app already plays audio (through the in-process ExoPlayer) and ships a full Now Playing screen, but it was not a first-class media-session citizen: the TV manifest never declared PlaybackService/LegacyPlaybackService, so MediaSessionManager.startServiceIfNeeded() could never resolve a media-browser service (resolveMediaBrowserServiceComponent() returned null and logged "No enabled media browser service found in manifest"). The consequence was no system MediaSession, no foreground service, and therefore:

  • no guaranteed background / screen-off playback continuation (the OS is free to reclaim the process),
  • no remote media-key / Bluetooth / Google Assistant transport control,
  • no media resumption (the resume-from-launcher surface),
  • no MediaBrowser content tree for the system/Assistant.

This PR wires TV up the same way the wear module does — reusing the shared services via the runtime toggle rather than adding anything TV-specific:

  • Manifest (tv/src/main/AndroidManifest.xml): declares PlaybackService (media3) and LegacyPlaybackService, both enabled="false" with foregroundServiceType="mediaPlayback" and the media-browser / media3 intent-filters, plus the WAKE_LOCK, POST_NOTIFICATIONS, FOREGROUND_SERVICE and FOREGROUND_SERVICE_MEDIA_PLAYBACK permissions they need.
  • TvApplication.onCreate(): sets up the notification channels (the foreground service posts on the "Playback" channel — without it startForeground throws on Android 8+) and calls PlaybackServiceToggle.ensureCorrectServiceEnabled(), which enables the correct service based on the MEDIA3_SESSION feature flag, before the existing playbackManager.setup().

This gap was surfaced by external contributor PR #5737 (thanks @nolengreenspan 🙏). That PR was based on a fork predating our Now Playing work and was closed as stale — its Now Playing screen already exists on main — but it correctly spotted the one real remaining gap: the TV manifest never declared the playback services. This PR addresses that specific issue in isolation, following the module's existing patterns.

Deliberately out of scope

  • MediaButtonReceiver — only forwards external broadcast ACTION_MEDIA_BUTTON events (Tasker/Automate); TV remote keys reach the session directly. wear omits it too, so we match wear.
  • FOREGROUND_SERVICE_DATA_SYNCwear/app declare it for WorkManager foreground workers. It's unrelated to media sessions and a pre-existing gap (TV had no foreground-service permissions at all before), so it's left for a separate change.

Fixes PCDROID-727 https://linear.app/a8c/issue/PCDROID-727/mediasession-support

Testing Instructions

  1. Build and install the TV app on an Android TV / Google TV device or emulator (./gradlew :tv:installDebug).
  2. Launch the app — it starts without crashing (the new startup wiring is the risk).
  3. Confirm the media3 session service is enabled at runtime:
    adb shell dumpsys package au.com.shiftyjelly.pocketcasts.debug | grep -A2 enabledComponents
    
    enabledComponents: …PlaybackService, disabledComponents: …LegacyPlaybackService.
  4. Start playing an episode, then send the app to the background / turn the screen off → playback continues.
  5. Use a remote/Bluetooth media key or "Hey Google, pause" → the transport control reaches the app.

Smoke-tested on a Google TV emulator (sdk_google_atv64_arm64, leanback)

Startup wiring only — the behavioral outcomes below still need verifying on a real device:

  • App launches cleanly, no FATAL EXCEPTION from the new onCreate() calls.
  • Runtime component state: enabledComponents: PlaybackService, disabledComponents: LegacyPlaybackServicePlaybackServiceToggle ran and enabled the media3 service.
  • No playback / notification-channel / foreground-service errors in logcat.

Not yet verified (steps 4–5 above): actual background / screen-off continuation, remote media-key / Assistant control, and media resumption on a real TV device.

Screenshots or Screencast

No UI change — the Now Playing screen already exists; this PR only wires up the media-session/foreground-service infrastructure. The verification artifact is the runtime component-enabled state above.

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

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

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


Code review: TV media-session wiring

Read the diff against origin/main and traced the runtime paths it activates (PlaybackServiceToggle, FeatureFlag, MediaSessionManager, PackageValidator, NotificationHelperImpl), comparing against how wear and app do the same thing.

  • Read the PR diff
  • Compare with wear/app manifests
  • Trace PlaybackServiceToggle / NotificationHelper / MediaSessionManager behaviour on TV
  • Check caller validation, launch-intent and foreground-service implications
  • Post review

Overall: the approach is right — reusing the shared services through the runtime toggle instead of adding TV-specific plumbing, and the manifest block is a faithful copy of wear/app. One finding is worth acting on before merge; the rest are minor. No security or performance concerns of substance.


1. FeatureFlag is never initialised on TV — the toggle always uses the compile-time default (TvApplication.kt:37)

PlaybackServiceToggle reads FeatureFlag.isEnabled(Feature.MEDIA3_SESSION), and FeatureFlag.isEnabled returns feature.defaultValue when no providers are registered. FeatureFlag.initialize(...) is only called from AppLifecycleObserver.setup() and AutomotiveApplication; TvApplication calls neither, and there is no FeatureFlag/FeatureProvider reference anywhere under tv/src. Since MEDIA3_SESSION.defaultValue = isDebugOrPrototypeBuild:

  • debug/prototype TV → media3 PlaybackService (what the emulator run verified),
  • release TV → LegacyPlaybackService, with the Firebase remote flag and dev toggle having no effect on TV.

It is at least self-consistent — MediaSessionManager.useMedia3Session reads the same uninitialised flag, so the enabled component and the session type can't disagree — but the path that ships to release users is the untested one. Wear calls appLifecycleObserver.setup() (which initialises the flags) immediately before ensureCorrectServiceEnabled; TV should either do the same or explicitly document that it ships the default, and verify a prototype build. Whichever way, any future flag init must go before line 37, otherwise the toggle and MediaSessionManager's lazy read can diverge for that launch. Details in the inline comment.

2. Permissions block — minor (inline)

  • POST_NOTIFICATIONS is already merged in from modules/services/repositories/src/main/AndroidManifest.xml:5; repeating it is harmless but it isn't what unblocks anything. Nothing in tv/src requests it at runtime, so on API 33+ the FGS notification is posted but not displayed (the service and session still work).
  • The FOREGROUND_SERVICE_DATA_SYNC gap is a bit sharper than described: the same library manifest merges in SystemForegroundService with foregroundServiceType="dataSync", so the APK declares a dataSync FGS with no matching permission. DownloadEpisodeWorker catches the failure; FixDownloadsWorker / OpmlImportTask don't. TV triggers none of them today, so deferring is fine.

3. Main-thread startup work — nit

setupNotificationChannels() creates 13 channels and ensureCorrectServiceEnabled() makes 2–4 synchronous PackageManager binder calls, both on the main thread in onCreate(). The toggle is guarded by a getComponentEnabledSetting read so it only writes when the state actually changes, and this matches wear exactly — but the channel setup could move into the existing applicationScope.launch if TV startup latency ever matters. The toggle itself must stay synchronous, so it's correctly placed.

Things I checked that are fine

  • Caller validation on TV. Exporting the browse service is required and safe here: PackageValidator.isKnownCaller admits system-signed callers, Process.SYSTEM_UID, and holders of MEDIA_CONTENT_CONTROL, which covers the Android TV system/Assistant surfaces even though allowed_media_browser_callers.xml lists no TV packages. The Android 11+ <queries> list isn't a problem either — binding grants mutual package visibility.
  • Leanback launch intent. Context.getLaunchActivityPendingIntent() already falls back to getLeanbackLaunchIntentForPackage, so the session activity / notification content intent resolves on TV.
  • Ordering. The toggle runs before playbackManager.setup(), which is what matters for startServiceIfNeeded() resolving a component.
  • Hilt. NotificationHelper binding is satisfied for the TV component (compile-time verified), and PlaybackService's deps were already validated in the TV graph before this PR.
  • Omitting MediaButtonReceiver matches wear and is correctly reasoned — it only forwards external ACTION_MEDIA_BUTTON broadcasts.

Adjacent, not introduced here

Util.getAppPlatform() has no TV branch, so TV resolves to AppPlatform.Phone. In PlaybackManager.sendDataWarningNotification (PlaybackManager.kt:2377-2385), getLaunchIntentForPackage returns null on a leanback-only manifest, the Phone && intent != null guard fails, and the else branch calls PendingIntent.getActivity(..., null, ...). Same class of bug the Context.getLaunchActivityPendingIntent() leanback fallback already fixed. Only reachable when streaming on a connection the system reports as metered, so it's unlikely on TV and clearly out of scope — but now that TV playback is first-class it's worth a follow-up.

No changes were pushed — this is review feedback only.
• branch feat/tv-media-session

Comment thread tv/src/main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt
Comment thread tv/src/main/AndroidManifest.xml
@nolengreenspan

nolengreenspan commented Aug 14, 2026

Copy link
Copy Markdown

Tested this branch (4bbda40) on real hardware — a Philips 4K A1 (Android 11),installDebugProd . Results for the items listed as not-yet-verified:

Startup and component state ✅
App launches cleanly, no crash from the new onCreate() wiring.
enabledComponents → ...repositories.playback.PlaybackService
disabledComponents → ...repositories.playback.LegacyPlaybackService

Media session ✅
dumpsys media_session shows androidx.media3.session.id.PocketCastsMedia3Session, and the system designates it as the media button session (Media button session is au.com.shiftyjelly.pocketcasts.debug/...PocketCastsMedia3Session).

Background continuation ✅
Playing an episode and pressing Home: audio continues uninterrupted.

Remote media-key control ✅
input keyevent 85 (KEYCODE_MEDIA_PLAY_PAUSE) toggles pause/resume with the app in the background.

Screen-off — behaves differently to a phone, and I don't think it's a defect
input keyevent 223 (KEYCODE_SLEEP) puts the TV into standby and audio stops. But the app process and the media session both survive, and a single media-key press after wake resumes playback immediately. That reads as TV standby suspending playback rather than the session being lost — but you'd know better than me whether that's the intended outcome on this hardware.

Not tested: media resumption after reboot, Assistant voice control, and the release/legacy path — this was a debug build, so per the review comment MEDIA3_SESSION resolved to its isDebugOrPrototypeBuild default and only the media3 service was exercised. Happy to run a prototype build against LegacyPlaybackService if that's useful.

One incidental note: the app installed under user User 10 on this device rather than the owner profile. Didn't seem to affect anything, just flagging it.

@nolengreenspan

Copy link
Copy Markdown

While testing this branch I noticed there's still no way to control playback without leaving whatever you're browsing. My closed #5737 had a mini-player drawer on the remote's Options/MENU key — transport controls slide in over the current screen, Menu or Back dismisses. I've since rebased it onto current main and cut it down to reuse TvSeekBar and the existing tv_nothing_playing_* strings rather than the hand-rolled controls it originally had. It's one new file plus a small hook in TvScaffold. Is that something you'd want? Happy to open it as its own PR, or drop it if you have designs for this already.

# Conflicts:
#	tv/src/main/java/au/com/shiftyjelly/pocketcasts/TvApplication.kt
@sztomek

sztomek commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

hi @nolengreenspan

Sorry for the long wait, i was AFK last week.
Thank you for having a look and actually testing this branch on your end. 🙏

I noticed there's still no way to control playback without leaving whatever you're browsing. My closed #5737 had a mini-player drawer on the remote's Options/MENU key — transport controls slide in over the current screen, Menu or Back dismisses. I've since rebased it onto current main and cut it down to reuse TvSeekBar and the existing tv_nothing_playing_* strings rather than the hand-rolled controls it originally had. It's one new file plus a small hook in TvScaffold. Is that something you'd want? Happy to open it as its own PR, or drop it if you have designs for this already.

I believe this piece would require input from our design team, but I'll ask them to investigate how a miniplayer would work best - if they approve the idea and draft a deisng for it, a follow-up PR will be quite likely.

@sztomek
sztomek marked this pull request as ready for review August 24, 2026 16:16
@sztomek
sztomek requested a review from a team as a code owner August 24, 2026 16:16
@sztomek
sztomek requested review from geekygecko and removed request for a team August 24, 2026 16:16
@sztomek sztomek added [Area] Playback Episode playback issue [Area] TV [Type] Enhancement Improve an existing feature. labels Aug 24, 2026
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown

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


Code review: TV media-session wiring (round 2)

Re-reviewed after 775a56f ("Address PR comments"), tracing the paths this now activates on TV: FeatureFlag / the three providers, PlaybackServiceToggle, NotificationHelperImpl, PlaybackService / LegacyPlaybackService, and the notification surfaces setupNotificationChannels() newly unblocks.

  • Read the updated diff
  • Verify the new setupFeatureFlags() against AppLifecycleObserver / AutomotiveApplication / wear
  • Check BuildConfig.IS_PROTOTYPE for the tv module
  • Re-check manifest, permissions, and startup ordering
  • Post review

Round-1 blocker is resolved. setupFeatureFlags() runs before PlaybackServiceToggle.ensureCorrectServiceEnabled() (TvApplication.kt:48-50), so the toggle and MediaSessionManager.useMedia3Session now read the same initialised provider set, and remote config / dev toggles apply on TV. The provider list matches AppLifecycleObserver.kt:106-117 exactly, and the ordering matches wear (PocketCastsWearApplication.kt:109-111). Nothing blocking left; three notes below.


1. prototype isn't a buildable variant for :tv — release is the only untested path (inline)

tv/build.gradle.kts:42-46 does builder.enable = builder.buildType != "prototype", so BuildConfig.IS_PROTOTYPE is always false here and that arm of the if is dead. TV has two flag paths, not three: debug/debugProdPreferencesFeatureProvider, release → Firebase + DefaultReleaseFeatureProvider.

That matters for verification: a prototype build isn't available as the "test the release path" shortcut. And the release path is deterministic in a way worth stating — Feature.MEDIA3_SESSION.defaultValue is false in release, and FirebaseConfig.kt:29-31 seeds media3_session into setDefaultsAsync from that same default, so a fresh release install enables LegacyPlaybackService. FirebaseRemoteFeatureProvider.init fetches asynchronously and resolves long after onCreate, so a remote flip only takes effect on the next launch; LegacyPlaybackService.onCreate's mediaSession == null → stopSelf() guard covers the launch in between. All of this is identical to app, so nothing is introduced here — but release TV ships legacy MediaSessionCompat, and neither the emulator run nor @nolengreenspan's Philips A1 run (both debug) exercised it.

2. setupFeatureFlags() is now the third verbatim copy — minor

Byte-identical to AppLifecycleObserver.kt:106-117 and AutomotiveApplication.kt:157-168. TV deliberately skips AppLifecycleObserver, so the duplication is understandable, but a shared helper in :modules:services:utils would keep the provider list and its "must run before anything reads a flag" constraint in one place. Fine to defer. Fix this →

3. Creating the channels re-opens two notification surfaces on TV that were previously dropped — low severity

Before this PR the TV app had no notification channels, so any NotificationManagerCompat.notify() on API 26+ was silently dropped by the system. setupNotificationChannels() changes that, and two playback notifications become postable on TV:

  • PlaybackManager.sendDataWarningNotification (PlaybackManager.kt:2374) and the playback-error notification (PlaybackManager.kt:2429/2435).
  • Their action buttons go through buildNotificationIntent (PlaybackManager.kt:2445), which targets NotificationBroadcastReceiver — declared only in app/src/main/AndroidManifest.xml:586, not in the repositories library manifest. On TV the receiver doesn't exist, so those buttons are no-ops.
  • sendDataWarningNotification also still hits the AppPlatform.Phone && intent != null guard at PlaybackManager.kt:2380 that round 1 flagged; getLaunchIntentForPackage is null on a leanback-only manifest, so it falls to PendingIntent.getActivity(..., null, ...).

Mitigations: same situation on wear/automotive (pre-existing, not introduced here), reachable only when streaming on a connection the system reports as metered, and nothing in tv/src requests POST_NOTIFICATIONS at runtime, so on API 33+ none of it is displayed anyway. Genuinely a follow-up, not a change to this PR — just no longer purely theoretical now that the channels exist.

Verified fine

  • Ordering. Flags → channels → toggle → playbackManager.setup(). No production code reads a flag with immutable = true (only FeatureFlagTest), so nothing constructed during Hilt field injection in super.onCreate() can snapshot a pre-initialize() value and lock it in.
  • FeatureFlag.initialize non-idempotency. It does providers.addAll(...) without dedup, but Application.onCreate runs once per process and PlaybackService shares that process — no double registration.
  • No FirebaseApp.initializeApp(this) on TV (app and wear both call it defensively). Not a regression: SettingsImpl already injects FirebaseRemoteConfig, so FirebaseRemoteConfig.getInstance() was already reached on every TV launch before this PR.
  • Manifest. Service blocks are byte-equivalent to wear/src/main/AndroidManifest.xml:80-102 and app/src/main/AndroidManifest.xml:605-626, including the androidx.media3.session.MediaLibraryService action needed for media3 and the android.media.browse.MediaBrowserService action that makes media resumption resolvable. enabled="false" on both is correct — the toggle owns the choice.
  • google-services.json. tv/src/debug and tv/src/debugProd both have one; release is injected by CI, same as app/wear/automotive.
  • Omitting MediaButtonReceiver. Matches wear; the reasoning holds. Note @nolengreenspan's input keyevent 85 test goes via AudioManager.dispatchMediaKeyEvent to the active session, so it confirms the session path, not the broadcast path — but the broadcast path is the one deliberately out of scope.
  • getComponentEnabledSetting guard in PlaybackServiceToggle.kt:29-30 means no PackageManager write on the steady-state launch.

On the screen-off observation

@nolengreenspan's KEYCODE_SLEEP result reads as expected rather than a defect — Android TV standby suspends the audio output, but the process and session surviving with an immediate resume on the next key press is exactly what a healthy foreground service + session looks like. The wired-up session is what makes that resume work at all.


No changes pushed — review feedback only. I did not run ./gradlew spotlessCheck or a build; the diff is formatted consistently with the surrounding files and CI covers both.
• branch feat/tv-media-session

@sztomek sztomek added this to the 8.20 milestone Aug 24, 2026
}

private fun setupFeatureFlags() {
val providers = if (BuildConfig.DEBUG || BuildConfig.IS_PROTOTYPE) {

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 notes on this block — the ordering fix itself is right (flags before ensureCorrectServiceEnabled, matching wear), neither of these is blocking.

1. BuildConfig.IS_PROTOTYPE is always false for :tv. tv/build.gradle.kts:42-46 disables the variant outright:

androidComponents {
    beforeVariants { builder ->
        builder.enable = builder.buildType != "prototype"
    }
}

so the || BuildConfig.IS_PROTOTYPE arm is dead on TV — harmless as copy-paste parity with app/automotive, but it means TV has exactly two flag paths, not three:

  • debug / debugProdPreferencesFeatureProvider (what the emulator + @nolengreenspan's device run exercised),
  • releaseFirebaseRemoteFeatureProvider + DefaultReleaseFeatureProvider.

Practical consequence: the earlier suggestion to "verify a prototype build" isn't available here. A release smoke test is the only way to exercise the legacy path, since Feature.MEDIA3_SESSION.defaultValue is false in release and FirebaseConfig.defaults seeds media3_session from that same default — so a fresh release install deterministically enables LegacyPlaybackService until a Firebase fetch flips it, and the flip only takes effect on the next launch (the fetch in FirebaseRemoteFeatureProvider.init is async and completes well after onCreate). LegacyPlaybackService.onCreate's mediaSession == null → stopSelf() guard covers the in-between launch. All of that is identical to app, so nothing is introduced here — just worth knowing that release TV ships MediaSessionCompat.

2. Third verbatim copy of this function. Identical to AppLifecycleObserver.kt:106-117 and AutomotiveApplication.kt:157-168. Since TV deliberately doesn't use AppLifecycleObserver, a small shared helper in :modules:services:utils (an injectable FeatureFlagInitializer, or FeatureFlag.initializeDefaults(...)) would stop the next surface from having to re-derive the provider list and its ordering constraint. Fine to defer, but it's now a pattern rather than a one-off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] Playback Episode playback issue [Area] TV [Type] Enhancement Improve an existing feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants