From 6972f64812467d9815d69dca9623d3370af873f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Font=C3=A1n?= Date: Sun, 5 May 2024 10:30:55 +0200 Subject: [PATCH] feat/WIP: Started adding repeat and shuffle options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gabriel Fontán --- .idea/studiobot.xml | 6 ++ .../mediaplayer/di/MediaPlayerModule.kt | 7 +- .../service/MediaLibrarySessionCallback.kt | 71 ++++++++++++++++ .../service/MediaServiceHandler.kt | 83 ++++++++++++++++--- .../mediaplayer/service/MediaplayerService.kt | 47 ++++++++++- .../MediaSessionLayoutHandler.kt | 5 ++ .../src/main/res/drawable/repeat.xml | 12 +++ .../src/main/res/drawable/repeat_on.xml | 12 +++ .../src/main/res/drawable/repeat_one_on.xml | 12 +++ .../src/main/res/drawable/shuffle.xml | 12 +++ .../src/main/res/drawable/shuffle_on.xml | 12 +++ .../src/main/res/values/strings.xml | 8 ++ .../java/com/bobbyesp/metadator/ext/Player.kt | 42 ++++++++++ .../pages/mediaplayer/MediaplayerPage.kt | 2 +- .../pages/mediaplayer/MediaplayerViewModel.kt | 19 ++++- 15 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 .idea/studiobot.xml create mode 100644 app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt create mode 100644 app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaSessionLayoutHandler.kt create mode 100644 app/mediaplayer/src/main/res/drawable/repeat.xml create mode 100644 app/mediaplayer/src/main/res/drawable/repeat_on.xml create mode 100644 app/mediaplayer/src/main/res/drawable/repeat_one_on.xml create mode 100644 app/mediaplayer/src/main/res/drawable/shuffle.xml create mode 100644 app/mediaplayer/src/main/res/drawable/shuffle_on.xml create mode 100644 app/mediaplayer/src/main/res/values/strings.xml create mode 100644 app/src/main/java/com/bobbyesp/metadator/ext/Player.kt diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt index ca0048c..5a3942a 100644 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt @@ -9,8 +9,10 @@ import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession import com.bobbyesp.mediaplayer.service.ConnectionHandler +import com.bobbyesp.mediaplayer.service.MediaLibrarySessionCallback import com.bobbyesp.mediaplayer.service.MediaServiceHandler import com.bobbyesp.mediaplayer.service.notifications.MediaNotificationManager import dagger.Module @@ -60,9 +62,10 @@ object MediaPlayerModule { @Singleton fun provideMediaSession( @ApplicationContext context: Context, - player: ExoPlayer + player: ExoPlayer, + mediaLibrarySessionCallback: MediaLibrarySessionCallback ): MediaSession = - MediaSession.Builder(context, player) + MediaLibrarySession.Builder(context, player, mediaLibrarySessionCallback) .build() @Provides diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt new file mode 100644 index 0000000..21c0e23 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt @@ -0,0 +1,71 @@ +package com.bobbyesp.mediaplayer.service + +import android.content.Context +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.ACTION_TOGGLE_LIBRARY +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.ACTION_TOGGLE_LIKE +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.ACTION_TOGGLE_REPEAT_MODE +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.ACTION_TOGGLE_SHUFFLE +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.CommandToggleLibrary +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.CommandToggleLike +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.CommandToggleRepeatMode +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.CommandToggleShuffle +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class MediaLibrarySessionCallback @Inject constructor( + @ApplicationContext val context: Context +) : MediaLibraryService.MediaLibrarySession.Callback { + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands.buildUpon() + .add(CommandToggleLibrary) + .add(CommandToggleLike) + .add(CommandToggleShuffle) + .add(CommandToggleRepeatMode) + .build(), + connectionResult.availablePlayerCommands + ) + } + + @OptIn(UnstableApi::class) + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + when (customCommand.customAction) { + ACTION_TOGGLE_LIKE -> TODO() + ACTION_TOGGLE_LIBRARY -> TODO() + ACTION_TOGGLE_SHUFFLE -> session.player.shuffleModeEnabled = + !session.player.shuffleModeEnabled + + ACTION_TOGGLE_REPEAT_MODE -> session.player.repeatMode = + when (session.player.repeatMode) { + REPEAT_MODE_OFF -> REPEAT_MODE_ONE + REPEAT_MODE_ONE -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_OFF + else -> REPEAT_MODE_OFF + } + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + +} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt index c644797..80554ac 100644 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt @@ -1,5 +1,6 @@ package com.bobbyesp.mediaplayer.service +import android.os.Bundle import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player @@ -11,7 +12,10 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.PlaybackStats import androidx.media3.exoplayer.analytics.PlaybackStatsListener +import androidx.media3.exoplayer.source.ShuffleOrder +import androidx.media3.session.SessionCommand import com.bobbyesp.mediaplayer.ext.toMediaItem +import com.bobbyesp.mediaplayer.service.notifications.MediaSessionLayoutHandler import com.bobbyesp.mediaplayer.service.queue.EmptyQueue import com.bobbyesp.mediaplayer.service.queue.Queue import kotlinx.coroutines.CoroutineScope @@ -47,7 +51,19 @@ class MediaServiceHandler @Inject constructor( private var job: Job? = null private val scope = CoroutineScope(Dispatchers.Main) + private lateinit var mediaSessionInterface: MediaSessionLayoutHandler + + fun setMediaSessionInterface(mediaSessionInterface: MediaSessionLayoutHandler) { + this.mediaSessionInterface = mediaSessionInterface + } + + init { + player.addListener(this) + job = Job() + } + override fun onIsPlayingChanged(isPlaying: Boolean) { + mediaSessionInterface.updateNotificationLayout() _mediaState.update { MediaState.Playing(isPlaying) } @@ -57,11 +73,6 @@ class MediaServiceHandler @Inject constructor( super.onIsPlayingChanged(isPlaying) } - init { - player.addListener(this) - job = Job() - } - /** * Stops the player, clears the media queue, and releases resources. */ @@ -221,13 +232,6 @@ class MediaServiceHandler @Inject constructor( } } - override fun onEvents(player: Player, events: Player.Events) { - super.onEvents(player, events) - if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { - currentMediaItem.value = player.currentMediaItem - } - } - fun getActualMediaItem(): MediaItem? { return player.currentMediaItem } @@ -252,6 +256,12 @@ class MediaServiceHandler @Inject constructor( return player.currentPosition / player.duration.toFloat() } + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + currentMediaItem.value = player.currentMediaItem + } + } override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { @@ -281,6 +291,44 @@ class MediaServiceHandler @Inject constructor( } } + /** + * This method is triggered when the shuffle mode is enabled or disabled in the player. + * It updates the notification layout and, if shuffle mode is enabled, it shuffles the media items in the player. + * + * @param shuffleModeEnabled A boolean indicating whether shuffle mode is enabled. + */ + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + // Update the notification layout + mediaSessionInterface.updateNotificationLayout() + + // If shuffle mode is enabled + if (shuffleModeEnabled) { + // Always put current playing item at first + // Create an array of indices representing the media items in the player + val shuffledIndices = IntArray(player.mediaItemCount) { it } + + // Shuffle the indices + shuffledIndices.shuffle() + + // Swap the current media item index with the first index + shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = + shuffledIndices[0] + shuffledIndices[0] = player.currentMediaItemIndex + + // Set the shuffle order in the player using the shuffled indices + player.setShuffleOrder( + ShuffleOrder.DefaultShuffleOrder( + shuffledIndices, + System.currentTimeMillis() + ) + ) + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + mediaSessionInterface.updateNotificationLayout() + } + private suspend fun startProgressUpdate() = job.run { while (true) { delay(250) @@ -303,6 +351,17 @@ class MediaServiceHandler @Inject constructor( ) { TODO("Not yet implemented") } + + companion object { + const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" + const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" + const val ACTION_TOGGLE_SHUFFLE = "TOGGLE_SHUFFLE" + const val ACTION_TOGGLE_REPEAT_MODE = "TOGGLE_REPEAT_MODE" + val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) + val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) + val CommandToggleShuffle = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY) + val CommandToggleRepeatMode = SessionCommand(ACTION_TOGGLE_REPEAT_MODE, Bundle.EMPTY) + } } sealed class PlayerEvent { diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt index e990806..37ea6cd 100644 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt @@ -2,17 +2,26 @@ package com.bobbyesp.mediaplayer.service import android.content.Intent import android.os.Binder +import android.util.Log import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import com.bobbyesp.mediaplayer.R +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.CommandToggleRepeatMode +import com.bobbyesp.mediaplayer.service.MediaServiceHandler.Companion.CommandToggleShuffle import com.bobbyesp.mediaplayer.service.notifications.MediaNotificationManager +import com.bobbyesp.mediaplayer.service.notifications.MediaSessionLayoutHandler import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @UnstableApi @AndroidEntryPoint -class MediaplayerService : MediaSessionService() { +class MediaplayerService : MediaSessionService(), MediaSessionLayoutHandler { @Inject lateinit var mediaSession: MediaSession @@ -25,6 +34,40 @@ class MediaplayerService : MediaSessionService() { @Inject lateinit var mediaServiceHandler: MediaServiceHandler + override fun updateNotificationLayout() { + Log.i("MediaplayerService", "Updating notification layout") + mediaSession.setCustomLayout( + listOf( + CommandButton.Builder() + .setDisplayName(getString(if (mediaSession.player.shuffleModeEnabled) R.string.action_shuffle_off else R.string.action_shuffle_on)) + .setIconResId(if (mediaSession.player.shuffleModeEnabled) R.drawable.shuffle_on else R.drawable.shuffle) + .setSessionCommand(CommandToggleShuffle) + .build(), + CommandButton.Builder() + .setDisplayName( + getString( + when (mediaSession.player.repeatMode) { + REPEAT_MODE_OFF -> R.string.repeat_mode_off + REPEAT_MODE_ONE -> R.string.repeat_mode_one + REPEAT_MODE_ALL -> R.string.repeat_mode_all + else -> throw IllegalStateException() + } + ) + ) + .setIconResId( + when (mediaSession.player.repeatMode) { + REPEAT_MODE_OFF -> R.drawable.repeat + REPEAT_MODE_ONE -> R.drawable.repeat_one_on + REPEAT_MODE_ALL -> R.drawable.repeat_on + else -> throw IllegalStateException() + } + ) + .setSessionCommand(CommandToggleRepeatMode) + .build() + ) + ) + } + @UnstableApi override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { connectionHandler.connect(mediaServiceHandler) @@ -32,7 +75,7 @@ class MediaplayerService : MediaSessionService() { mediaSessionService = this, mediaSession = mediaSession ) - + mediaServiceHandler.setMediaSessionInterface(this) return super.onStartCommand(intent, flags, startId) } diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaSessionLayoutHandler.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaSessionLayoutHandler.kt new file mode 100644 index 0000000..c2edefe --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaSessionLayoutHandler.kt @@ -0,0 +1,5 @@ +package com.bobbyesp.mediaplayer.service.notifications + +interface MediaSessionLayoutHandler { + fun updateNotificationLayout() +} \ No newline at end of file diff --git a/app/mediaplayer/src/main/res/drawable/repeat.xml b/app/mediaplayer/src/main/res/drawable/repeat.xml new file mode 100644 index 0000000..73eb2c3 --- /dev/null +++ b/app/mediaplayer/src/main/res/drawable/repeat.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/mediaplayer/src/main/res/drawable/repeat_on.xml b/app/mediaplayer/src/main/res/drawable/repeat_on.xml new file mode 100644 index 0000000..e6ca40f --- /dev/null +++ b/app/mediaplayer/src/main/res/drawable/repeat_on.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/mediaplayer/src/main/res/drawable/repeat_one_on.xml b/app/mediaplayer/src/main/res/drawable/repeat_one_on.xml new file mode 100644 index 0000000..3d56e0e --- /dev/null +++ b/app/mediaplayer/src/main/res/drawable/repeat_one_on.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/mediaplayer/src/main/res/drawable/shuffle.xml b/app/mediaplayer/src/main/res/drawable/shuffle.xml new file mode 100644 index 0000000..c535838 --- /dev/null +++ b/app/mediaplayer/src/main/res/drawable/shuffle.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/mediaplayer/src/main/res/drawable/shuffle_on.xml b/app/mediaplayer/src/main/res/drawable/shuffle_on.xml new file mode 100644 index 0000000..17df242 --- /dev/null +++ b/app/mediaplayer/src/main/res/drawable/shuffle_on.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/mediaplayer/src/main/res/values/strings.xml b/app/mediaplayer/src/main/res/values/strings.xml new file mode 100644 index 0000000..848e1bb --- /dev/null +++ b/app/mediaplayer/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Shuffle off + Shuffle on + Repeat mode off + Repeat current song + Repeat all mode + \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/ext/Player.kt b/app/src/main/java/com/bobbyesp/metadator/ext/Player.kt new file mode 100644 index 0000000..4413775 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/ext/Player.kt @@ -0,0 +1,42 @@ +package com.bobbyesp.metadator.ext + +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Timeline + +fun Player.getQueueWindows(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = ArrayDeque() + val queueSize = timeline.windowCount + + val currentMediaItemIndex: Int = currentMediaItemIndex + queue.add(timeline.getWindow(currentMediaItemIndex, Timeline.Window())) + + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) { + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(timeline.getWindow(lastMediaItemIndex, Timeline.Window())) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { + firstMediaItemIndex = timeline.getPreviousWindowIndex( + firstMediaItemIndex, + REPEAT_MODE_OFF, + shuffleModeEnabled + ) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) + } + } + } + return queue.toList() +} diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt index b8dcb4b..853780b 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt @@ -67,7 +67,7 @@ fun MediaplayerPage( fadeOutSpec = null ), onClick = { - viewModel.playShuffledQueue(song) + viewModel.playOrderedQueue(song) if (mediaPlayerSheetState.isDismissed) { mediaPlayerSheetState.collapseSoft() diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt index 1572f82..feca4b3 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt @@ -43,7 +43,6 @@ class MediaplayerViewModel @Inject constructor( val songsFlow = applicationContext.contentResolver.observeSongs() val isPlaying = serviceHandler.isThePlayerPlaying - val playingSong = serviceHandler.currentMediaItem.asStateFlow() data class MediaplayerPageState( @@ -83,6 +82,13 @@ class MediaplayerViewModel @Inject constructor( } } + fun playOrderedQueue(firstSong: Song) { + playQueue(firstSong) + viewModelScope.launch { + serviceHandler.onPlayerEvent(PlayerEvent.PlayPause) + } + } + fun playShuffledQueue(firstSong: Song) { playRandomQueue(firstSong) viewModelScope.launch { @@ -124,6 +130,17 @@ class MediaplayerViewModel @Inject constructor( } } + private fun playQueue(firstSong: Song) { + viewModelScope.launch { + val copiedList = applicationContext.contentResolver.getSongs().toMutableList() + + copiedList.remove(firstSong) + copiedList.add(0, firstSong) + + loadQueueSongs(copiedList) + } + } + private fun loadQueueSongs(songs: List) { val mediaItems = songs.map { song -> MediaItem.Builder()