feat(android): LikeMediaCallback for media-session like button
android / Build + lint + test (push) Failing after 1m29s
android / Build + lint + test (push) Failing after 1m29s
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.
This commit is contained in:
@@ -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<SessionResult> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<LikesRepository>(relaxed = true)
|
||||
private val session = mockk<MediaSession>()
|
||||
private val controllerInfo = mockk<MediaSession.ControllerInfo>()
|
||||
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<Player> { 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<Player> { 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<Player> { 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user