feat: notification-heart-rating

This commit is contained in:
eddyizm 2025-10-01 22:27:26 -07:00
parent 5891ec800c
commit a940af934c
No known key found for this signature in database
GPG key ID: CF5F671829E8158A
4 changed files with 286 additions and 79 deletions

View file

@ -122,4 +122,7 @@ object Constants {
const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
}

View file

@ -88,6 +88,9 @@
<string name="error_required">Required</string>
<string name="error_server_prefix">http or https prefix required</string>
<string name="exo_download_notification_channel_name">Downloads</string>
<string name="exo_controls_heart_off_description">Toggle Heart off</string>
<string name="exo_controls_heart_on_description">Toggle Heart on</string>
<string name="cast_expanded_controller_loading">Loading…</string>
<string name="filter_info_selection">Select two or more filters</string>
<string name="filter_title">Filter</string>
<string name="filter_artist">Filter artists</string>

View file

@ -2,21 +2,41 @@ package com.cappielloantonio.tempo.service
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.annotation.OptIn
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.subsonic.base.ApiResponse
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_LOADING
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
open class MediaLibrarySessionCallback(
context: Context,
@ -28,82 +48,253 @@ open class MediaLibrarySessionCallback(
MediaBrowserTree.initialize(automotiveRepository)
}
private val shuffleCommandButtons: List<CommandButton> = listOf(
CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_off).build(),
private val customCommandToggleShuffleModeOn = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_off).build()
CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_on).build()
private val customCommandToggleShuffleModeOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_on).build()
private val customCommandToggleRepeatModeOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_off)
.build()
private val customCommandToggleRepeatModeOne = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_one)
.build()
private val customCommandToggleRepeatModeAll = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_all)
.build()
private val customCommandToggleHeartOn = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_heart_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY
)
)
.setIconResId(R.drawable.ic_favorite)
.build()
private val customCommandToggleHeartOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_heart_off_description))
.setSessionCommand(
SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
)
.setIconResId(R.drawable.ic_favorites_outlined)
.build()
// Fake Command while waiting for like update command
private val customCommandToggleHeartLoading = CommandButton.Builder()
.setDisplayName(context.getString(R.string.cast_expanded_controller_loading))
.setSessionCommand(
SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_LOADING, Bundle.EMPTY)
)
.setIconResId(R.drawable.ic_bookmark_sync)
.build()
private val customLayoutCommandButtons = listOf(
customCommandToggleShuffleModeOn,
customCommandToggleShuffleModeOff,
customCommandToggleRepeatModeOff,
customCommandToggleRepeatModeOne,
customCommandToggleRepeatModeAll,
customCommandToggleHeartOn,
customCommandToggleHeartOff,
customCommandToggleHeartLoading,
)
private val repeatCommandButtons: List<CommandButton> = listOf(
CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_off)
.build(),
CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_one)
.build(),
CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_all)
.build()
)
private val customLayoutCommandButtons: List<CommandButton> =
shuffleCommandButtons + repeatCommandButtons
@OptIn(UnstableApi::class)
val mediaNotificationSessionCommands =
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.also { builder ->
(shuffleCommandButtons + repeatCommandButtons).forEach { commandButton ->
customLayoutCommandButtons.forEach { commandButton ->
commandButton.sessionCommand?.let { builder.add(it) }
}
}.build()
fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
val shuffle = shuffleCommandButtons[if (player.shuffleModeEnabled) 1 else 0]
val repeat = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> repeatCommandButtons[1]
Player.REPEAT_MODE_ALL -> repeatCommandButtons[2]
else -> repeatCommandButtons[0]
}
return ImmutableList.of(shuffle, repeat)
}
@OptIn(UnstableApi::class)
override fun onConnect(
session: MediaSession, controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
session.player.addListener(object : Player.Listener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
updateMediaNotificationCustomLayout(session)
}
override fun onRepeatModeChanged(repeatMode: Int) {
updateMediaNotificationCustomLayout(session)
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
updateMediaNotificationCustomLayout(session)
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateMediaNotificationCustomLayout(session)
}
})
// FIXME: I'm not sure this if is required anymore
if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
controller
) || session.isAutoCompanionController(controller)
) {
val customLayout = buildCustomLayout(session.player)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
.setCustomLayout(customLayout).build()
.setCustomLayout(buildCustomLayout(session.player))
.build()
}
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
}
// Update the mediaNotification after some changes
@OptIn(UnstableApi::class)
private fun updateMediaNotificationCustomLayout(
session: MediaSession,
isRatingPending: Boolean = false
) {
session.setCustomLayout(
session.mediaNotificationControllerInfo!!,
buildCustomLayout(session.player, isRatingPending)
)
}
private fun buildCustomLayout(player: Player, isRatingPending: Boolean = false): ImmutableList<CommandButton> {
val customLayout = mutableListOf<CommandButton>()
//TODO: create a user setting to decide which of the buttons to show on the mini player
// // Add shuffle button
// customLayout.add(
// if (player.shuffleModeEnabled) customCommandToggleShuffleModeOff else customCommandToggleShuffleModeOn
// )
// Add repeat button
val repeatButton = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> customCommandToggleRepeatModeOne
Player.REPEAT_MODE_ALL -> customCommandToggleRepeatModeAll
else -> customCommandToggleRepeatModeOff
}
customLayout.add(repeatButton)
// HEART_DEBUG logging
Log.d("HEART_DEBUG:", "Current media item: ${player.currentMediaItem}")
Log.d("HEART_DEBUG:", "User rating: ${player.mediaMetadata.userRating}")
Log.d("HEART_DEBUG:", "Is rating pending: $isRatingPending")
// Add heart button if there's a current media item
if (player.currentMediaItem != null) {
if (isRatingPending) {
customLayout.add(customCommandToggleHeartLoading)
} else if ((player.mediaMetadata.userRating as HeartRating?)?.isHeart == true) {
customLayout.add(customCommandToggleHeartOff)
} else {
customLayout.add(customCommandToggleHeartOn)
}
} else {
Log.d("HEART_DEBUG:", "No current media item - skipping heart button")
}
return ImmutableList.copyOf(customLayout)
}
// Setting rating without a mediaId will set the currently listened mediaId
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
return onSetRating(session, controller, session.player.currentMediaItem!!.mediaId, rating)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaId: String,
rating: Rating
): ListenableFuture<SessionResult> {
val isStaring = (rating as HeartRating).isHeart
val networkCall = if (isStaring)
App.getSubsonicClientInstance(false)
.mediaAnnotationClient
.star(mediaId, null, null)
else
App.getSubsonicClientInstance(false)
.mediaAnnotationClient
.unstar(mediaId, null, null)
return CallbackToFutureAdapter.getFuture { completer ->
networkCall.enqueue(object : Callback<ApiResponse?> {
@OptIn(UnstableApi::class)
override fun onResponse(
call: Call<ApiResponse?>,
response: Response<ApiResponse?>
) {
if (response.isSuccessful) {
// Search if the media item in the player should be updated
for (i in 0 until session.player.mediaItemCount) {
val mediaItem = session.player.getMediaItemAt(i)
if (mediaItem.mediaId == mediaId) {
val newMetadata = mediaItem.mediaMetadata.buildUpon()
.setUserRating(HeartRating(isStaring)).build()
session.player.replaceMediaItem(
i,
mediaItem.buildUpon().setMediaMetadata(newMetadata).build()
)
}
}
updateMediaNotificationCustomLayout(session)
completer.set(SessionResult(SessionResult.RESULT_SUCCESS))
} else {
updateMediaNotificationCustomLayout(session)
completer.set(
SessionResult(
SessionError(
response.code(),
response.message()
)
)
)
}
}
@OptIn(UnstableApi::class)
override fun onFailure(call: Call<ApiResponse?>, t: Throwable) {
updateMediaNotificationCustomLayout(session)
completer.set(
SessionResult(
SessionError(
SessionError.ERROR_UNKNOWN,
"An error as occurred"
)
)
)
}
})
}
}
@OptIn(UnstableApi::class)
override fun onCustomCommand(
session: MediaSession,
@ -111,9 +302,23 @@ open class MediaLibrarySessionCallback(
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
val mediaItemId = args.getString(
MediaConstants.EXTRA_KEY_MEDIA_ID,
session.player.currentMediaItem?.mediaId
)
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> {
session.player.shuffleModeEnabled = true
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> {
session.player.shuffleModeEnabled = false
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
@ -123,16 +328,31 @@ open class MediaLibrarySessionCallback(
else -> Player.REPEAT_MODE_OFF
}
session.player.repeatMode = nextMode
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
CUSTOM_COMMAND_TOGGLE_HEART_ON,
CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
val currentRating = session.player.mediaMetadata.userRating as? HeartRating
val isCurrentlyLiked = currentRating?.isHeart ?: false
val newLikedState = !isCurrentlyLiked
updateMediaNotificationCustomLayout(
session,
isRatingPending = true // Show loading state
)
return onSetRating(session, controller, HeartRating(newLikedState))
}
else -> return Futures.immediateFuture(
SessionResult(
SessionError(
SessionError.ERROR_NOT_SUPPORTED,
customCommand.customAction
)
)
)
}
session.setCustomLayout(
session.mediaNotificationControllerInfo!!,
buildCustomLayout(session.player)
)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
@ -186,17 +406,4 @@ open class MediaLibrarySessionCallback(
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return MediaBrowserTree.search(query)
}
companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
"android.media3.session.demo.SHUFFLE_ON"
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
"android.media3.session.demo.SHUFFLE_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
"android.media3.session.demo.REPEAT_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
"android.media3.session.demo.REPEAT_ONE"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
"android.media3.session.demo.REPEAT_ALL"
}
}

View file

@ -219,16 +219,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
)
}
})
}