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_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<CommandButton>
internal var customLayout = ImmutableList.of<CommandButton>()
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<CommandButton>
val defaultCustomCommands: List<CommandButton>
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<SessionResult> {
Timber.i("onCustomCommand")
Timber.i("onCustomCommand %s", customCommand.customAction)
var customCommandFuture: ListenableFuture<SessionResult>? = 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<SessionResult> {
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<MediaItem>
): ListenableFuture<List<MediaItem>> {
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<LibraryResult<ImmutableList<MediaItem>>> {
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<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())
.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
}
}

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>