Merge branch 'feature/android-auto-shuffle-repeat' into 'develop'

Added custom buttons for shuffling the current queue and setting repeat mode

Closes #883

See merge request ultrasonic/ultrasonic!1075
This commit is contained in:
birdbird 2023-07-24 19:20:21 +00:00
commit acbaae9f14
3 changed files with 285 additions and 79 deletions

View File

@ -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_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.Player
import androidx.media3.common.Rating import androidx.media3.common.Rating
import androidx.media3.common.StarRating import androidx.media3.common.StarRating
import androidx.media3.session.CommandButton import androidx.media3.session.CommandButton
@ -116,22 +117,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private val isOffline get() = ActiveServerProvider.isOffline() private val isOffline get() = ActiveServerProvider.isOffline()
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
private var customCommands: List<CommandButton> private val placeholderButton = getPlaceholderButton()
internal var customLayout = ImmutableList.of<CommandButton>()
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<CommandButton>
val defaultCustomCommands: List<CommandButton>
init { init {
customCommands = val shuffleCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_SHUFFLE, Bundle.EMPTY)
listOf( shuffleButton = getShuffleCommandButton(shuffleCommand)
// This button is used for an unstarred track, and its action will star the track
getHeartCommandButton( val repeatCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_REPEAT_MODE, Bundle.EMPTY)
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY) repeatOffButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_OFF)
), repeatOneButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ONE)
// This button is used for an starred track, and its action will unstar the track repeatAllButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ALL)
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY) allCustomCommands = listOf(
) heartButtonToggleOn,
) heartButtonToggleOff,
customLayout = ImmutableList.of(customCommands[0]) shuffleButton,
repeatOffButton,
repeatOneButton,
repeatAllButton
)
defaultCustomCommands = listOf(heartButtonToggleOn, shuffleButton, repeatOffButton)
} }
/** /**
@ -188,14 +226,17 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
controller: MediaSession.ControllerInfo controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult { ): MediaSession.ConnectionResult {
Timber.i("onConnect") Timber.i("onConnect")
val connectionResult = super.onConnect(session, controller) val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
for (commandButton in customCommands) { for (commandButton in allCustomCommands) {
// Add custom command to available session commands. // Add custom command to available session commands.
commandButton.sessionCommand?.let { availableSessionCommands.add(it) } commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
} }
session.player.repeatMode = Player.REPEAT_MODE_ALL
return MediaSession.ConnectionResult.accept( return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(), availableSessionCommands.build(),
connectionResult.availablePlayerCommands connectionResult.availablePlayerCommands
@ -203,26 +244,72 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
} }
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { 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) // Let Media3 controller (for instance the MediaNotificationProvider)
// know about the custom layout right after it connected. // 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 { private fun getHeartCommandButton(sessionCommand: SessionCommand, willHeart: Boolean) =
val willHeart = CommandButton.Builder()
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON) .setDisplayName(
return CommandButton.Builder() if (willHeart)
.setDisplayName("Love") "Love"
else
"Dislike"
)
.setIconResId( .setIconResId(
if (willHeart) R.drawable.ic_star_hollow if (willHeart)
else R.drawable.ic_star_full 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) .setSessionCommand(sessionCommand)
.setEnabled(true) .setEnabled(true)
.build() .build()
}
override fun onGetItem( override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession, session: MediaLibraryService.MediaLibrarySession,
@ -266,18 +353,30 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
customCommand: SessionCommand, customCommand: SessionCommand,
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
Timber.i("onCustomCommand") Timber.i("onCustomCommand %s", customCommand.customAction)
var customCommandFuture: ListenableFuture<SessionResult>? = null var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) { when (customCommand.customAction) {
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
customCommandFuture = onSetRating(session, controller, HeartRating(true)) customCommandFuture = onSetRating(session, controller, HeartRating(true))
updateCustomHeartButton(session, true) updateCustomHeartButton(session, isHeart = true)
} }
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
customCommandFuture = onSetRating(session, controller, HeartRating(false)) 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 -> { else -> {
@ -288,9 +387,14 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
) )
} }
} }
if (customCommandFuture != null)
return customCommandFuture return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args) ?: super.onCustomCommand(
session,
controller,
customCommand,
args
)
} }
override fun onSetRating( override fun onSetRating(
@ -299,6 +403,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
rating: Rating rating: Rating
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
val mediaItem = session.player.currentMediaItem val mediaItem = session.player.currentMediaItem
if (mediaItem != null) { if (mediaItem != null) {
if (rating is HeartRating) { if (rating is HeartRating) {
mediaItem.toTrack().starred = rating.isHeart mediaItem.toTrack().starred = rating.isHeart
@ -312,6 +417,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
rating rating
) )
} }
return super.onSetRating(session, controller, rating) return super.onSetRating(session, controller, rating)
} }
@ -381,6 +487,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private fun onAddLegacyAutoItems( private fun onAddLegacyAutoItems(
mediaItems: MutableList<MediaItem> mediaItems: MutableList<MediaItem>
): ListenableFuture<List<MediaItem>> { ): ListenableFuture<List<MediaItem>> {
Timber.i("onAddLegacyAutoItems %s", mediaItems.first().mediaId)
val mediaIdParts = mediaItems.first().mediaId.split('|') val mediaIdParts = mediaItems.first().mediaId.split('|')
val tracks = when (mediaIdParts.first()) { val tracks = when (mediaIdParts.first()) {
@ -410,55 +518,54 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
else -> null else -> null
} }
if (tracks != null) { return tracks
return Futures.immediateFuture( ?.let {
tracks.map { track -> track.toMediaItem() } Futures.immediateFuture(
.toMutableList() it.map { track -> track.toMediaItem() }
) .toMutableList()
} )
}
// Fallback to the original list ?: Futures.immediateFuture(mediaItems)
return Futures.immediateFuture(mediaItems)
} }
@Suppress("ReturnCount", "ComplexMethod") @Suppress("ComplexMethod")
fun onLoadChildren( private fun onLoadChildren(
parentId: String, parentId: String,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId)
val parentIdParts = parentId.split('|') val parentIdParts = parentId.split('|')
when (parentIdParts.first()) { return when (parentIdParts.first()) {
MEDIA_ROOT_ID -> return getRootItems() MEDIA_ROOT_ID -> getRootItems()
MEDIA_LIBRARY_ID -> return getLibrary() MEDIA_LIBRARY_ID -> getLibrary()
MEDIA_ARTIST_ID -> return getArtists() MEDIA_ARTIST_ID -> getArtists()
MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1]) MEDIA_ARTIST_SECTION -> getArtists(parentIdParts[1])
MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME) MEDIA_ALBUM_ID -> getAlbums(AlbumListType.SORTED_BY_NAME)
MEDIA_ALBUM_PAGE_ID -> return getAlbums( MEDIA_ALBUM_PAGE_ID -> getAlbums(
AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt()
) )
MEDIA_PLAYLIST_ID -> return getPlaylists() MEDIA_PLAYLIST_ID -> getPlaylists()
MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT) MEDIA_ALBUM_FREQUENT_ID -> getAlbums(AlbumListType.FREQUENT)
MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST) MEDIA_ALBUM_NEWEST_ID -> getAlbums(AlbumListType.NEWEST)
MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT) MEDIA_ALBUM_RECENT_ID -> getAlbums(AlbumListType.RECENT)
MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM) MEDIA_ALBUM_RANDOM_ID -> getAlbums(AlbumListType.RANDOM)
MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED) MEDIA_ALBUM_STARRED_ID -> getAlbums(AlbumListType.STARRED)
MEDIA_SONG_RANDOM_ID -> return getRandomSongs() MEDIA_SONG_RANDOM_ID -> getRandomSongs()
MEDIA_SONG_STARRED_ID -> return getStarredSongs() MEDIA_SONG_STARRED_ID -> getStarredSongs()
MEDIA_SHARE_ID -> return getShares() MEDIA_SHARE_ID -> getShares()
MEDIA_BOOKMARK_ID -> return getBookmarks() MEDIA_BOOKMARK_ID -> getBookmarks()
MEDIA_PODCAST_ID -> return getPodcasts() MEDIA_PODCAST_ID -> getPodcasts()
MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2]) MEDIA_PLAYLIST_ITEM -> getPlaylist(parentIdParts[1], parentIdParts[2])
MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( MEDIA_ARTIST_ITEM -> getAlbumsForArtist(
parentIdParts[1], parentIdParts[2] parentIdParts[1], parentIdParts[2]
) )
MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2]) MEDIA_ALBUM_ITEM -> getSongsForAlbum(parentIdParts[1], parentIdParts[2])
MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1]) MEDIA_SHARE_ITEM -> getSongsForShare(parentIdParts[1])
MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1]) MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1])
else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null))
} }
} }
@ -1316,14 +1423,102 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
} }
} }
fun updateCustomHeartButton( private fun Player.setNextRepeatMode() {
session: MediaSession, repeatMode =
isHeart: Boolean when (repeatMode) {
) { Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
val command = if (isHeart) customCommands[1] else customCommands[0] Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
// Change the custom layout to contain the right heart button else -> Player.REPEAT_MODE_OFF
customLayout = ImmutableList.of(command) }
// Send the updated custom layout to controllers. }
session.setCustomLayout(customLayout)
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<CommandButton> {
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<MediaItem>()
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())
} }
} }

View File

@ -150,10 +150,8 @@ class PlaybackService :
.setBitmapLoader(ArtworkBitmapLoader()) .setBitmapLoader(ArtworkBitmapLoader())
.build() .build()
if (!librarySessionCallback.customLayout.isEmpty()) { // Send custom layout to legacy session.
// Send custom layout to legacy session. mediaLibrarySession.setCustomLayout(librarySessionCallback.defaultCustomCommands)
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
}
// Set a listener to update the API client when the active server has changed // Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
@ -422,6 +420,12 @@ class PlaybackService :
"org.moire.ultrasonic.HEART_ON" "org.moire.ultrasonic.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
"org.moire.ultrasonic.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 private const val NOTIFICATION_ID = 3009
} }
} }

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<size
android:width="120dp"
android:height="120dp"/>
</shape>