Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
75e9d8b
Add native Android media controls for HA media_player entities
FletcherD Mar 25, 2026
20bb0b1
1. Dispatchers.Main → Dispatchers.Default in service scope
FletcherD Mar 26, 2026
3b3178d
Add volume control to native media controls
FletcherD Mar 26, 2026
ba45502
Decouple session logic from HaMediaSessionService into HaMediaSession
FletcherD Mar 26, 2026
45a112b
Stop media session service when its configured server is deleted
FletcherD Mar 26, 2026
133c278
Support multiple media_player entities as native media controls
FletcherD Mar 26, 2026
3bd574e
add COMMAND_SET_DEVICE_VOLUME and COMMAND_ADJUST_DEVICE_VOLUME back a…
FletcherD Mar 26, 2026
c83de10
Put the player into STATE_BUFFERING on disconnect when the WebSocket …
FletcherD Mar 26, 2026
7028496
Address PR review feedback on media session code
FletcherD Mar 26, 2026
4ee48ed
Use URL for artwork path
FletcherD Mar 26, 2026
2b1cbf2
Refactor Media Control settings UI to use reorderable picker similar …
FletcherD Mar 26, 2026
bbad9c2
Refactor to avoid ComposeUnstableCollections issues, remove stale lin…
FletcherD Mar 27, 2026
f484849
Store media control configuration in the database instead of PrefsRep…
FletcherD Mar 27, 2026
c434c30
Fix crash when adding media control entity; Fix artwork disappearing …
FletcherD Mar 27, 2026
e75df17
Fetch media information and artwork immediately on startObservingState
FletcherD Mar 27, 2026
16805bd
Remove Save and Clear All buttons from media control settings page, s…
FletcherD Mar 27, 2026
eea9e85
Minor cleanup
FletcherD Mar 27, 2026
cf2db98
Fix multi-entity session routing; add @AssistedInject and unit tests
FletcherD Mar 27, 2026
f11db4c
ktlint fixes
FletcherD Mar 27, 2026
2b3209d
Reactively manage sessions via observeConfiguredEntities() flow
FletcherD Mar 28, 2026
52ba3b7
Fix media controls not appearing on cold start
FletcherD Mar 28, 2026
b14c1e5
Fix media controls not appearing on cold start on V2 frontend
FletcherD Mar 29, 2026
1ad0c4e
Expose stop, mute, shuffle, and repeat functions to media controls
FletcherD Mar 29, 2026
5abd769
Merge branch 'main' into feature/native-media-controls
FletcherD Mar 29, 2026
8375610
Expose more metadata fields from media player entity
FletcherD Mar 29, 2026
045ede1
Make media controls settings page more consistent with other settings
FletcherD Mar 29, 2026
1290d3e
Remove setContentBufferedPositionMs() call which is useless
FletcherD Mar 29, 2026
d36ba76
Tapping media controls notification body opens the media player entit…
FletcherD Apr 1, 2026
5563c41
Address mechanical PR review comments from TimoPtr
FletcherD Apr 2, 2026
c5089c0
Address coroutine architecture PR review comments
FletcherD Apr 2, 2026
b65ddb0
Split up HaRemoteMediaPlayer.kt getState(); use entityFriendlyName in…
FletcherD Apr 2, 2026
23c5d38
→ use ; Remove the four fields.
FletcherD Apr 2, 2026
8702016
Address MediaControlSettings PR review comments
FletcherD Apr 2, 2026
70080ba
Fix showing entity name instead of track title; Add small app icon to…
FletcherD Apr 2, 2026
4a4f113
When disconnected, retry only once when opening app instead of in a l…
FletcherD Apr 3, 2026
ebe2801
Simplify startObservingState by moving initial fetch into observeEnti…
FletcherD Apr 3, 2026
9a2cec1
FrontendViewModel.kt: Removed appContext: Context, mediaControlReposi…
FletcherD Apr 3, 2026
09ac27e
ktlint fix
FletcherD Apr 3, 2026
06172da
loadArtworkAndUpdatePlayer now uses state.entityPictureUrl as the cac…
FletcherD Apr 5, 2026
fb0612c
Don't include .size() when requesting artwork image
FletcherD Apr 8, 2026
612a1f8
Remove ServerRegistries in MediaControlSettingsViewModel.kt; Remove '…
FletcherD Apr 8, 2026
5867fe5
HaMediaSessionTest.kt: Remove Thread.sleep
FletcherD Apr 8, 2026
b6af049
HaMediaSessionServiceTest.kt: Don't call reconcileSessions() directly…
FletcherD Apr 9, 2026
5c1251a
Remove fake 3-item playlist from HaRemoteMediaPlayer
FletcherD Apr 9, 2026
2a28179
Remove automotive and Meya Quest support for media controls
FletcherD Apr 9, 2026
0d1bd25
Remove Meta Quest support for media controls
FletcherD Apr 9, 2026
b7d9e0b
Add screenshot tests for MediaControlSettingsScreen
FletcherD Apr 9, 2026
1c9b63f
Merge origin/main into feature/native-media-controls
FletcherD Apr 9, 2026
c3641e6
Remove the reconnect() call from onStartCommand so we don't cancel We…
FletcherD Apr 14, 2026
08b8d6f
Remove retry loop from startObservingState()
FletcherD Apr 14, 2026
4278fb2
Refactor HaMediaSession.kt: Remove scope creation from this class. In…
FletcherD Apr 14, 2026
26a8a83
Code clarity in HaRemoteMediaPlayer.kt
FletcherD Apr 14, 2026
6b823b1
HaRemoteMediaPlayer.kt
FletcherD Apr 14, 2026
7de40bb
Address PR review comments (batch 1)
FletcherD Apr 14, 2026
9d48643
HaMediaSessionService: Document why onTaskRemoved skips stopSelf when…
FletcherD Apr 14, 2026
eda99bf
HaMediaSessionService: Remove repository param from startIfConfigured
FletcherD Apr 14, 2026
5766948
WebViewActivity: Revert unrelated deep-link handling change
FletcherD Apr 14, 2026
c491d11
MediaControlSettings: Move HATheme from Screen composable into Fragme…
FletcherD Apr 14, 2026
1f607e8
HaMediaSessionServiceTest: Remove redundant unmockkAll() — instance m…
FletcherD Apr 14, 2026
3b85483
MediaControlConfig: Rename position column to index
FletcherD Apr 14, 2026
5d53fe6
Revert changes to ServerSettingsPresenterImpl.kt; Revert log message …
FletcherD Apr 15, 2026
20757c4
Fix crash when removing/readding entity in settings
FletcherD Apr 15, 2026
e2c2120
Workaround for notification disappearing after adding two entities an…
FletcherD Apr 15, 2026
fd69a5d
Refactor to fix multiple entitites: Build MediaStyle notifications ou…
FletcherD Apr 15, 2026
a695d13
Set notification when playing so it can't be swiped away (dismissibl…
FletcherD Apr 15, 2026
e75dea8
Remove entity reordering from media controls settings screen
FletcherD Apr 15, 2026
1b38f33
Fix MediaControlRepositoryImplTest tests
FletcherD Apr 15, 2026
3575cfe
Fix subtitle in media control settings entity row; Fix defunct notifi…
FletcherD Apr 16, 2026
a3ffa0a
Improve media control settings screen: Don't show anything until load…
FletcherD Apr 16, 2026
0ad3588
Make activeSessions, reconcileSessions, and startObservingEntities pr…
FletcherD Apr 16, 2026
82895ff
MediaControlSettingsViewModel.kt: inject the Default dispatcher into …
FletcherD Apr 16, 2026
99b6b10
Media controls tests: Clean up properly after tests
FletcherD Apr 17, 2026
3c65412
Update screenshot tests for media control settings changes and regene…
FletcherD Apr 17, 2026
1eda4e4
Merge origin/main into feature/native-media-controls
FletcherD Apr 17, 2026
ad135f5
All `handle*()` methods return a pending `SettableFuture<Void>` (stor…
FletcherD Apr 17, 2026
e0e43d6
Cleanup test structure
FletcherD Apr 23, 2026
3b32629
Merge origin/main into feature/native-media-controls
FletcherD May 1, 2026
adee5e9
Remove unnecessary SDK_INT version check
FletcherD May 5, 2026
cb0f9d5
Merge branch 'main' into feature/native-media-controls
FletcherD May 5, 2026
304ed6c
Move media control service start to LaunchActivity
FletcherD May 5, 2026
30073b4
Use @ApplicationContext in HaMediaSession constructor
FletcherD May 5, 2026
275b4ec
Encapsulate MediaSession inside HaMediaSession, expose id and notific…
FletcherD May 5, 2026
c4b1784
Refactor CommandCallback to return Job, tie ListenableFuture to corou…
FletcherD May 5, 2026
c222e81
Remove actionScope field, pass scope to getCommandCallback, make call…
FletcherD May 5, 2026
d3794a2
Add FailFast guard in HaMediaSession.observe() for double-call
FletcherD May 5, 2026
535cb96
Remove verbose log in observe(), make startObservingState private
FletcherD May 5, 2026
9baa5b1
Use LaunchActivity for media notification tap intent
FletcherD May 5, 2026
c6c4314
Simplify loadArtworkAndUpdatePlayer in HaMediaSession
FletcherD May 5, 2026
486a859
Fix log message in resolveArtworkUrl to reference server ID
FletcherD May 5, 2026
71bb517
Split reconcileSessions into smaller helper functions
FletcherD May 5, 2026
f5a47c4
Remove unnecessary onStartCommand override in HaMediaSessionService
FletcherD May 5, 2026
4f5be54
Confine activeSessions map access to Main thread
FletcherD May 5, 2026
6f6fa04
Pre-scale album art to notification icon size to avoid main thread do…
FletcherD May 5, 2026
fddbcd1
Revert "Encapsulate MediaSession inside HaMediaSession, expose id and…
FletcherD May 6, 2026
623bb57
Restore tests: observe() stays alive after flow ends, restarts on com…
FletcherD May 6, 2026
eaf71d6
Encapsulate MediaSession inside HaMediaSession, expose id and notific…
FletcherD May 6, 2026
7c4b248
Unify session ID format with map key; use key string in promoteForegr…
FletcherD May 6, 2026
f6cd168
Merge remote-tracking branch 'fork/feature/native-media-controls' int…
FletcherD May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
Expand Down Expand Up @@ -323,6 +324,18 @@
</intent-filter>
</service>

<service
android:name=".mediacontrol.HaMediaSessionService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<!-- Exported so system media controllers can bind to the session.
Connections are filtered in MediaSession.Callback#onConnect. -->
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>

<activity android:name=".controls.HaControlsPanelActivity"
android:permission="android.permission.BIND_CONTROLS"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import io.homeassistant.companion.android.authenticator.Authenticator.Companion.
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.launch.applock.HazeLockOverlay
import io.homeassistant.companion.android.mediacontrol.HaMediaSessionService
import io.homeassistant.companion.android.sensors.SensorReceiver
import io.homeassistant.companion.android.sensors.SensorWorker
import io.homeassistant.companion.android.util.ChangeLog
Expand Down Expand Up @@ -191,6 +192,7 @@ class LaunchActivity : AppCompatActivity() {
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.

checkLocationDisabled()
changeLog.showChangeLog(this@LaunchActivity, forceShow = false)
}
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
package io.homeassistant.companion.android.mediacontrol

import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import androidx.annotation.OptIn
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig
import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber

/**
* A [MediaSessionService] that exposes one or more Home Assistant media_player entities as native
* Android media controls in the notification shade. Each configured entity gets its own
* [HaMediaSession] and [MediaSession], which Media3 registers and presents individually.
*
* Notifications are managed via [onUpdateNotification], which is called per-session by Media3
* whenever a session's player state changes. Each entity receives a notification with a unique ID
* derived from its session ID, so each entity appears as a separate card in the notification shade.
*
* This service is responsible only for the Android service lifecycle and session reconciliation.
* All per-entity session logic is delegated to [HaMediaSession].
*/
@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
Comment on lines +44 to +54
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


// Keyed by "$serverId:$entityId". Each entry pairs the session with the job running observe().
private val activeSessions = mutableMapOf<String, Pair<HaMediaSession, Job>>()

/** The notification ID last passed to [startForeground], or null if not in the foreground. */
private var foregroundNotificationId: Int? = null
private val notificationManager by lazy { NotificationManagerCompat.from(this) }

override fun onCreate() {
super.onCreate()
createNotificationChannel()
Timber.d("HaMediaSessionService created")
startObservingEntities()
}

@VisibleForTesting
internal fun startObservingEntities() {
Comment on lines +70 to +71
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.

mediaControlRepository.observeConfiguredEntities()
.onEach { entities -> reconcileSessions(entities) }
.launchIn(serviceScope)
}

// Returns null intentionally: Media3 routes each controller to the session whose ID matches
// the one it was constructed with. Returning a specific session here would cause all
// controllers (including the notification) to connect to that one session, breaking
// multi-session behavior where each entity has its own independent media control card.
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = null
Comment thread
FletcherD marked this conversation as resolved.

override fun onTaskRemoved(rootIntent: Intent?) {
val anyPlaying = activeSessions.values.any { (session, _) -> session.isPlaying }
// Keep the service alive while playback is active so the media notification remains
// visible and controllable from the notification shade after the app is dismissed.
// If nothing is playing there is no reason to keep the service alive.
// Note: there is no automatic stop when playback ends after this point — the service
// will only stop when the user removes all configured entities, which causes
// reconcileSessions to call stopSelf() on an empty list.
if (!anyPlaying) {
stopSelf()
}
}

/**
* Called by Media3 whenever a session's player state changes and the notification needs to be
* updated. Each session gets a notification with a unique ID derived from the session's ID,
* so each entity appears as its own card in the media controls carousel.
*/
// POST_NOTIFICATIONS is not required for notifications linked to an active MediaSession
// (MediaStyle notifications). This is a platform-level guarantee on API 33+; on API < 33
// the permission does not exist at all.
@SuppressLint("MissingPermission")
@OptIn(UnstableApi::class)
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
val notificationId = session.id.hashCode()

// A session not in activeSessions is being torn down. removeSession() and player.release()
// both trigger onUpdateNotification, so without this guard we would re-post a notification
// we just cancelled, leaving a zombie media control card after removal.
val haSession = activeSessions.values.firstOrNull { (haSession, _) -> haSession.id == session.id }?.first

if (haSession == null || session.player.mediaItemCount == 0) {
// Entity is off, no state has arrived yet, or the session is being torn down.
notificationManager.cancel(notificationId)
if (foregroundNotificationId == notificationId) {
promoteForegroundOrStop(excludeKey = session.id)
}
return
}

val notification = haSession.buildNotification() ?: return
if (foregroundNotificationId == null && startInForegroundRequired) {
// Service is not yet in the foreground and playback requires it — start foreground
// with this session's notification. All subsequent sessions (and updates to this one)
// go through notificationManager.notify() to avoid replacing the foreground
// notification ID, which would dismiss the previously-shown notification on Android 13+.
startForeground(notificationId, notification)
foregroundNotificationId = notificationId
} else {
// Service is already in the foreground (or foreground not yet required).
// notificationManager.notify() works for both regular notifications and for updating
// the foreground notification in-place when the ID matches.
notificationManager.notify(notificationId, notification)
}
}

override fun onDestroy() {
Timber.d("HaMediaSessionService destroyed")
if (foregroundNotificationId != null) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
foregroundNotificationId = null
}
// Snapshot and clear activeSessions before calling removeSession so that the
// onUpdateNotification guard (!isActive check) treats these sessions as inactive and
// cancels rather than re-posts their notifications during teardown.
val sessionsToClean = activeSessions.values.toList()
activeSessions.clear()
sessionsToClean.forEach { (session, job) ->
notificationManager.cancel(session.id.hashCode())
session.unregisterFrom(this)
job.cancel()
}
serviceScope.cancel()
super.onDestroy()
}

private suspend fun reconcileSessions(configuredEntities: List<MediaControlEntityConfig>) {
Comment thread
FletcherD marked this conversation as resolved.
Timber.d(
"reconcileSessions: received ${configuredEntities.size} entities=${configuredEntities.map {
it.entityId
}}",
)

if (configuredEntities.isEmpty()) {
Timber.d("No media control entities configured, stopping service")
withContext(Dispatchers.Main) { stopSelf() }
return
}

// All activeSessions reads and writes are confined to the Main thread to avoid data races
// with onUpdateNotification and onTaskRemoved, which are always called on Main by the OS
// and Media3. The diff is computed here on Main as well since it operates on small sets.
withContext(Dispatchers.Main) {
val desiredKeys = configuredEntities.map { it.sessionKey() }.toSet()
val currentKeys = activeSessions.keys.toSet()

Timber.d("reconcileSessions: desiredKeys=$desiredKeys, currentKeys=$currentKeys")

val toRemove = (currentKeys - desiredKeys).mapNotNull { key ->
val pair = activeSessions.remove(key) ?: return@mapNotNull null
key to pair
}
val toAdd = (desiredKeys - currentKeys).map { key ->
val entityConfig = configuredEntities.first { it.sessionKey() == key }
key to haMediaSessionFactory.create(config = entityConfig)
}

Timber.d("reconcileSessions: toRemove=${toRemove.map { it.first }}, toAdd=${toAdd.map { it.first }}")

toRemove.forEach { (key, pair) -> tearDownSession(key, pair) }
toAdd.forEach { (key, session) -> launchSession(key, session) }

Timber.d("reconcileSessions: done, activeSessions=${activeSessions.keys.toList()}")
}
}

/**
* Cancels the notification for a session, unregisters it from the service, and joins the
* observation coroutine so all Media3 resources are released before returning.
* Must be called from the Main dispatcher.
*/
private suspend fun tearDownSession(key: String, pair: Pair<HaMediaSession, Job>) {
val (haSession, job) = pair
val notificationId = key.hashCode()
notificationManager.cancel(notificationId)
if (foregroundNotificationId == notificationId) {
promoteForegroundOrStop(excludeKey = key)
}
haSession.unregisterFrom(this)
job.cancelAndJoin()
Timber.d("Removed media session for $key")
}

/**
* Launches the observation coroutine for a new session, registers it with the service, and
* stores it in [activeSessions].
* Must be called from the Main dispatcher (required by [MediaSessionService.addSession]).
*/
private fun launchSession(key: String, session: HaMediaSession) {
val job = serviceScope.launch {
session.observe { mediaSession -> addSession(mediaSession) }
}
activeSessions[key] = session to job
Timber.d("Added media session for $key")
}

/**
* Promotes a remaining active session to the foreground notification when the current
* foreground session is removed or goes idle. If no active session has media content,
* stops the foreground state.
*
* @param excludeKey The map key of the session being removed, to skip it when searching
* for a replacement.
*/
private fun promoteForegroundOrStop(excludeKey: String) {
val nextSession = activeSessions.entries
.firstOrNull { (key, pair) -> key != excludeKey && pair.first.hasActiveMedia }
?.value?.first

if (nextSession != null) {
val nextId = nextSession.id.hashCode()
val notification = nextSession.buildNotification() ?: return
startForeground(nextId, notification)
foregroundNotificationId = nextId
Timber.d("promoteForegroundOrStop: promoted session ${nextSession.id}")
} else {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
foregroundNotificationId = null
Timber.d("promoteForegroundOrStop: no active sessions, stopped foreground")
}
}

private fun createNotificationChannel() {
val channel = NotificationChannel(
HaMediaSession.NOTIFICATION_CHANNEL_ID,
getString(commonR.string.media_controls),
NotificationManager.IMPORTANCE_LOW,
).apply {
setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
}

companion object {
/**
* Starts the service. Should be called from a foreground context (e.g. Activity) to avoid
* Android 15+ restrictions on starting mediaPlayback foreground services from background.
* If no entities are configured the service will stop itself immediately after starting.
* Once running, the service observes the database and reconciles sessions automatically.
*/
fun start(context: Context) {
Timber.d("Starting HaMediaSessionService")
try {
context.startService(Intent(context, HaMediaSessionService::class.java))
} catch (e: Exception) {
Timber.e(e, "Failed to start HaMediaSessionService")
}
}
}
}

private fun MediaControlEntityConfig.sessionKey(): String = "$serverId:$entityId"
Loading