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