Skip to content

Add native Android media controls for HA media_player entities#6626

Draft
FletcherD wants to merge 98 commits intohome-assistant:mainfrom
FletcherD:feature/native-media-controls
Draft

Add native Android media controls for HA media_player entities#6626
FletcherD wants to merge 98 commits intohome-assistant:mainfrom
FletcherD:feature/native-media-controls

Conversation

@FletcherD
Copy link
Copy Markdown

@FletcherD FletcherD commented Mar 25, 2026

Summary

I wanted to be able to control a media player entity natively on Android without having to open the app or navigate to a widget. So this feature exposes one or more Home Assistant media_player entities as native Android Media Controls (described here) in the notification shade, the same UI used by other media players on Android.
The media controls show the currently playing track info and play position with album art. Prev/next track, play/pause and seek controls work and are forwarded to the media_player entity (if the entity supports them).
A new "Media controls" setting is added under "Companion app" to choose which media_player entities to expose in the media controls, if any. You can choose more than one entity, in which case a notification will be created for each one.
Unit tests are added to test playback state mapping, state flow, settings and everything else I could think of.

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Screenshots

notification_shade_media_controls settings_media_controls_entry_dark media_controls_settings_dark

Link to pull request in documentation repositories

User Documentation PR: home-assistant/companion.home-assistant#1304

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a native Android MediaSession-backed surface for controlling a selected Home Assistant media_player entity from the notification shade, plus companion settings and supporting data plumbing.

Changes:

  • Introduces MediaControlRepository + state model to observe a configured media_player and map HA state/attributes into media metadata and supported commands
  • Adds HaMediaSessionService and HaRemoteMediaPlayer to expose the entity via Android’s media controls and forward transport/seek actions back to HA
  • Adds a new “Media controls” settings screen and preference entry to select/clear the exposed entity, plus unit tests and changelog entry

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
common/src/test/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImplTest.kt Unit tests for repository configuration and HA→media state mapping
common/src/main/res/values/strings.xml New UI strings for the media controls settings
common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt Persists configured media control server/entity IDs; clears on server removal
common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt Adds prefs API for media controls configuration
common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlState.kt New state model + playback state types for media controls
common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImpl.kt Observes websocket entity updates and emits MediaControlState
common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepository.kt Repository interface for configuration + observation
common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlModule.kt Hilt binding for the new repository
common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt Adds media_player supported-features constants and helper accessors
automotive/src/main/AndroidManifest.xml Declares MediaSessionService and media playback FGS permission
automotive/lint-baseline.xml Updates lint baseline (new ComposeUnstableCollections entries)
app/src/test/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModelTest.kt Unit tests for settings ViewModel selection/save/clear behaviors
app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayerTest.kt Robolectric tests for player state mapping, commands, and callbacks
app/src/main/res/xml/preferences.xml Adds preference category/entry for “Media controls”
app/src/main/res/xml/changelog_master.xml Adds user-facing changelog entry for the feature
app/src/main/res/drawable/ic_play_circle_outline.xml New icon for the settings entry
app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Starts the media session service on app start when configured
app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/views/MediaControlSettingsView.kt Compose UI for selecting server/entity and saving/clearing
app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModel.kt Loads servers/entities/registries, manages selection, and starts/stops service
app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsFragment.kt Fragment host for the Compose settings screen (+ help link)
app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt Wires the new preference click to open the media controls settings
app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayer.kt Media3 SimpleBasePlayer proxy translating commands to HA callbacks
app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionService.kt MediaSessionService that observes HA state, loads artwork, and calls HA actions
app/src/main/AndroidManifest.xml Declares MediaSessionService and media playback FGS permission
app/lint-baseline.xml Updates lint baseline (new ComposeUnstableCollections entries)

Comment thread app/lint-baseline.xml Outdated
Comment thread automotive/lint-baseline.xml Outdated
@FletcherD FletcherD force-pushed the feature/native-media-controls branch from c9a2d04 to 785d6a5 Compare March 25, 2026 07:04
Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

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

I didn't check the code yet, I'm curious could it also work on the watch? (In another PR)

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 25, 2026

Screen_recording_20260325_145042.mp4

When you open the setting screen, it has a weird animation that shouldn't be there.
On a wider screen it looks off (margin on the right side).
The screen feels empty I wonder if we can do something about it, like showing how it looks like?

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 25, 2026

Why limiting to only one entity ? The service does support multiple sessions it would be nice to be able to track multiple entities as per the documentaion

However, if you're capable of handling multiple playbacks and want to keep their sessions while the app is in the background, create multiple sessions and add them to this service with addSession(MediaSession).

An extension to your PR (in another one) would be that we should be able to send a media player from HA to the phone through a command so that from an automation you can dynamically add/remove a session. (if you think it is feasible let's create an issue)

image "This phone" is wrong it should be probably the entity name, when clicking on it I can change the volume of my phone which is wrong. --> You probably need to set `.setDeviceInfo(deviceInfo)` on the State object and also use the device type `PLAYBACK_TYPE_REMOTE`. --> For the volume something similar like `setDeviceVolume` Or try to disable it in the scope of this PR.

@TimoPtr TimoPtr marked this pull request as draft March 25, 2026 14:54
@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 25, 2026

@FletcherD it does look promising, I didn't go in details for now but I gave you some comments that are important to look at before going any further.

@FletcherD
Copy link
Copy Markdown
Author

FletcherD commented Mar 26, 2026

Thanks for the comments, they're good ideas.
I refactored to support multiple entities, now you can add and remove them in the setting.

"This phone" is wrong it should be probably the entity name, when clicking on it I can change the volume of my phone which is wrong. --> You probably need to set .setDeviceInfo(deviceInfo) on the State object and also use the device type PLAYBACK_TYPE_REMOTE. --> For the volume something similar like setDeviceVolume Or try to disable it in the scope of this PR.

I exposed volume set/adjust which also allows the volume to be adjusted with the hardware buttons.
Also the device info so the badge shows "Other device". As far as I can tell there's no way to set the name here, it is determined by the OS

@TimoPtr TimoPtr mentioned this pull request Mar 26, 2026
4 tasks
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

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

I focused my review on the MediaControl first. Let's make this right and then I'll move to the rest of the PR otherwise it takes too much time to review.

Also I got this StrictMode issue while testing did you?

!!! CRITICAL FAILURE: FAIL-FAST !!!
                                                                                                    ██████████████████████
                                                                                                    
                                                                                                    An unrecoverable error has occurred, and the FailFast mechanism
                                                                                                    has been triggered. The application cannot continue and will now exit.
                                                                                                    
                                                                                                    ACTION REQUIRED: This error must be investigated and resolved.
                                                                                                    Review the accompanying stack trace for details.
                                                                                                    ----------------------------------------------------------------
                                                                                                    
                                                                                                    
                                                                                                    android.os.strictmode.CustomViolation: Downscaling oversized Icon Bitmap
                                                                                                    	at android.os.StrictMode$AndroidBlockGuardPolicy.onCustomSlowCall(StrictMode.java:1807)
                                                                                                    	at android.os.StrictMode.noteSlowCall(StrictMode.java:3073)
                                                                                                    	at android.graphics.drawable.Icon.scaleDownIfNecessary(Icon.java:1249)
                                                                                                    	at android.graphics.drawable.Icon.scaleDownIfNecessary(Icon.java:1271)
                                                                                                    	at android.app.Notification.reduceImageSizes(Notification.java:8782)
                                                                                                    	at android.app.Notification$Builder.build(Notification.java:8490)
                                                                                                    	at androidx.core.app.NotificationCompatBuilder.buildInternal(NotificationCompatBuilder.java:375)
                                                                                                    	at androidx.core.app.NotificationCompatBuilder.build(NotificationCompatBuilder.java:297)
                                                                                                    	at androidx.core.app.NotificationCompat$Builder.build(NotificationCompat.java:2617)
                                                                                                    	at io.homeassistant.companion.android.mediacontrol.HaMediaSessionService.buildNotification(HaMediaSessionService.kt:281)
                                                                                                    	at io.homeassistant.companion.android.mediacontrol.HaMediaSessionService.onUpdateNotification(HaMediaSessionService.kt:135)
                                                                                                    	at androidx.media3.session.MediaSessionService.onUpdateNotificationInternal(MediaSessionService.java:854)
                                                                                                    	at androidx.media3.session.MediaNotificationManager$MediaControllerListener.onEvents(MediaNotificationManager.java:495)
                                                                                                    	at androidx.media3.session.MediaControllerImplBase.lambda$new$0$androidx-media3-session-MediaControllerImplBase(MediaControllerImplBase.java:185)
                                                                                                    	at androidx.media3.session.MediaControllerImplBase$$ExternalSyntheticLambda86.invoke(D8$$SyntheticClass:0)
                                                                                                    	at androidx.media3.common.util.ListenerSet$ListenerHolder.iterationFinished(ListenerSet.java:477)
                                                                                                    	at androidx.media3.common.util.ListenerSet.handleMessage(ListenerSet.java:421)
                                                                                                    	at androidx.media3.common.util.ListenerSet.$r8$lambda$rFcF5Pkb99AL585p5-2u78YfNkY(ListenerSet.java:0)
                                                                                                    	at androidx.media3.common.util.ListenerSet$$ExternalSyntheticLambda0.handleMessage(D8$$SyntheticClass:0)
                                                                                                    	at android.os.Handler.dispatchMessageImpl(Handler.java:138)
                                                                                                    	at android.os.Handler.dispatchMessage(Handler.java:125)
                                                                                                    	at android.os.Looper.loopOnce(Looper.java:296)
                                                                                                    	at android.os.Looper.loop(Looper.java:397)
                                                                                                    	at android.app.ActivityThread.main(ActivityThread.java:9521)
                                                                                                    	at java.lang.reflect.Method.invoke(Native Method)
                                                                                                    	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:575)
                                                                                                    	at com.android.internal.os.WrapperInit.main(WrapperInit.java:94)
                                                                                                    	at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
                                                                                                    	at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:367)

Comment on lines +75 to +76
@VisibleForTesting
internal fun startObservingEntities() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
@VisibleForTesting
internal fun startObservingEntities() {
private fun startObservingEntities() {

Now that the scope is given as parameters it is possible. Move this function after the overrides.

@TimoPtr TimoPtr marked this pull request as draft May 5, 2026 09:13
FletcherD added 19 commits May 5, 2026 05:42
Replaces the MediaControlStarterViewModel + repeatOnLifecycle pattern in
FrontendNavigation with a direct HaMediaSessionService.start() call in
LaunchActivity.onResume(), consistent with how SensorWorker and
WebsocketManager are already started there.
Context is only needed for Coil image loading, MediaSession construction,
and PendingIntent creation — all of which work with application context.
Hilt now injects it automatically, removing context from the @AssistedFactory
interface so callers only need to supply the per-entity config.
…ation builder

- Make mediaSession @VisibleForTesting internal (private set) instead of public
- Add val id derived from config (matches the MediaSession ID assigned internally)
- Add val isPlaying and val hasMediaContent for service-level playback checks
- Add addToService/removeFromService to encapsulate MediaSessionService registration
- Add buildNotification() so the service no longer needs to reach into the raw MediaSession
- Change observe() callback to receive HaMediaSession instead of MediaSession
- Update HaMediaSessionService to use all new APIs; remove its private buildNotification()
- Fix pre-existing ktlint import order violation in LaunchActivity.kt
…tine completion

All CommandCallback methods now return Job instead of Unit. handleCommand
ties the returned ListenableFuture<Void> to the Job's completion via
invokeOnCompletion, so the future resolves as soon as the HA network call
finishes rather than waiting for the next WebSocket state update.
The pendingCommandFuture field and its manual completion in updateState()
and setConnecting() are removed.
…MediaAction suspend

The stored actionScope field is replaced by passing the coroutineScope{} receiver directly
to getCommandCallback(scope). callMediaAction is now a private suspend fun with an internal
withContext(Dispatchers.IO) switch, removing the need for the actionScope null guard.
Extract tearDownSession and launchSession as private helpers so
reconcileSessions is a thin orchestrator responsible only for computing
the diff and dispatching to the helpers.
Move all activeSessions reads and writes inside withContext(Dispatchers.Main)
in reconcileSessions, eliminating the data race with onUpdateNotification and
onTaskRemoved which are always called on Main by Media3 and the OS.
…wnscaling

Add a size(256, 256) constraint to the Coil ImageRequest in loadBitmapAsPng so
the bitmap is delivered at the notification large icon size before compression.
This prevents Android's Icon.scaleDownIfNecessary from running on the main thread
and triggering a StrictMode CustomViolation.
…mand

After the revert of the over-scoped comment-4 change, wire up the two
behaviors the unit tests expect:
- observe() launches startObservingState() as a child and uses
  awaitCancellation() so the session stays alive after the WebSocket
  flow completes (e.g. transient disconnect).
- getCommandCallback now accepts an onCommandComplete callback; after
  each HA action succeeds, restartObservationIfNeeded() re-launches
  startObservingState() if the observation job has ended.
- Removed withContext(Dispatchers.IO) from callMediaAction: Retrofit
  suspend functions are non-blocking so Dispatchers.Default suffices,
  and this keeps the call synchronous under UnconfinedTestDispatcher.
…ation builder

Address review comment: mediaSession should not be exposed directly to the service.
HaMediaSession now exposes a stable val id, isPlaying/hasActiveMedia properties,
unregisterFrom() for teardown, and buildNotification() instead of the raw session.
NOTIFICATION_CHANNEL_ID moves to the HaMediaSession companion. Tests are updated
to capture the MediaSession via the observe() callback and use buildNotification()
for lifecycle assertions.
…oundOrStop

HaMediaSession.id and the activeSessions map key both identified the
same entity but used different separators (_ vs :). Align the separator
to : so they are identical, then pass the key string directly to
promoteForegroundOrStop instead of a hashCode int, removing the hash
collision risk and the redundant identity derivation.
SensorWorker.start(this)
lifecycleScope.launch {
WebsocketManager.start(this@LaunchActivity)
HaMediaSessionService.start(this@LaunchActivity)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can probably update the LaunchActivitTest (unit test) to verify that this is properly called in onResume.

Comment on lines +83 to +89
/**
* Unregisters this session from [service] by calling
* [MediaSessionService.removeSession]. Has no effect if the session is not currently active.
*/
fun unregisterFrom(service: MediaSessionService) {
mediaSession?.let { service.removeSession(it) }
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's not do that, we don't want to know the existence of the service within the session.

Comment on lines +222 to +225
if (!observationJob.isActive) {
Timber.d("observe: restarting observation after command for ${config.entityId}")
observationJob = launch { startObservingState() }
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You shouldn't do that, the repository should make sure that you always are connected to the WS, you don't want the session to handle the internal logic of potentially losing the WS connection.

}

private fun buildMediaSession(player: HaRemoteMediaPlayer): MediaSession = MediaSession.Builder(context, player)
.setId("${config.serverId}:${config.entityId}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
.setId("${config.serverId}:${config.entityId}")
.setId(id)

}
session.sessionActivity = PendingIntent.getActivity(
context,
"${config.serverId}:${config.entityId}".hashCode(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"${config.serverId}:${config.entityId}".hashCode(),
id.hashCode(),

Comment on lines +44 to +54
@AndroidEntryPoint
class HaMediaSessionService @VisibleForTesting constructor(private val serviceScope: CoroutineScope) :
MediaSessionService() {

@Inject constructor() : this(CoroutineScope(SupervisorJob() + Dispatchers.Default))

@Inject
lateinit var mediaControlRepository: MediaControlRepository

@Inject
lateinit var haMediaSessionFactory: HaMediaSession.Factory
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
@AndroidEntryPoint
class HaMediaSessionService @VisibleForTesting constructor(private val serviceScope: CoroutineScope) :
MediaSessionService() {
@Inject constructor() : this(CoroutineScope(SupervisorJob() + Dispatchers.Default))
@Inject
lateinit var mediaControlRepository: MediaControlRepository
@Inject
lateinit var haMediaSessionFactory: HaMediaSession.Factory
@AndroidEntryPoint
class HaMediaSessionService @VisibleForTesting constructor(private val serviceScope: CoroutineScope,
val mediaControlRepository: MediaControlRepository, val haMediaSessionFactory: HaMediaSession.Factory) :
MediaSessionService() {
@Inject constructor(val mediaControlRepository: MediaControlRepository, val haMediaSessionFactory: HaMediaSession.Factory) : this(CoroutineScope(SupervisorJob() + Dispatchers.Default), mediaControlRepository, haMediaSessionFactory)

It allows you a better control and no more lateinit var

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants