diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index bc9cbba0..ae4f3eb3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -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 { + 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()