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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user