diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt index 2752f1df..bd0cc26d 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -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" } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd69ed38..37b0e501 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,9 @@ Required http or https prefix required Downloads + Toggle Heart off + Toggle Heart on + Loading… Select two or more filters Filter Filter artists diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt index 099ae672..f9254974 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt @@ -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 = 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 = 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 = - 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 { - 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 { + val customLayout = mutableListOf() + + //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 { + return onSetRating(session, controller, session.player.currentMediaItem!!.mediaId, rating) + } + + override fun onSetRating( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaId: String, + rating: Rating + ): ListenableFuture { + 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 { + @OptIn(UnstableApi::class) + override fun onResponse( + call: Call, + response: Response + ) { + 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, 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 { + + 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>> { 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" - } } \ No newline at end of file diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index a8b17ef6..963daa32 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -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) - ) } }) }