From b2d9b244e5b37022b15f7a00174789b7a3adba87 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:02:29 -0700 Subject: [PATCH] playback: redocument/refactor gapless playback Should complete this feature, save regression fixes. Resolves #110. --- .../playback/state/PlaybackStateHolder.kt | 139 ++++++++++++++ .../playback/state/PlaybackStateManager.kt | 8 +- .../auxio/playback/system/ExoPlayerExt.kt | 176 ------------------ .../auxio/playback/system/PlaybackService.kt | 160 +++++++++++++--- 4 files changed, 277 insertions(+), 206 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 7b6b16fd1..7de33f20c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -25,73 +25,206 @@ import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song +/** + * The designated "source of truth" for the current playback state. Should only be used by + * [PlaybackStateManager], which mirrors a more refined version of the state held here. + * + * @author Alexander Capehart (OxygenCobalt) + */ interface PlaybackStateHolder { + /** The current [Progression] state of the audio player. */ val progression: Progression + /** The current [RepeatMode] of the audio player. */ val repeatMode: RepeatMode + /** The current [MusicParent] being played from. Null if playing from all songs. */ val parent: MusicParent? + /** + * Resolve the current queue state as a [RawQueue]. + * + * @return The current queue state. + */ fun resolveQueue(): RawQueue + /** The current audio session ID of the audio player. */ val audioSessionId: Int + /** + * Applies a completely new playback state to the holder. + * + * @param queue The new queue to use. + * @param start The song to start playback from. Should be in the queue. + * @param parent The parent to play from. + * @param shuffled Whether the queue should be shuffled. + */ fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean) + /** + * Update the playing state of the audio player. + * + * @param playing Whether the player should be playing audio. + */ fun playing(playing: Boolean) + /** + * Seek to a position in the current song. + * + * @param positionMs The position to seek to, in milliseconds. + */ fun seekTo(positionMs: Long) + /** + * Update the repeat mode of the audio player. + * + * @param repeatMode The new repeat mode. + */ fun repeatMode(repeatMode: RepeatMode) + /** Go to the next song in the queue. */ fun next() + /** Go to the previous song in the queue. */ fun prev() + /** + * Go to a specific index in the queue. + * + * @param index The index to go to. Should be in the queue. + */ fun goto(index: Int) + /** + * Add songs to the currently playing item in the queue. + * + * @param songs The songs to add. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + */ fun playNext(songs: List, ack: StateAck.PlayNext) + /** + * Add songs to the end of the queue. + * + * @param songs The songs to add. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + */ fun addToQueue(songs: List, ack: StateAck.AddToQueue) + /** + * Move a song in the queue to a new position. + * + * @param from The index of the song to move. + * @param to The index to move the song to. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + */ fun move(from: Int, to: Int, ack: StateAck.Move) + /** + * Remove a song from the queue. + * + * @param at The index of the song to remove. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + * @return The [Song] that was removed. + */ fun remove(at: Int, ack: StateAck.Remove) + /** + * Reorder the queue. + * + * @param shuffled Whether the queue should be shuffled. + */ fun shuffled(shuffled: Boolean) + /** + * Handle a deferred playback action. + * + * @param action The action to handle. + * @return Whether the action could be handled, or if it should be deferred for later. + */ fun handleDeferred(action: DeferredPlayback): Boolean + /** + * Override the current held state with a saved state. + * + * @param parent The parent to play from. + * @param rawQueue The queue to use. + * @param ack The [StateAck] to return to [PlaybackStateManager]. If null, do not return any + * ack. + */ fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) } +/** + * An acknowledgement that the state of the [PlaybackStateHolder] has changed. This is sent back to + * [PlaybackStateManager] once an operation in [PlaybackStateHolder] has completed so that the new + * state can be mirrored to the rest of the application. + * + * @author Alexander Capehart (OxygenCobalt) + */ sealed interface StateAck { + /** + * @see PlaybackStateHolder.next + * @see PlaybackStateHolder.prev + * @see PlaybackStateHolder.goto + */ data object IndexMoved : StateAck + /** @see PlaybackStateHolder.playNext */ data class PlayNext(val at: Int, val size: Int) : StateAck + /** @see PlaybackStateHolder.addToQueue */ data class AddToQueue(val at: Int, val size: Int) : StateAck + /** @see PlaybackStateHolder.move */ data class Move(val from: Int, val to: Int) : StateAck + /** @see PlaybackStateHolder.remove */ data class Remove(val index: Int) : StateAck + /** @see PlaybackStateHolder.shuffled */ data object QueueReordered : StateAck + /** + * @see PlaybackStateHolder.newPlayback + * @see PlaybackStateHolder.applySavedState + */ data object NewPlayback : StateAck + /** + * @see PlaybackStateHolder.playing + * @see PlaybackStateHolder.seekTo + */ data object ProgressionChanged : StateAck + /** @see PlaybackStateHolder.repeatMode */ data object RepeatModeChanged : StateAck } +/** + * The queue as it is represented in the audio player held by [PlaybackStateHolder]. This should not + * be used as anything but a container. Use the provided fields to obtain saner queue information. + * + * @param heap The ordered list of all [Song]s in the queue. + * @param shuffledMapping A list of indices that remap the songs in [heap] into a shuffled queue. + * Empty if the queue is not shuffled. + * @param heapIndex The index of the current song in [heap]. Note that if shuffled, this will be a + * nonsensical value that cannot be used to obtain next and last songs without first resolving the + * queue. + */ data class RawQueue( val heap: List, val shuffledMapping: List, val heapIndex: Int, ) { + /** Whether the queue is currently shuffled. */ val isShuffled = shuffledMapping.isNotEmpty() + /** + * Resolve and return the exact [Song] sequence in the queue. + * + * @return The [Song]s in the queue, in order. + */ fun resolveSongs() = if (isShuffled) { shuffledMapping.map { heap[it] } @@ -99,6 +232,11 @@ data class RawQueue( heap } + /** + * Resolve and return the current index of the queue. + * + * @return The current index of the queue. + */ fun resolveIndex() = if (isShuffled) { shuffledMapping.indexOf(heapIndex) @@ -107,6 +245,7 @@ data class RawQueue( } companion object { + /** Create a blank instance. */ fun nil() = RawQueue(emptyList(), emptyList(), -1) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index d24927511..7cdbe0690 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -45,19 +45,25 @@ import org.oxycblt.auxio.util.logW * @author Alexander Capehart (OxygenCobalt) */ interface PlaybackStateManager { - /** The current [Progression] state. */ + /** The current [Progression] of the audio player */ val progression: Progression + /** The current [RepeatMode]. */ val repeatMode: RepeatMode + /** The current [MusicParent] being played from */ val parent: MusicParent? + /** The current [Song] being played. Null if nothing is playing. */ val currentSong: Song? + /** The current queue of [Song]s. */ val queue: List + /** The index of the currently playing [Song] in the queue. */ val index: Int + /** Whether the queue is shuffled or not. */ val isShuffled: Boolean /** The audio session ID of the internal player. Null if no internal player exists. */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt deleted file mode 100644 index b5fba9d74..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * ExoPlayerExt.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.system - -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.state.RawQueue -import org.oxycblt.auxio.util.logD - -val ExoPlayer.song - get() = currentMediaItem?.song - -fun ExoPlayer.resolveQueue(): RawQueue { - val heap = (0 until mediaItemCount).map { getMediaItemAt(it).song } - val shuffledMapping = if (shuffleModeEnabled) unscrambleQueueIndices() else emptyList() - return RawQueue(heap, shuffledMapping, currentMediaItemIndex) -} - -fun ExoPlayer.orderedQueue(queue: Collection, start: Song?) { - clearMediaItems() - shuffleModeEnabled = false - setMediaItems(queue.map { it.toMediaItem() }) - if (start != null) { - val startIndex = queue.indexOf(start) - if (startIndex != -1) { - seekTo(startIndex, C.TIME_UNSET) - } else { - throw IllegalArgumentException("Start song not in queue") - } - } -} - -fun ExoPlayer.shuffledQueue(queue: Collection, start: Song?) { - setMediaItems(queue.map { it.toMediaItem() }) - shuffleModeEnabled = true - val startIndex = - if (start != null) { - queue.indexOf(start).also { check(it != -1) { "Start song not in queue" } } - } else { - -1 - } - setShuffleOrder(BetterShuffleOrder(queue.size, startIndex)) - seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET) -} - -fun ExoPlayer.applyQueue(rawQueue: RawQueue) { - setMediaItems(rawQueue.heap.map { it.toMediaItem() }) - if (rawQueue.isShuffled) { - shuffleModeEnabled = true - setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) - } else { - shuffleModeEnabled = false - } - seekTo(rawQueue.heapIndex, C.TIME_UNSET) -} - -fun ExoPlayer.shuffled(shuffled: Boolean) { - logD("Reordering queue to $shuffled") - shuffleModeEnabled = shuffled - if (shuffled) { - // Have to manually refresh the shuffle seed and anchor it to the new current songs - setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex)) - } -} - -fun ExoPlayer.playNext(songs: List) { - addMediaItems(nextMediaItemIndex, songs.map { it.toMediaItem() }) -} - -fun ExoPlayer.addToQueue(songs: List) { - addMediaItems(songs.map { it.toMediaItem() }) -} - -fun ExoPlayer.goto(index: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[index] - seekTo(trueIndex, C.TIME_UNSET) -} - -fun ExoPlayer.move(from: Int, to: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueFrom = indices[from] - val trueTo = indices[to] - - when { - trueFrom > trueTo -> { - moveMediaItem(trueFrom, trueTo) - moveMediaItem(trueTo + 1, trueFrom) - } - trueTo > trueFrom -> { - moveMediaItem(trueFrom, trueTo) - moveMediaItem(trueTo - 1, trueFrom) - } - } -} - -fun ExoPlayer.remove(at: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[at] - removeMediaItem(trueIndex) -} - -fun ExoPlayer.unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty()) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue -} - -fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() - -private val MediaItem.song: Song - get() = requireNotNull(localConfiguration).tag as Song diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 9bf6511c1..168514c01 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -222,13 +222,14 @@ class PlaybackService : override val progression: Progression get() = - player.song?.let { + player.currentMediaItem?.let { Progression.from( player.playWhenReady, player.isPlaying, // The position value can be below zero or past the expected duration, make // sure we handle that. - player.currentPosition.coerceAtLeast(0).coerceAtMost(it.durationMs)) + player.currentPosition.coerceAtLeast(0) + .coerceAtMost(it.song.durationMs)) } ?: Progression.nil() @@ -243,7 +244,15 @@ class PlaybackService : override var parent: MusicParent? = null - override fun resolveQueue() = player.resolveQueue() + override fun resolveQueue(): RawQueue { + val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).song } + val shuffledMapping = if (player.shuffleModeEnabled) { + player.unscrambleQueueIndices() + } else { + emptyList() + } + return RawQueue(heap, shuffledMapping, player.currentMediaItemIndex) + } override val audioSessionId: Int get() = player.audioSessionId @@ -255,11 +264,18 @@ class PlaybackService : shuffled: Boolean ) { this.parent = parent + player.shuffleModeEnabled = shuffled + player.setMediaItems(queue.map { it.toMediaItem() }) + val startIndex = + start + ?.let { queue.indexOf(start) } + .also { check(it != -1) { "Start song not in queue" } } if (shuffled) { - player.shuffledQueue(queue, start) - } else { - player.orderedQueue(queue, start) + player.setShuffleOrder(BetterShuffleOrder(queue.size, startIndex ?: -1)) } + val target = + startIndex ?: player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled) + player.seekTo(target, C.TIME_UNSET) player.prepare() player.play() playbackManager.ack(this, StateAck.NewPlayback) @@ -300,32 +316,67 @@ class PlaybackService : } override fun goto(index: Int) { - player.goto(index) + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[index] + player.seekTo(trueIndex, C.TIME_UNSET) playbackManager.ack(this, StateAck.IndexMoved) } override fun shuffled(shuffled: Boolean) { - player.shuffled(shuffled) + logD("Reordering queue to $shuffled") + player.shuffleModeEnabled = shuffled + if (shuffled) { + // Have to manually refresh the shuffle seed and anchor it to the new current songs + player.setShuffleOrder( + BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) + } playbackManager.ack(this, StateAck.QueueReordered) } override fun playNext(songs: List, ack: StateAck.PlayNext) { - player.playNext(songs) + player.addMediaItems(player.nextMediaItemIndex, songs.map { it.toMediaItem() }) playbackManager.ack(this, ack) } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addToQueue(songs) + player.addMediaItems(songs.map { it.toMediaItem() }) playbackManager.ack(this, ack) } override fun move(from: Int, to: Int, ack: StateAck.Move) { - player.move(from, to) + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueFrom = indices[from] + val trueTo = indices[to] + + when { + trueFrom > trueTo -> { + player.moveMediaItem(trueFrom, trueTo) + player.moveMediaItem(trueTo + 1, trueFrom) + } + trueTo > trueFrom -> { + player.moveMediaItem(trueFrom, trueTo) + player.moveMediaItem(trueTo - 1, trueFrom) + } + } playbackManager.ack(this, ack) } override fun remove(at: Int, ack: StateAck.Remove) { - player.remove(at) + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[at] + player.removeMediaItem(trueIndex) playbackManager.ack(this, ack) } @@ -375,7 +426,14 @@ class PlaybackService : ack: StateAck.NewPlayback? ) { this.parent = parent - player.applyQueue(rawQueue) + player.setMediaItems(rawQueue.heap.map { it.toMediaItem() }) + if (rawQueue.isShuffled) { + player.shuffleModeEnabled = true + player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + player.shuffleModeEnabled = false + } + player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) player.prepare() ack?.let { playbackManager.ack(this, it) } } @@ -456,13 +514,72 @@ class PlaybackService : } } - // --- OTHER FUNCTIONS --- + override fun onPostNotification(notification: NotificationComponent) { + // Do not post the notification if playback hasn't started yet. This prevents errors + // where changing a setting would cause the notification to appear in an unfriendly + // manner. + if (hasPlayed) { + logD("Played before, starting foreground state") + if (!foregroundManager.tryStartForeground(notification)) { + logD("Notification changed, re-posting") + notification.post() + } + } + } + + // --- PLAYER MANAGEMENT --- private fun updatePauseOnRepeat() { player.pauseAtEndOfMediaItems = playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat } + private fun ExoPlayer.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty()) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue + } + + private fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() + + private val MediaItem.song: Song + get() = requireNotNull(localConfiguration).tag as Song + + // --- OTHER FUNCTIONS --- + private fun broadcastAudioEffectAction(event: String) { logD("Broadcasting AudioEffect event: $event") sendBroadcast( @@ -484,21 +601,6 @@ class PlaybackService : } } - // --- MEDIASESSIONCOMPONENT OVERRIDES --- - - override fun onPostNotification(notification: NotificationComponent) { - // Do not post the notification if playback hasn't started yet. This prevents errors - // where changing a setting would cause the notification to appear in an unfriendly - // manner. - if (hasPlayed) { - logD("Played before, starting foreground state") - if (!foregroundManager.tryStartForeground(notification)) { - logD("Notification changed, re-posting") - notification.post() - } - } - } - /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require * an active [IntentFilter] to be registered.