-
-
Notifications
You must be signed in to change notification settings - Fork 957
Add native Android media controls for HA media_player entities #6626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
75e9d8b
20bb0b1
3b3178d
ba45502
45a112b
133c278
3bd574e
c83de10
7028496
4ee48ed
2b1cbf2
bbad9c2
f484849
c434c30
e75df17
16805bd
eea9e85
cf2db98
f11db4c
2b3209d
52ba3b7
b14c1e5
1ad0c4e
5abd769
8375610
045ede1
1290d3e
d36ba76
5563c41
c5089c0
b65ddb0
23c5d38
8702016
70080ba
4a4f113
ebe2801
9a2cec1
09ac27e
06172da
fb0612c
612a1f8
5867fe5
b6af049
5c1251a
2a28179
0d1bd25
b7d9e0b
1c9b63f
c3641e6
08b8d6f
4278fb2
26a8a83
6b823b1
7de40bb
9d48643
eda99bf
5766948
c491d11
1f607e8
3b85483
5d53fe6
20757c4
e2c2120
fd69a5d
a695d13
e75dea8
1b38f33
3575cfe
a3ffa0a
0ad3588
82895ff
99b6b10
3c65412
1eda4e4
ad135f5
e0e43d6
3b32629
adee5e9
cb0f9d5
304ed6c
30073b4
275b4ec
c4b1784
c222e81
d3794a2
535cb96
9baa5b1
c6c4314
486a859
71bb517
f5a47c4
4f5be54
6f6fa04
fddbcd1
623bb57
eaf71d6
7c4b248
f6cd168
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
It allows you a better control and no more |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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 | ||||||||||||||||||||||||||||||||||||
|
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>) { | ||||||||||||||||||||||||||||||||||||
|
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" | ||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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.