From 77c1329be58a70c7a850b25b9446993c14d502b0 Mon Sep 17 00:00:00 2001 From: Alex Katlein Date: Mon, 24 Jul 2023 19:20:21 +0000 Subject: [PATCH] Added custom buttons for shuffling the current queue and setting repeat mode --- .../playback/AutoMediaBrowserCallback.kt | 345 ++++++++++++++---- .../ultrasonic/playback/PlaybackService.kt | 12 +- ultrasonic/src/main/res/drawable/empty.xml | 7 + 3 files changed, 285 insertions(+), 79 deletions(-) create mode 100644 ultrasonic/src/main/res/drawable/empty.xml 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 d6b375b5..291dc5fe 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -17,6 +17,7 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED 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.common.StarRating import androidx.media3.session.CommandButton @@ -116,22 +117,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : private val isOffline get() = ActiveServerProvider.isOffline() private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId - private var customCommands: List - internal var customLayout = ImmutableList.of() + private val placeholderButton = getPlaceholderButton() + + private var heartIsCurrentlyOn = false + + // This button is used for an unstarred track, and its action will star the track + private val heartButtonToggleOn = + getHeartCommandButton( + SessionCommand( + PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, + Bundle.EMPTY + ), + willHeart = true + ) + + // This button is used for an starred track, and its action will star the track + private val heartButtonToggleOff = + getHeartCommandButton( + SessionCommand( + PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, + Bundle.EMPTY + ), + willHeart = false + ) + + private val shuffleButton: CommandButton + + private val repeatOffButton: CommandButton + private val repeatOneButton: CommandButton + private val repeatAllButton: CommandButton + + private val allCustomCommands: List + + val defaultCustomCommands: List init { - customCommands = - listOf( - // This button is used for an unstarred track, and its action will star the track - getHeartCommandButton( - SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY) - ), - // This button is used for an starred track, and its action will unstar the track - getHeartCommandButton( - SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY) - ) - ) - customLayout = ImmutableList.of(customCommands[0]) + val shuffleCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_SHUFFLE, Bundle.EMPTY) + shuffleButton = getShuffleCommandButton(shuffleCommand) + + val repeatCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_REPEAT_MODE, Bundle.EMPTY) + repeatOffButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_OFF) + repeatOneButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ONE) + repeatAllButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ALL) + + allCustomCommands = listOf( + heartButtonToggleOn, + heartButtonToggleOff, + shuffleButton, + repeatOffButton, + repeatOneButton, + repeatAllButton + ) + + defaultCustomCommands = listOf(heartButtonToggleOn, shuffleButton, repeatOffButton) } /** @@ -188,14 +226,17 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { Timber.i("onConnect") + val connectionResult = super.onConnect(session, controller) val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() - for (commandButton in customCommands) { + for (commandButton in allCustomCommands) { // Add custom command to available session commands. commandButton.sessionCommand?.let { availableSessionCommands.add(it) } } + session.player.repeatMode = Player.REPEAT_MODE_ALL + return MediaSession.ConnectionResult.accept( availableSessionCommands.build(), connectionResult.availablePlayerCommands @@ -203,26 +244,72 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : } override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { - if (!customLayout.isEmpty() && controller.controllerVersion != 0) { + if (controller.controllerVersion != 0) { // Let Media3 controller (for instance the MediaNotificationProvider) // know about the custom layout right after it connected. - session.setCustomLayout(customLayout) + with(session) { + setCustomLayout(session.buildCustomCommands(canShuffle = canShuffle())) + } } } - private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton { - val willHeart = - (sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON) - return CommandButton.Builder() - .setDisplayName("Love") + private fun getHeartCommandButton(sessionCommand: SessionCommand, willHeart: Boolean) = + CommandButton.Builder() + .setDisplayName( + if (willHeart) + "Love" + else + "Dislike" + ) .setIconResId( - if (willHeart) R.drawable.ic_star_hollow - else R.drawable.ic_star_full + if (willHeart) + R.drawable.ic_star_hollow + else + R.drawable.ic_star_full + ) + .setSessionCommand(sessionCommand) + .setEnabled(true) + .build() + + private fun getShuffleCommandButton(sessionCommand: SessionCommand) = + CommandButton.Builder() + .setDisplayName("Shuffle") + .setIconResId(R.drawable.media_shuffle) + .setSessionCommand(sessionCommand) + .setEnabled(true) + .build() + + private fun getPlaceholderButton() = CommandButton.Builder() + .setDisplayName("Placeholder") + .setIconResId(R.drawable.empty) + .setSessionCommand( + SessionCommand( + PlaybackService.CUSTOM_COMMAND_PLACEHOLDER, + Bundle.EMPTY + ) + ) + .setEnabled(false) + .build() + + private fun getRepeatModeButton(sessionCommand: SessionCommand, repeatMode: Int) = + CommandButton.Builder() + .setDisplayName( + when (repeatMode) { + Player.REPEAT_MODE_ONE -> "Repeat One" + Player.REPEAT_MODE_ALL -> "Repeat All" + else -> "Repeat None" + } + ) + .setIconResId( + when (repeatMode) { + Player.REPEAT_MODE_ONE -> R.drawable.media_repeat_one + Player.REPEAT_MODE_ALL -> R.drawable.media_repeat_all + else -> R.drawable.media_repeat_off + } ) .setSessionCommand(sessionCommand) .setEnabled(true) .build() - } override fun onGetItem( session: MediaLibraryService.MediaLibrarySession, @@ -266,18 +353,30 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : customCommand: SessionCommand, args: Bundle ): ListenableFuture { - Timber.i("onCustomCommand") + Timber.i("onCustomCommand %s", customCommand.customAction) var customCommandFuture: ListenableFuture? = null when (customCommand.customAction) { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> { customCommandFuture = onSetRating(session, controller, HeartRating(true)) - updateCustomHeartButton(session, true) + updateCustomHeartButton(session, isHeart = true) } PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> { customCommandFuture = onSetRating(session, controller, HeartRating(false)) - updateCustomHeartButton(session, false) + updateCustomHeartButton(session, isHeart = false) + } + + PlaybackService.CUSTOM_COMMAND_SHUFFLE -> { + customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + shuffleCurrentPlaylist(session.player) + } + + PlaybackService.CUSTOM_COMMAND_REPEAT_MODE -> { + customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + + session.player.setNextRepeatMode() + session.updateCustomCommands() } else -> { @@ -288,9 +387,14 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : ) } } - if (customCommandFuture != null) - return customCommandFuture - return super.onCustomCommand(session, controller, customCommand, args) + + return customCommandFuture + ?: super.onCustomCommand( + session, + controller, + customCommand, + args + ) } override fun onSetRating( @@ -299,6 +403,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : rating: Rating ): ListenableFuture { val mediaItem = session.player.currentMediaItem + if (mediaItem != null) { if (rating is HeartRating) { mediaItem.toTrack().starred = rating.isHeart @@ -312,6 +417,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : rating ) } + return super.onSetRating(session, controller, rating) } @@ -381,6 +487,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : private fun onAddLegacyAutoItems( mediaItems: MutableList ): ListenableFuture> { + Timber.i("onAddLegacyAutoItems %s", mediaItems.first().mediaId) + val mediaIdParts = mediaItems.first().mediaId.split('|') val tracks = when (mediaIdParts.first()) { @@ -410,55 +518,54 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : else -> null } - if (tracks != null) { - return Futures.immediateFuture( - tracks.map { track -> track.toMediaItem() } - .toMutableList() - ) - } - - // Fallback to the original list - return Futures.immediateFuture(mediaItems) + return tracks + ?.let { + Futures.immediateFuture( + it.map { track -> track.toMediaItem() } + .toMutableList() + ) + } + ?: Futures.immediateFuture(mediaItems) } - @Suppress("ReturnCount", "ComplexMethod") - fun onLoadChildren( + @Suppress("ComplexMethod") + private fun onLoadChildren( parentId: String, ): ListenableFuture>> { Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) val parentIdParts = parentId.split('|') - when (parentIdParts.first()) { - MEDIA_ROOT_ID -> return getRootItems() - MEDIA_LIBRARY_ID -> return getLibrary() - MEDIA_ARTIST_ID -> return getArtists() - MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1]) - MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME) - MEDIA_ALBUM_PAGE_ID -> return getAlbums( + return when (parentIdParts.first()) { + MEDIA_ROOT_ID -> getRootItems() + MEDIA_LIBRARY_ID -> getLibrary() + MEDIA_ARTIST_ID -> getArtists() + MEDIA_ARTIST_SECTION -> getArtists(parentIdParts[1]) + MEDIA_ALBUM_ID -> getAlbums(AlbumListType.SORTED_BY_NAME) + MEDIA_ALBUM_PAGE_ID -> getAlbums( AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() ) - MEDIA_PLAYLIST_ID -> return getPlaylists() - MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT) - MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST) - MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT) - MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM) - MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED) - MEDIA_SONG_RANDOM_ID -> return getRandomSongs() - MEDIA_SONG_STARRED_ID -> return getStarredSongs() - MEDIA_SHARE_ID -> return getShares() - MEDIA_BOOKMARK_ID -> return getBookmarks() - MEDIA_PODCAST_ID -> return getPodcasts() - MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2]) - MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( + MEDIA_PLAYLIST_ID -> getPlaylists() + MEDIA_ALBUM_FREQUENT_ID -> getAlbums(AlbumListType.FREQUENT) + MEDIA_ALBUM_NEWEST_ID -> getAlbums(AlbumListType.NEWEST) + MEDIA_ALBUM_RECENT_ID -> getAlbums(AlbumListType.RECENT) + MEDIA_ALBUM_RANDOM_ID -> getAlbums(AlbumListType.RANDOM) + MEDIA_ALBUM_STARRED_ID -> getAlbums(AlbumListType.STARRED) + MEDIA_SONG_RANDOM_ID -> getRandomSongs() + MEDIA_SONG_STARRED_ID -> getStarredSongs() + MEDIA_SHARE_ID -> getShares() + MEDIA_BOOKMARK_ID -> getBookmarks() + MEDIA_PODCAST_ID -> getPodcasts() + MEDIA_PLAYLIST_ITEM -> getPlaylist(parentIdParts[1], parentIdParts[2]) + MEDIA_ARTIST_ITEM -> getAlbumsForArtist( parentIdParts[1], parentIdParts[2] ) - MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2]) - MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1]) - MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1]) - else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) + MEDIA_ALBUM_ITEM -> getSongsForAlbum(parentIdParts[1], parentIdParts[2]) + MEDIA_SHARE_ITEM -> getSongsForShare(parentIdParts[1]) + MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1]) + else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) } } @@ -1316,14 +1423,102 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) : } } - fun updateCustomHeartButton( - session: MediaSession, - isHeart: Boolean - ) { - val command = if (isHeart) customCommands[1] else customCommands[0] - // Change the custom layout to contain the right heart button - customLayout = ImmutableList.of(command) - // Send the updated custom layout to controllers. - session.setCustomLayout(customLayout) + private fun Player.setNextRepeatMode() { + repeatMode = + when (repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + } + + private fun MediaSession.updateCustomCommands() { + setCustomLayout( + buildCustomCommands( + heartIsCurrentlyOn, + canShuffle() + ) + ) + } + + fun updateCustomHeartButton(session: MediaSession, isHeart: Boolean) { + with(session) { + setCustomLayout( + buildCustomCommands( + isHeart = isHeart, + canShuffle = canShuffle() + ) + ) + } + } + + private fun MediaSession.canShuffle() = + player.mediaItemCount > 2 + + private fun MediaSession.buildCustomCommands( + isHeart: Boolean = false, + canShuffle: Boolean = false + ): ImmutableList { + Timber.d("building custom commands (isHeart = %s, canShuffle = %s)", isHeart, canShuffle) + + heartIsCurrentlyOn = isHeart + + return ImmutableList.copyOf( + buildList { + // placeholder must come first here because if there is no next button the first + // custom command button is place right next to the play/pause button + if ( + player.repeatMode != Player.REPEAT_MODE_ALL && + player.currentMediaItemIndex == player.mediaItemCount - 1 + ) + add(placeholderButton) + + // due to the previous placeholder this heart button will always appear to the left + // of the default playback items + add( + if (isHeart) + heartButtonToggleOff + else + heartButtonToggleOn + ) + + // both the shuffle and the active repeat mode button will end up in the overflow + // menu if both are available at the same time + if (canShuffle) + add(shuffleButton) + + add( + when (player.repeatMode) { + Player.REPEAT_MODE_ONE -> repeatOneButton + Player.REPEAT_MODE_ALL -> repeatAllButton + else -> repeatOffButton + } + ) + }.asIterable() + ) + } + + private fun shuffleCurrentPlaylist(player: Player) { + Timber.d("shuffleCurrentPlaylist") + + // 3 was chosen because that leaves at least two other songs to be shuffled around + @Suppress("MagicNumber") + if (player.mediaItemCount < 3) + return + + val mediaItemsToShuffle = mutableListOf() + + for (i in 0 until player.currentMediaItemIndex) { + mediaItemsToShuffle += player.getMediaItemAt(i) + } + + for (i in player.currentMediaItemIndex + 1 until player.mediaItemCount) { + mediaItemsToShuffle += player.getMediaItemAt(i) + } + + player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) + player.removeMediaItems(0, player.currentMediaItemIndex) + + player.addMediaItems(mediaItemsToShuffle.shuffled()) } } 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 e58a6799..40b57843 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -150,10 +150,8 @@ class PlaybackService : .setBitmapLoader(ArtworkBitmapLoader()) .build() - if (!librarySessionCallback.customLayout.isEmpty()) { - // Send custom layout to legacy session. - mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout) - } + // Send custom layout to legacy session. + mediaLibrarySession.setCustomLayout(librarySessionCallback.defaultCustomCommands) // Set a listener to update the API client when the active server has changed rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { @@ -422,6 +420,12 @@ class PlaybackService : "org.moire.ultrasonic.HEART_ON" const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "org.moire.ultrasonic.HEART_OFF" + const val CUSTOM_COMMAND_SHUFFLE = + "org.moire.ultrasonic.SHUFFLE" + const val CUSTOM_COMMAND_PLACEHOLDER = + "org.moire.ultrasonic.PLACEHOLDER" + const val CUSTOM_COMMAND_REPEAT_MODE = + "org.moire.ultrasonic.REPEAT_MODE" private const val NOTIFICATION_ID = 3009 } } diff --git a/ultrasonic/src/main/res/drawable/empty.xml b/ultrasonic/src/main/res/drawable/empty.xml new file mode 100644 index 00000000..a4762a86 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/empty.xml @@ -0,0 +1,7 @@ + + + +