Skip to content

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

Draft
sztomek wants to merge 2 commits into
mainfrom
feat/tv-media-session
Draft

[TV] Make the app a first-class media-session citizen#5740
sztomek wants to merge 2 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...

@dangermattic

Copy link
Copy Markdown
Collaborator
1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

@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

Timber.plant(TimberDebugTree())
}
notificationHelper.setupNotificationChannels()
PlaybackServiceToggle.ensureCorrectServiceEnabled(this)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FeatureFlag is never initialised on TV, so this always reads the compile-time default.

PlaybackServiceToggle.ensureCorrectServiceEnabled()FeatureFlag.isEnabled(Feature.MEDIA3_SESSION), and FeatureFlag.isEnabled falls back to feature.defaultValue when no providers are registered:

findProviderForFeature<FeatureProvider>(feature)?.isEnabled(feature) ?: feature.defaultValue

FeatureFlag.initialize(...) is only called from AppLifecycleObserver.setup() (line 115) and AutomotiveApplication. TvApplication calls neither — there is no FeatureFlag/FeatureProvider reference anywhere under tv/src. Feature.MEDIA3_SESSION.defaultValue is isDebugOrPrototypeBuild, so:

  • debug / prototype TV builds → media3 PlaybackService enabled (this is what the emulator run in the description verified),
  • release TV buildsLegacyPlaybackService enabled, and the Firebase remote flag and the dev toggle have no effect on TV at all.

The good news is it's self-consistent: MediaSessionManager.useMedia3Session reads the same uninitialised flag, so the enabled component and the session type can't disagree. But the release path (legacy MediaSessionCompat on TV) is the one that ships and is the one that hasn't been exercised.

Two suggestions:

  1. Either initialise the flags on TV the way wear does — appLifecycleObserver.setup() immediately before this call (wear: PocketCastsWearApplication.setupApp() lines 109–111) — or state in the PR that TV deliberately ships the compile-time default, and verify a prototype build too, since the legacy service is what release users get.
  2. Whichever you pick, keep/insert FeatureFlag.initialize before ensureCorrectServiceEnabled. If it is ever added after this line, the toggle would enable a component chosen from the default while MediaSessionManager.useMedia3Session (lazy, first read in startObserving()) resolves from the initialised providers — a mismatch for that launch.

Fix this →

Comment on lines +6 to +9
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

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 permission block (neither blocking):

  1. POST_NOTIFICATIONS is already contributed by a library manifestmodules/services/repositories/src/main/AndroidManifest.xml line 5 declares it, so it merges into the TV APK regardless. Harmless to repeat it explicitly, just don't count on this line as the thing that fixed anything. Separately: nothing in tv/src requests the runtime permission (no hasNotificationsPermission() / request flow), so on API 33+ the playback foreground-service notification is posted but not displayed. That doesn't stop the FGS or the session — worth knowing before claiming the notification surface works.

  2. FOREGROUND_SERVICE_DATA_SYNC — the PR calls this out as out of scope, and I agree it's pre-existing, but note the gap is slightly sharper than "TV has no foreground-service permissions": the same repositories library manifest merges in androidx.work.impl.foreground.SystemForegroundService with android:foregroundServiceType="dataSync", so the TV APK declares a dataSync FGS with no matching permission. Reachable callers are DownloadEpisodeWorker (wraps setForegroundAsync(...).get() in a try/catch → degrades safely), FixDownloadsWorker and OpmlImportTask (unguarded setForeground(...)). TV doesn't trigger any of them today, so deferring is defensible — it's just a one-line addition if you'd rather close it here.

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants