v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77

Merged
bvandeusen merged 29 commits from dev into main 2026-06-03 14:09:23 -04:00
Showing only changes of commit 551bbf83c2 - Show all commits
@@ -2,11 +2,29 @@ package com.fabledsword.minstrel.player
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity 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 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 import javax.inject.Inject
/** /**
@@ -20,29 +38,49 @@ import javax.inject.Inject
* MediaButtonReceiver is registered automatically by the library, no * MediaButtonReceiver is registered automatically by the library, no
* manual receiver class needed. * 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: * 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 * - onGetSession: return the live session to any binding controller
* (system UI media card, Wear OS companion, MediaController3 clients). * (system UI media card, Wear OS companion, MediaController3 clients).
* - onTaskRemoved: if the user swipes the app away, keep playing when * - onTaskRemoved: if the user swipes the app away, keep playing when
* audio is active (standard media-app behavior — music shouldn't * audio is active (standard media-app behavior — music shouldn't
* die because the app left recents); otherwise stop the service so * die because the app left recents); otherwise stop the service so
* the lingering notification clears. * 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 @AndroidEntryPoint
class MinstrelPlayerService : MediaSessionService() { class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var playerFactory: PlayerFactory @Inject lateinit var playerFactory: PlayerFactory
@Inject lateinit var likesRepository: LikesRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val player = playerFactory.build() val player = playerFactory.build()
mediaSession = MediaSession.Builder(this, player) val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent()) .setSessionActivity(buildNowPlayingPendingIntent())
.setCallback(callback)
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
.build() .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<String?> {
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? = override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
mediaSession mediaSession
@@ -85,6 +184,7 @@ class MinstrelPlayerService : MediaSessionService() {
} }
override fun onDestroy() { override fun onDestroy() {
serviceScope.cancel()
mediaSession?.run { mediaSession?.run {
player.release() player.release()
release() release()