feat(android): wire MediaSession like button + reactive state
android / Build + lint + test (push) Successful in 3m54s

MinstrelPlayerService now injects LikesRepository, attaches the new
LikeMediaCallback, sets an initial unfilled CommandButton via
setMediaButtonPreferences, and launches a service-scoped job that
rebuilds the preferences list when the current track or its
server-side liked state changes.

flatMapLatest on (currentMediaItem x observeIsLiked) means the icon
mirrors cross-device likes (web tap flips the notification heart
within EventsStream propagation) and never leaks Flows across track
transitions. Initial emission on subscription guarantees the icon is
correct on the first frame the controller renders.

onDestroy now cancels the service scope before releasing the session
so the like-state job can't touch a released MediaSession.

Closes the Media3 like-button work spec'd in
docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
On-device verification still pending: phone notification, lock
screen, Pixel Watch, Android Auto, offline replay, cross-device.
This commit is contained in:
2026-06-03 09:28:37 -04:00
parent 43754d03c4
commit 551bbf83c2
@@ -2,11 +2,29 @@ package com.fabledsword.minstrel.player
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.google.common.collect.ImmutableList
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -20,29 +38,49 @@ import javax.inject.Inject
* MediaButtonReceiver is registered automatically by the library, no
* manual receiver class needed.
*
* The session advertises a single custom CommandButton — the like/heart
* toggle. [LikeMediaCallback] grants the command in `onConnect` and
* routes taps through [LikesRepository.toggleLike] so they inherit the
* offline-resilient MutationQueue path. The icon mirrors server state:
* a service-scoped coroutine collects `currentMediaItem × isLiked` and
* rebuilds the preferences list on each emission so a cross-device
* like (web tap) flips the notification heart automatically.
*
* Lifecycle:
* - onCreate: build the ExoPlayer + MediaSession once.
* - onCreate: build the ExoPlayer + MediaSession once, attach the
* callback, set initial preferences, launch the like-state job.
* - onGetSession: return the live session to any binding controller
* (system UI media card, Wear OS companion, MediaController3 clients).
* - onTaskRemoved: if the user swipes the app away, keep playing when
* audio is active (standard media-app behavior — music shouldn't
* die because the app left recents); otherwise stop the service so
* the lingering notification clears.
* - onDestroy: release the session + player.
* - onDestroy: cancel the service scope, then release the session +
* player. Cancellation comes first so the like-state job doesn't
* touch a released session.
*/
@AndroidEntryPoint
class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var playerFactory: PlayerFactory
@Inject lateinit var likesRepository: LikesRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val player = playerFactory.build()
mediaSession = MediaSession.Builder(this, player)
val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent())
.setCallback(callback)
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
.build()
mediaSession = session
serviceScope.launch { observeLikeState(session, player) }
}
/**
@@ -72,6 +110,67 @@ class MinstrelPlayerService : MediaSessionService() {
)
}
/**
* Build the like/heart CommandButton. Icon flips between
* ICON_HEART_FILLED and ICON_HEART_UNFILLED to mirror server-side
* like state. The session command is the same in both states so
* the callback's onCustomCommand handles them identically (it
* inverts the current observed state regardless of which icon was
* tapped).
*/
private fun buildLikeButton(isLiked: Boolean): CommandButton {
val icon = if (isLiked) {
CommandButton.ICON_HEART_FILLED
} else {
CommandButton.ICON_HEART_UNFILLED
}
return CommandButton.Builder(icon)
.setDisplayName(if (isLiked) "Unlike" else "Like")
.setSessionCommand(SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY))
.build()
}
/**
* Collect player.currentMediaItem changes (via a Player.Listener
* lifted into a Flow) and, for each non-null mediaId, observe its
* liked state. On every emission, rebuild the session's media
* button preferences with the icon flipped accordingly.
* flatMapLatest cancels the previous track's subscription so we
* never leak Flows across track transitions.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun observeLikeState(session: MediaSession, player: Player) {
currentMediaIdFlow(player)
.flatMapLatest { mediaId ->
if (mediaId == null) {
flowOf(false)
} else {
likesRepository.observeIsLiked(ENTITY_TRACK, mediaId)
}
}
.collect { isLiked ->
session.setMediaButtonPreferences(
ImmutableList.of(buildLikeButton(isLiked = isLiked)),
)
}
}
/**
* Lift Player.currentMediaItem changes into a Flow. Emits the
* current mediaId on subscription so the initial icon state is
* correct on the first frame the controller renders (no
* unfilled-then-flips flicker).
*/
private fun currentMediaIdFlow(player: Player) = callbackFlow<String?> {
val listener = object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
trySend(item?.mediaId)
}
}
player.addListener(listener)
awaitClose { player.removeListener(listener) }
}.onStart { emit(player.currentMediaItem?.mediaId) }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
mediaSession
@@ -85,6 +184,7 @@ class MinstrelPlayerService : MediaSessionService() {
}
override fun onDestroy() {
serviceScope.cancel()
mediaSession?.run {
player.release()
release()