feat(android): wire MediaSession like button + reactive state
android / Build + lint + test (push) Successful in 3m54s
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:
+103
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user