Add native Android media controls for HA media_player entities - #6626
Add native Android media controls for HA media_player entities#6626FletcherD wants to merge 137 commits into
Conversation
There was a problem hiding this comment.
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 configuredmedia_playerand map HA state/attributes into media metadata and supported commands - Adds
HaMediaSessionServiceandHaRemoteMediaPlayerto 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) |
c9a2d04 to
785d6a5
Compare
Screen_recording_20260325_145042.mp4When you open the setting screen, it has a weird animation that shouldn't be there. |
|
@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. |
|
Thanks for the comments, they're good ideas.
I exposed volume set/adjust which also allows the volume to be adjusted with the hardware buttons. |
Adds multi-server variant references and refreshes existing ones.
Resolve changelog conflict: keep 2026.5.3 version and include both the native media controls entry and the upstream bug fixes entry.
Two compounding problems caused the notification to lag several seconds
behind the HA frontend when skipping songs quickly:
1. collect {} is sequential: every state emission waited for the previous
artwork load (including PNG encoding) to complete before processing.
Rapid skipping queued all intermediate states, showing each song in
turn long after it stopped playing. Fix: switch to collectLatest so
a new emission cancels the in-flight artwork fetch for the previous
state, and stale intermediate states are never processed.
2. PNG encoding of large album art took 6–7 s (a 4000×4000 bitmap with
CompressFormat.PNG, 100 — PNG ignores the quality parameter and always
does its slowest pass). Fix: switch to CompressFormat.JPEG, 90, which
encodes in milliseconds.
Additionally, the player is now updated immediately on each state arrival
(with cached bytes if the URL is unchanged, or null bytes if it changed),
so metadata and playback state reach the notification without waiting for
the IO-bound artwork fetch to complete.
Rename artworkPngBytes → artworkBytes throughout.
- Switch collect → collectLatest so rapid track changes cancel stale in-flight artwork loads, preventing a queue of intermediate states from showing in turn. - Update player immediately on each state arrival (metadata, playback state) without waiting for IO; keep old artwork bytes in the player until the new artwork finishes loading, eliminating the blank gap that appeared between tracks when the URL changed. - Switch artwork encoding from PNG to JPEG (quality 90). PNG ignored the quality parameter and always ran its slowest compression pass; a 4000×4000 image took 6–7 s. JPEG encodes the same image in milliseconds. - Replace fixed-square prescaling with a proportional scaleDownIfNecessary helper that mirrors the AOSP algorithm: only scales down when the bitmap exceeds notification_large_icon_width, preserves aspect ratio. Runs on IO to avoid the StrictMode CustomViolation triggered on API 36+. - Add detailed Timber logging throughout the artwork path (state arrival, cache hits/misses, URL resolution, Coil fetch, JPEG encoding, scaling, player updates) to support timeline analysis. - Rename artworkPngBytes → artworkBytes throughout.
jpelgrom
left a comment
There was a problem hiding this comment.
There's way too much complexity in one PR here IMO but that's probably too late now. Only checked the app module so far.
| private suspend fun <T> loadRegistry(serverId: Int, name: String, loader: suspend (Int) -> List<T>?): List<T> = | ||
| try { | ||
| loader(serverId).orEmpty() | ||
| } catch (e: CancellationException) { | ||
| throw e | ||
| } catch (e: Exception) { | ||
| Timber.e(e, "Couldn't load $name for server $serverId") | ||
| emptyList() | ||
| } |
There was a problem hiding this comment.
This function feels weird to have here. @TimoPtr was this previously discussed in the huge list of comments? I would consider this maybe as a generic util function?
There was a problem hiding this comment.
I noticed this pattern is used pretty much the same in other ViewModels like ManageTilesViewModel and ManageAndroidAutoViewModel. Would definitely make sense to extract it to a shared util as a follow up.
There was a problem hiding this comment.
Or extract it in a shared util function before, so we have less code in this PR
There was a problem hiding this comment.
Extracted it into a shared util: loadListOrEmpty in common/.../common/util/ListLoader.kt (with unit tests), and this ViewModel now uses it. I kept the migration of ManageTilesViewModel/ManageAndroidAutoViewModel out of this PR to avoid growing it further — happy to do that in a small follow-up PR.
| } | ||
|
|
||
| findPreference<PreferenceCategory>("media_controls")?.let { | ||
| it.isVisible = !isAutomotive && !QuestUtil.isQuest |
There was a problem hiding this comment.
Why not on Automotive? It may be in the previous comments but there is too much too read all of them to find out.
There was a problem hiding this comment.
I have no idea if it will work on automotive, since Google doesn't allow apps to open settings on automotive apparently. But TimoPtr says an incoming change may allow it, and anyway it doesn't hurt to allow it on automotive, so I'll add it.
There was a problem hiding this comment.
We've merged the PR that allow opening the settings so it should allow setting up the media player controls. But it needs to be tested before allowing it. You can do it by using a automotive emulator.
There was a problem hiding this comment.
Please test this, as it will reduce complexity in this PR if we don't have to exclude Automotive.
There was a problem hiding this comment.
Following up on the thread above — I reverted the automotive enablement for now so it can be verified properly on an automotive emulator in a dedicated follow-up PR. The settings entry is hidden on Automotive again (!isAutomotive), matching the previously reviewed state.
There was a problem hiding this comment.
Ok I've tested by removing this and it doesn't crash but does nothing. So keep it like this.
Pressing the volume button triggered a ~1-2 second backward jump in the media notification seek bar. The pending future created by volume commands deferred getState() until the WebSocket volume update arrived. That update only changes volume_level (HA uses delta encoding), leaving media_position unchanged. When getState() re-anchored SimpleBasePlayer to the same stale raw position, the bar jumped back by the time elapsed since the last HA position report. Fix: track the position anchor locally using the device clock inside HaRemoteMediaPlayer. The anchor only resets when media_position actually changes value or when playback transitions from non-playing to playing. Volume/mute/shuffle/repeat updates leave the anchor untouched, so getState() computes the correct compensated position (anchor + elapsed) and there is no visible jump.
Switching from DefaultMediaNotificationProvider to a custom buildNotification() dropped the setMediaButtonPreferences() call that registers shuffle/repeat buttons with the session. Without it, Media3 only surfaces the default transport controls (play/pause, next, previous) in the media widget; shuffle and repeat, being non-standard, are omitted. Fix: call session.setMediaButtonPreferences() on every notification update with CommandButton entries for shuffle (ICON_SHUFFLE_ON/OFF) and repeat (ICON_REPEAT_OFF/ ALL/ONE) when those commands are available. Each button encodes the target state as the second setPlayerCommand argument so tapping cycles to the correct next mode.
- Remove redundant KDoc main-thread prose covered by @mainthread annotations - Use @return tag format for boolean property KDocs - Remove redundant @OptIn(UnstableApi::class) on buildNotification() (class-level annotation covers it) - Infer buildMap type params; use bitmap.scale() KTX extension over Bitmap.createScaledBitmap - Fix scaleDownIfNecessary KDoc reference (was pointing at nonexistent Icon method) - Convert // field comment to KDoc on activeSessions - Merge stacked comment block into KDoc on onUpdateNotification - Remove duplicate zombie-guard comment (covered by the inline comment below it) - Rename "Main dispatcher" to "Main thread" in tearDownSession/launchSession KDocs - Remove Player. qualifiers from inherited COMMAND_* and REPEAT_MODE_* constants - Add @return tag to handleCommand KDoc; fix block comment to KDoc on VOLUME_SCALE - Inline C.TIME_UNSET; remove single-use DURATION_UNSET_US alias - Remove private val from backgroundDispatcher constructor param (only used in init) - Remove redundant KDoc block from test (test name is self-documenting)
- Consolidate two withContext(Dispatchers.Main) blocks in reconcileSessions into one wrapping the full body, using return@withContext for early exit - Rename desiredKeys/currentKeys to desiredSessionIds/currentSessionIds to better reflect that the IDs are composite serverId:entityId strings - Guard NotificationChannel creation with SDK_INT >= O check since minSdk is 23 and NotificationChannel is an API 26 class
Remove the !isAutomotive guard from the media controls settings preference so it is visible on Automotive OS. Add the service declaration and FOREGROUND_SERVICE_MEDIA_PLAYBACK permission to the automotive manifest so HaMediaSessionService can run there.
Identifying the entity to remove by index is racy: the configured list can change between the user tapping remove and the coroutine executing. Removing by config equality is safe regardless of concurrent updates.
|
@FletcherD There are some merge conflicts and open comments to check. Please also rebase this on the latest commit from main/merge main so all tests in CI will work. |
…controls # Conflicts: # app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt # app/src/main/res/xml/changelog_master.xml # common/schemas/io.homeassistant.companion.android.database.AppDatabase/52.json # common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt # common/src/main/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImpl.kt # common/src/test/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImplTest.kt
Main independently used database version 52, so the media control table migration moves to version 53. Claude-Session: https://claude.ai/code/session_012gGmFUzuWpqFhWUcEnXDvC
- Restore smart cast in HaRemoteMediaPlayer.updateState that the earlier ktlint reformat broke by hoisting the expression out of the null check - Drop ConsoleLogExtension/ConsoleLogRule usages; main replaced them with the auto-registered ConsoleLogPlatformListener Claude-Session: https://claude.ai/code/session_012gGmFUzuWpqFhWUcEnXDvC
Automotive support needs to be verified on an automotive emulator before it can ship (settings access on Automotive via home-assistant#6834 is still pending Google approval). Defer it to a follow-up PR to keep this one testable. This reverts commit b263ef0. Claude-Session: https://claude.ai/code/session_012gGmFUzuWpqFhWUcEnXDvC
Addresses review feedback that the generic load-or-empty pattern in MediaControlSettingsViewModel should live in a shared util. Other ViewModels using the same pattern (ManageTilesViewModel, ManageAndroidAutoViewModel) can migrate in a follow-up PR. Claude-Session: https://claude.ai/code/session_012gGmFUzuWpqFhWUcEnXDvC
The reasoning is already documented on scaleDownIfNecessary itself. Claude-Session: https://claude.ai/code/session_012gGmFUzuWpqFhWUcEnXDvC
Aligns with the convention introduced on main and addresses the ObsoleteSdkInt lint warning flagged on the automotive variant. Claude-Session: https://claude.ai/code/session_012gGmFUzuWpqFhWUcEnXDvC
|
Merged latest main and addressed the open comments:
Media-control unit tests, |
This has been approved by Google FYI.
|
| * Call [observe] to start the session. The session and its Media3 resources are created when | ||
| * [observe] is called and released automatically when the calling coroutine is cancelled. | ||
| * | ||
| * @param context Application context used for Coil image loading and [MediaSession] construction. |
There was a problem hiding this comment.
| * @param context Application context used for Coil image loading and [MediaSession] construction. | |
| * @param context Application context. |
You'r also using it for other things like string loading.
| // SupervisorJob without a parent: command failures don't propagate to the | ||
| // observation scope, and this scope does not block coroutineScope from completing | ||
| // when the entity state flow ends naturally. Cancelled explicitly in the finally block. | ||
| val commandScope = CoroutineScope( |
There was a problem hiding this comment.
| // SupervisorJob without a parent: command failures don't propagate to the | |
| // observation scope, and this scope does not block coroutineScope from completing | |
| // when the entity state flow ends naturally. Cancelled explicitly in the finally block. | |
| val commandScope = CoroutineScope( | |
| // Dedicated scope to not block coroutineScope from completing | |
| // when the entity state flow ends naturally. Cancelled explicitly in the finally block. | |
| val commandScope = CoroutineScope( |
| else -> Unit | ||
| } | ||
| } | ||
| Timber.d("startObservingState: flow collection ended for ${config.entityId}") |
There was a problem hiding this comment.
You are still collecting
| Timber.d("startObservingState: flow collection ended for ${config.entityId}") |
| /** | ||
| * FLAG_ACTIVITY_NEW_TASK is required when starting an activity from a service context | ||
| * (PendingIntents from notifications always fire in a non-Activity context). | ||
| * FLAG_ACTIVITY_SINGLE_TOP prevents stacking a redundant WebViewActivity if one is | ||
| * already at the top; onNewIntent delivers the path to the existing instance instead. | ||
| */ | ||
| val tapIntent = LaunchActivity.newInstance( |
There was a problem hiding this comment.
| /** | |
| * FLAG_ACTIVITY_NEW_TASK is required when starting an activity from a service context | |
| * (PendingIntents from notifications always fire in a non-Activity context). | |
| * FLAG_ACTIVITY_SINGLE_TOP prevents stacking a redundant WebViewActivity if one is | |
| * already at the top; onNewIntent delivers the path to the existing instance instead. | |
| */ | |
| val tapIntent = LaunchActivity.newInstance( | |
| val tapIntent = LaunchActivity.newInstance( |
| } | ||
|
|
||
| override fun onDestroy() { | ||
| Timber.d("HaMediaSessionService destroyed") |
There was a problem hiding this comment.
Let's call this at the end of the method.
| ) | ||
| if (state.configuredEntityItems.none { it.config == config }) { | ||
| val newConfigs = state.configuredEntityItems.map { it.config } + config | ||
| mediaControlRepository.setConfiguredEntities(newConfigs) |
There was a problem hiding this comment.
Having a simpler addConfig to the repository would simplify things.
| } | ||
|
|
||
| private suspend fun loadMediaPlayerEntities(serverId: Int): List<Entity> = | ||
| loadListOrEmpty("media_player entities for server $serverId") { |
There was a problem hiding this comment.
Keep the impl of loadListOrEmpty within this file for now the name is missleading out of context. I'll work on improving the EntityPicker experience but for now keep things here.
| deviceRegistry = uiState.deviceRegistryForServer(uiState.selectedServerId), | ||
| areaRegistry = uiState.areaRegistryForServer(uiState.selectedServerId), | ||
| modifier = modifier.padding(horizontal = HADimens.SPACE4), | ||
| ) |
| } | ||
|
|
||
| findPreference<PreferenceCategory>("media_controls")?.let { | ||
| it.isVisible = !isAutomotive && !QuestUtil.isQuest |
There was a problem hiding this comment.
Ok I've tested by removing this and it doesn't crash but does nothing. So keep it like this.
| fun observeConfiguredEntities(): Flow<List<MediaControlEntityConfig>> | ||
|
|
||
| /** Replaces the full list of configured media_player entities. */ | ||
| suspend fun setConfiguredEntities(entities: List<MediaControlEntityConfig>) |
There was a problem hiding this comment.
Replace this by 2 method add/remove


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_playerentities 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
Screenshots
Link to pull request in documentation repositories
User Documentation PR: home-assistant/companion.home-assistant#1304