playback: redocument/refactor gapless playback

Should complete this feature, save regression fixes.

Resolves #110.
This commit is contained in:
Alexander Capehart 2024-01-15 16:02:29 -07:00
parent 48ab83f6de
commit b2d9b244e5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 277 additions and 206 deletions

View file

@ -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<Song>, 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<Song>, 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<Song>, 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<Song>,
val shuffledMapping: List<Int>,
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)
}
}

View file

@ -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<Song>
/** 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. */

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>, 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<Song>, 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<Song>) {
addMediaItems(nextMediaItemIndex, songs.map { it.toMediaItem() })
}
fun ExoPlayer.addToQueue(songs: List<Song>) {
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<Int> {
val timeline = currentTimeline
if (timeline.isEmpty()) {
return emptyList()
}
val queue = mutableListOf<Int>()
// 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

View file

@ -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<Song>, ack: StateAck.PlayNext) {
player.playNext(songs)
player.addMediaItems(player.nextMediaItemIndex, songs.map { it.toMediaItem() })
playbackManager.ack(this, ack)
}
override fun addToQueue(songs: List<Song>, 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<Int> {
val timeline = currentTimeline
if (timeline.isEmpty()) {
return emptyList()
}
val queue = mutableListOf<Int>()
// 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.