From d37ef56bb1fb2e86371b05bce18d8d7d05b7702a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 23:48:25 -0400 Subject: [PATCH] feat(android): LikeMediaCallback for media-session like button New MediaSession.Callback that grants CMD_TOGGLE_LIKE in onConnect (Media3 issue #2679 guard) and routes onCustomCommand through LikesRepository.toggleLike so notification/lock-screen/Pixel-Watch taps inherit the offline-resilient MutationQueue path. Unit tests cover the onConnect grant, current-state inversion in both directions, no-op when there is no current MediaItem, and rejection of unknown custom actions. MinstrelPlayerService wiring lands in a follow-up commit. --- .../minstrel/player/LikeMediaCallback.kt | 72 ++++++++++++ .../minstrel/player/LikeMediaCallbackTest.kt | 103 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/LikeMediaCallback.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/LikeMediaCallbackTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/LikeMediaCallback.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/LikeMediaCallback.kt new file mode 100644 index 00000000..1b8cf73f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/LikeMediaCallback.kt @@ -0,0 +1,72 @@ +package com.fabledsword.minstrel.player + +import android.os.Bundle +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * MediaSession callback that adds the like/heart custom command to + * every connecting controller (notification, lock screen, Pixel Watch, + * Android Auto) and routes taps through [LikesRepository.toggleLike] + * so they inherit the offline-resilient MutationQueue path. + * + * The [onConnect] grant is REQUIRED — without it the button is + * silently invisible on some surfaces (Media3 issue #2679). See the + * spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md. + * + * Suspend work in [onCustomCommand] is launched on [scope] (a + * service-lifetime scope) so the callback returns synchronously and + * the controller does not block on Room + REST. State updates flow + * back through [LikesRepository.observeIsLiked] so the icon refreshes + * via the like-state job in [MinstrelPlayerService]. + */ +class LikeMediaCallback( + private val likes: LikesRepository, + private val scope: CoroutineScope, +) : MediaSession.Callback { + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult { + val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY)) + .build() + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(grants) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + if (customCommand.customAction != CMD_TOGGLE_LIKE) { + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)) + } + val mediaId = session.player.currentMediaItem?.mediaId + ?: return Futures.immediateFuture(SessionResult(SessionResult.RESULT_INFO_SKIPPED)) + scope.launch { + val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first() + likes.toggleLike(ENTITY_TRACK, mediaId, !current) + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + companion object { + /** Custom-action key for the like/heart button. Namespaced so + * future custom commands (output picker, etc.) don't collide. */ + const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like" + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/LikeMediaCallbackTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/LikeMediaCallbackTest.kt new file mode 100644 index 00000000..9a7685ce --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/LikeMediaCallbackTest.kt @@ -0,0 +1,103 @@ +package com.fabledsword.minstrel.player + +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LikeMediaCallbackTest { + + private val scope = TestScope(UnconfinedTestDispatcher()) + private val likes = mockk(relaxed = true) + private val session = mockk() + private val controllerInfo = mockk() + private val callback = LikeMediaCallback(likes, scope) + + @Test + fun `onConnect adds CMD_TOGGLE_LIKE to available session commands`() { + val result = callback.onConnect(session, controllerInfo) + assertTrue( + result.availableSessionCommands.contains( + SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY), + ), + ) + } + + @Test + fun `onCustomCommand routes toggle when current track has a mediaId`() { + val item = MediaItem.Builder().setMediaId("track-abc").build() + val player = mockk { every { currentMediaItem } returns item } + every { session.player } returns player + coEvery { likes.observeIsLiked(ENTITY_TRACK, "track-abc") } returns flowOf(false) + + val future = callback.onCustomCommand( + session, + controllerInfo, + SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY), + Bundle.EMPTY, + ) + + assertEquals(SessionResult.RESULT_SUCCESS, future.get().resultCode) + coVerify(exactly = 1) { likes.toggleLike(ENTITY_TRACK, "track-abc", true) } + } + + @Test + fun `onCustomCommand inverts current liked state`() { + val item = MediaItem.Builder().setMediaId("track-xyz").build() + val player = mockk { every { currentMediaItem } returns item } + every { session.player } returns player + coEvery { likes.observeIsLiked(ENTITY_TRACK, "track-xyz") } returns flowOf(true) + + callback.onCustomCommand( + session, + controllerInfo, + SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY), + Bundle.EMPTY, + ) + + coVerify(exactly = 1) { likes.toggleLike(ENTITY_TRACK, "track-xyz", false) } + } + + @Test + fun `onCustomCommand is no-op when no currentMediaItem`() { + val player = mockk { every { currentMediaItem } returns null } + every { session.player } returns player + + val future = callback.onCustomCommand( + session, + controllerInfo, + SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY), + Bundle.EMPTY, + ) + + assertEquals(SessionResult.RESULT_INFO_SKIPPED, future.get().resultCode) + coVerify(exactly = 0) { likes.toggleLike(any(), any(), any()) } + } + + @Test + fun `onCustomCommand rejects unknown actions`() { + val future = callback.onCustomCommand( + session, + controllerInfo, + SessionCommand("garbage", Bundle.EMPTY), + Bundle.EMPTY, + ) + assertEquals(SessionResult.RESULT_ERROR_NOT_SUPPORTED, future.get().resultCode) + } +}