From 9014b47b746253ec6f24e16d655d0ec36ae12401 Mon Sep 17 00:00:00 2001 From: Maxence G Date: Sat, 2 Jul 2022 01:27:12 +0200 Subject: [PATCH] Add song rating to notification --- .../ultrasonic/adapters/TrackViewHolder.kt | 2 + .../ultrasonic/fragment/PlayerFragment.kt | 44 ++++--- .../playback/AutoMediaBrowserCallback.kt | 107 ++++++++++++++++++ .../playback/MediaNotificationProvider.kt | 44 ++++++- .../service/MediaPlayerController.kt | 48 +++++--- 5 files changed, 209 insertions(+), 36 deletions(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 2b9c2ec5..b0f921ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -153,6 +153,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable star.setImageDrawable(imageHelper.starHollowDrawable) song.starred = false } + + // Should this be done here ? Thread { val musicService = MusicServiceFactory.getMusicService() try { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index bb06c722..f2aa408d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -33,17 +33,22 @@ import android.widget.LinearLayout import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.TextView +import android.widget.Toast import android.widget.ViewFlipper import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.media3.common.HeartRating import androidx.media3.common.Player import androidx.media3.common.Timeline +import androidx.media3.session.SessionResult import androidx.navigation.Navigation import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures import io.reactivex.rxjava3.disposables.CompositeDisposable import java.text.DateFormat import java.text.SimpleDateFormat @@ -748,25 +753,28 @@ class PlayerFragment : val isStarred = currentSong!!.starred val id = currentSong!!.id - if (isStarred) { - starMenuItem.icon = hollowStar - currentSong!!.starred = false - } else { - starMenuItem.icon = fullStar - currentSong!!.starred = true - } - Thread { - val musicService = getMusicService() - try { - if (isStarred) { - musicService.unstar(id, null, null) - } else { - musicService.star(id, null, null) + + mediaPlayerController.controller?.setRating( + id, + HeartRating(!isStarred) + )?.let { + Futures.addCallback(it, object : FutureCallback { + override fun onSuccess(result: SessionResult?) { + if (isStarred) { + starMenuItem.icon = hollowStar + currentSong!!.starred = false + } else { + starMenuItem.icon = fullStar + currentSong!!.starred = true + } } - } catch (all: Exception) { - Timber.e(all) - } - }.start() + + override fun onFailure(t: Throwable) { + Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT).show() + } + }, this.executorService) + } + return true } R.id.menu_item_bookmark_set -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index 29905d24..25625115 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -9,6 +9,7 @@ package org.moire.ultrasonic.playback import android.net.Uri import android.os.Bundle +import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS @@ -18,9 +19,15 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.Player +import androidx.media3.common.Rating import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE +import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN +import androidx.media3.session.SessionResult.RESULT_SUCCESS import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture @@ -150,6 +157,22 @@ class AutoMediaBrowserCallback(var player: Player) : ) } + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() + + // TODO: Make a Const value list of available custom SessionCommands + availableSessionCommands.add(SessionCommand("COMMAND_CODE_SESSION_SET_RATING", Bundle())) + + return MediaSession.ConnectionResult.accept( + availableSessionCommands.build(), + connectionResult.availablePlayerCommands + ) + } + override fun onGetItem( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, @@ -177,6 +200,87 @@ class AutoMediaBrowserCallback(var player: Player) : return onLoadChildren(parentId) } + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + + /* + * It is currently not possible to edit a MediaItem after creation so the isRated value + * is stored in the track.starred value + */ + val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let { + HeartRating( + it + ) + } + + if (rating is HeartRating) { + return when (customCommand.customAction) { + "COMMAND_CODE_SESSION_SET_RATING" -> { + onSetRating( + session, + controller, + HeartRating(!rating.isHeart) + ) + } + else -> { + Timber.d( + "CustomCommand not recognized %s with extra %s", + customCommand.customAction, + customCommand.customExtras.toString() + ) + super.onCustomCommand(session, controller, customCommand, args) + } + } + } + return super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onSetRating( + session: MediaSession, + controller: MediaSession.ControllerInfo, + rating: Rating + ): ListenableFuture { + if (session.player.currentMediaItem != null) + return onSetRating( + session, + controller, + session.player.currentMediaItem!!.mediaId, + rating + ) + return super.onSetRating(session, controller, rating) + } + + override fun onSetRating( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaId: String, + rating: Rating + ): ListenableFuture { + return serviceScope.future { + if (rating is HeartRating) { + val musicService = MusicServiceFactory.getMusicService() + try { + if (rating.isHeart) { + musicService.star(mediaId, null, null) + } else { + musicService.unstar(mediaId, null, null) + } + } catch (all: Exception) { + Timber.e(all) + // TODO: Better handle exception + return@future SessionResult(RESULT_ERROR_UNKNOWN) + } + mediaPlayerController.currentPlayingLegacy?.track?.starred = rating.isHeart + return@future SessionResult(RESULT_SUCCESS) + } + return@future SessionResult(RESULT_ERROR_BAD_VALUE) + } + } + /* * For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer, * and thereby customarily it is required to rebuild it.. @@ -1076,6 +1180,7 @@ class AutoMediaBrowserCallback(var player: Player) : album = track.album, artist = track.artist, genre = track.genre, + starred = track.starred ) } @@ -1090,6 +1195,7 @@ class AutoMediaBrowserCallback(var player: Player) : genre: String? = null, sourceUri: Uri? = null, imageUri: Uri? = null, + starred: Boolean = false ): MediaItem { val metadata = MediaMetadata.Builder() @@ -1097,6 +1203,7 @@ class AutoMediaBrowserCallback(var player: Player) : .setTitle(title) .setArtist(artist) .setGenre(genre) + .setUserRating(HeartRating(starred)) .setFolderType(folderType) .setIsPlayable(isPlayable) .setArtworkUri(imageUri) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt index d9cd7c36..15250879 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt @@ -9,15 +9,28 @@ package org.moire.ultrasonic.playback import android.content.Context import androidx.core.app.NotificationCompat +import androidx.media3.common.HeartRating import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.CommandButton import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaNotification import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.service.MediaPlayerController @UnstableApi -class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { +class MediaNotificationProvider(context: Context) : + DefaultMediaNotificationProvider(context), KoinComponent { + + /* + * It is currently not possible to edit a MediaItem after creation so the isRated value + * is stored in the track.starred value + */ + private val mediaPlayerController by inject() override fun addNotificationActions( mediaSession: MediaSession, @@ -25,7 +38,34 @@ class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProv builder: NotificationCompat.Builder, actionFactory: MediaNotification.ActionFactory ): IntArray { - return super.addNotificationActions(mediaSession, mediaButtons, builder, actionFactory) + val tmp: MutableList = mutableListOf() + val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let { + HeartRating( + it + ) + } + if (rating is HeartRating) { + tmp.add( + CommandButton.Builder() + .setDisplayName("Love") + .setIconResId(if (rating.isHeart) R.drawable.ic_star_full_dark else R.drawable.ic_star_hollow_dark) + .setSessionCommand( + SessionCommand( + "COMMAND_CODE_SESSION_SET_RATING", + HeartRating(rating.isHeart).toBundle() + ) + ) + .setExtras(HeartRating(rating.isHeart).toBundle()) + .setEnabled(true) + .build() + ) + } + return super.addNotificationActions( + mediaSession, + mediaButtons + tmp, + builder, + actionFactory + ) } override fun getMediaButtons( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 73a5ef25..0dc5939d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -9,13 +9,20 @@ package org.moire.ultrasonic.service import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Build +import android.os.Bundle import androidx.core.net.toUri +import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.RequestMetadata import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.session.MediaController +import androidx.media3.session.SessionResult import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.MoreExecutors import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope @@ -579,23 +586,31 @@ class MediaPlayerController( if (legacyPlaylistManager.currentPlaying == null) return val song = legacyPlaylistManager.currentPlaying!!.track - Thread { - val musicService = getMusicService() - try { - if (song.starred) { - musicService.unstar(song.id, null, null) - } else { - musicService.star(song.id, null, null) - } - } catch (all: Exception) { - Timber.e(all) - } - }.start() + fun updateStarred() { + // Trigger an update + // TODO Update Metadata of MediaItem... + // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) + song.starred = !song.starred + } - // Trigger an update - // TODO Update Metadata of MediaItem... - // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) - song.starred = !song.starred + controller?.setRating( + song.id, + HeartRating(!song.starred) + ).let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && it != null) { + Futures.addCallback(it, object : FutureCallback { + override fun onSuccess(result: SessionResult?) { + updateStarred() + } + + override fun onFailure(t: Throwable) { + TODO("Not yet implemented") + } + }, context.mainExecutor) + } else { + updateStarred() + } + } } @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions @@ -668,6 +683,7 @@ fun Track.toMediaItem(): MediaItem { .setArtist(artist) .setAlbumTitle(album) .setAlbumArtist(artist) + .setUserRating(HeartRating(starred)) .build() val mediaItem = MediaItem.Builder()