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..dcb5acd0 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 @@ -747,26 +752,32 @@ class PlayerFragment : if (currentSong == null) return true val isStarred = currentSong!!.starred - val id = currentSong!!.id - if (isStarred) { - starMenuItem.icon = hollowStar - currentSong!!.starred = false - } else { - starMenuItem.icon = fullStar - currentSong!!.starred = true + + mediaPlayerController.controller?.setRating( + 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 + } + } + + override fun onFailure(t: Throwable) { + Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT) + .show() + } + }, + this.executorService + ) } - Thread { - val musicService = getMusicService() - try { - if (isStarred) { - musicService.unstar(id, null, null) - } else { - musicService.star(id, null, null) - } - } catch (all: Exception) { - Timber.e(all) - } - }.start() + 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..6797ee53 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,9 @@ package org.moire.ultrasonic.playback import android.net.Uri import android.os.Bundle +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS @@ -18,10 +21,17 @@ 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.FutureCallback import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope @@ -41,6 +51,7 @@ import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory +import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -80,16 +91,18 @@ private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" private const val DISPLAY_LIMIT = 100 private const val SEARCH_LIMIT = 10 +// List of available custom SessionCommands +const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING" + /** * MediaBrowserService implementation for e.g. Android Auto */ @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") -class AutoMediaBrowserCallback(var player: Player) : +class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { private val mediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() - private val musicService = MusicServiceFactory.getMusicService() private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) @@ -99,6 +112,7 @@ class AutoMediaBrowserCallback(var player: Player) : private var randomSongsCache: List? = null private var searchSongsCache: List? = null + private val musicService get() = MusicServiceFactory.getMusicService() private val isOffline get() = ActiveServerProvider.isOffline() private val useId3Tags get() = Settings.shouldUseId3Tags private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId @@ -150,6 +164,25 @@ 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: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107 + * When this issue is fixed we should be able to remove this method again + */ + availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle())) + + return MediaSession.ConnectionResult.accept( + availableSessionCommands.build(), + connectionResult.availablePlayerCommands + ) + } + override fun onGetItem( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, @@ -177,6 +210,103 @@ class AutoMediaBrowserCallback(var player: Player) : return onLoadChildren(parentId) } + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + + var customCommandFuture: ListenableFuture? = null + + when (customCommand.customAction) { + SESSION_CUSTOM_SET_RATING -> { + /* + * It is currently not possible to edit a MediaItem after creation so the isRated value + * is stored in the track.starred value + * See https://github.com/androidx/media/issues/33 + */ + val track = mediaPlayerController.currentPlayingLegacy?.track + if (track != null) { + customCommandFuture = onSetRating( + session, + controller, + HeartRating(!track.starred) + ) + Futures.addCallback( + customCommandFuture, + object : FutureCallback { + override fun onSuccess(result: SessionResult) { + track.starred = !track.starred + // This needs to be called on the main Thread + libraryService.onUpdateNotification(session) + } + + override fun onFailure(t: Throwable) { + Toast.makeText( + mediaPlayerController.context, + "There was an error updating the rating", + LENGTH_SHORT + ).show() + } + }, + MainThreadExecutor() + ) + } + } + else -> { + Timber.d( + "CustomCommand not recognized %s with extra %s", + customCommand.customAction, + customCommand.customExtras.toString() + ) + } + } + if (customCommandFuture != null) + return customCommandFuture + 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) { + 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) + } + 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 +1206,7 @@ class AutoMediaBrowserCallback(var player: Player) : album = track.album, artist = track.artist, genre = track.genre, + starred = track.starred ) } @@ -1090,6 +1221,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 +1229,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..fc159127 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,29 @@ 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. See https://github.com/androidx/media/issues/33 + * TODO: Once the bug is fixed remove this circular reference! + */ + private val mediaPlayerController by inject() override fun addNotificationActions( mediaSession: MediaSession, @@ -25,7 +39,43 @@ class MediaNotificationProvider(context: Context) : DefaultMediaNotificationProv builder: NotificationCompat.Builder, actionFactory: MediaNotification.ActionFactory ): IntArray { - return super.addNotificationActions(mediaSession, mediaButtons, builder, actionFactory) + val tmp: MutableList = mutableListOf() + /* + * TODO: + * It is currently not possible to edit a MediaItem after creation so the isRated value + * is stored in the track.starred value + * See https://github.com/androidx/media/issues/33 + */ + 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( + SESSION_CUSTOM_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/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 1cc994d2..3cb043dd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -111,7 +111,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent { player.experimentalSetOffloadSchedulingEnabled(true) // Create browser interface - librarySessionCallback = AutoMediaBrowserCallback(player) + librarySessionCallback = AutoMediaBrowserCallback(player, this) // This will need to use the AutoCalls mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) 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..40b2ea26 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,18 @@ package org.moire.ultrasonic.service import android.content.ComponentName import android.content.Context import android.content.Intent +import android.widget.Toast import androidx.core.net.toUri +import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem 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 @@ -34,6 +39,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.Settings import timber.log.Timber @@ -579,23 +585,30 @@ 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() + controller?.setRating( + HeartRating(!song.starred) + ).let { + Futures.addCallback( + it, + object : FutureCallback { + override fun onSuccess(result: SessionResult?) { + // 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 + override fun onFailure(t: Throwable) { + Toast.makeText( + context, + "There was an error updating the rating", + Toast.LENGTH_SHORT + ).show() + } + }, + MainThreadExecutor() + ) + } } @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions @@ -668,6 +681,7 @@ fun Track.toMediaItem(): MediaItem { .setArtist(artist) .setAlbumTitle(album) .setAlbumArtist(artist) + .setUserRating(HeartRating(starred)) .build() val mediaItem = MediaItem.Builder() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MainThreadExecutor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MainThreadExecutor.kt new file mode 100644 index 00000000..a1d162c6 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MainThreadExecutor.kt @@ -0,0 +1,22 @@ +/* + * MainThreadExecutor.java + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.util + +import android.os.Handler +import android.os.Looper +import java.util.concurrent.Executor + +/* +* Executor for running Futures on the main thread +* See https://stackoverflow.com/questions/52642246/how-to-get-executor-for-main-thread-on-api-level-28 +*/ +class MainThreadExecutor : Executor { + private val handler = Handler(Looper.getMainLooper()) + override fun execute(r: Runnable) { + handler.post(r) + } +}