v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77
+103
-3
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user