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)
- )
}
})
}