queue: add fine-grained updates

Add fine-grained updates to the queue view.

Should do the following:
1. Make queue updates faster (no diff calculation)
2. Resolve some bugs regarding duplicate queue items.
3. Finally complete the new list framework.

Resolves #350
Resolves #335
Resolves #380
This commit is contained in:
Alexander Capehart 2023-03-16 19:37:03 -06:00
parent bde4035654
commit 59818471d6
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 88 additions and 113 deletions

View file

@ -10,6 +10,7 @@ deletion
#### What's Improved #### What's Improved
- Accept `REPLAYGAIN_*` adjustment information on OPUS files alongside - Accept `REPLAYGAIN_*` adjustment information on OPUS files alongside
`R128_*` adjustments. `R128_*` adjustments.
- List updates are now consistent across the app
## 3.0.3 ## 3.0.3

View file

@ -23,6 +23,7 @@ import androidx.recyclerview.widget.*
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor import java.util.concurrent.Executor
import org.oxycblt.auxio.util.logD
/** /**
* A variant of ListDiffer with more flexible updates. * A variant of ListDiffer with more flexible updates.
@ -52,7 +53,10 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
newData: List<T>, newData: List<T>,
instructions: UpdateInstructions?, instructions: UpdateInstructions?,
callback: (() -> Unit)? = null callback: (() -> Unit)? = null
) = differ.update(newData, instructions, callback) ) =
differ.update(newData, instructions, callback).also {
logD("Update delivered: $instructions" + "")
}
} }
/** /**
@ -72,6 +76,14 @@ sealed class UpdateInstructions {
*/ */
data class Replace(val from: Int) : UpdateInstructions() data class Replace(val from: Int) : UpdateInstructions()
/**
* Add a new set of items.
*
* @param at The position at which to add.
* @param size The amount of items to add.
*/
data class Add(val at: Int, val size: Int) : UpdateInstructions()
/** /**
* Move one item to another location. * Move one item to another location.
* *
@ -116,10 +128,6 @@ private class FlexibleListDiffer<T>(
fun update(newList: List<T>, instructions: UpdateInstructions?, callback: (() -> Unit)?) { fun update(newList: List<T>, instructions: UpdateInstructions?, callback: (() -> Unit)?) {
// incrementing generation means any currently-running diffs are discarded when they finish // incrementing generation means any currently-running diffs are discarded when they finish
val runGeneration = ++maxScheduledGeneration val runGeneration = ++maxScheduledGeneration
if (currentList == newList) {
callback?.invoke()
return
}
when (instructions) { when (instructions) {
is UpdateInstructions.Replace -> { is UpdateInstructions.Replace -> {
updateCallback.onRemoved(instructions.from, currentList.size - instructions.from) updateCallback.onRemoved(instructions.from, currentList.size - instructions.from)
@ -130,6 +138,11 @@ private class FlexibleListDiffer<T>(
} }
callback?.invoke() callback?.invoke()
} }
is UpdateInstructions.Add -> {
currentList = newList
updateCallback.onInserted(instructions.at, instructions.size)
callback?.invoke()
}
is UpdateInstructions.Move -> { is UpdateInstructions.Move -> {
currentList = newList currentList = newList
updateCallback.onMoved(instructions.from, instructions.to) updateCallback.onMoved(instructions.from, instructions.to)

View file

@ -114,9 +114,9 @@ constructor(
_song.value = queue.currentSong _song.value = queue.currentSong
} }
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { override fun onQueueChanged(queue: Queue, change: Queue.Change) {
// Other types of queue changes preserve the current song. // Other types of queue changes preserve the current song.
if (change == Queue.ChangeResult.SONG) { if (change.type == Queue.Change.Type.SONG) {
_song.value = queue.currentSong _song.value = queue.currentSong
} }
} }

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.playback.queue
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextInt import kotlin.random.nextInt
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -51,19 +52,25 @@ interface Queue {
fun resolve(): List<Song> fun resolve(): List<Song>
/** /**
* Represents the possible changes that can occur during certain queue mutation events. The * Represents the possible changes that can occur during certain queue mutation events.
* precise meanings of these differ somewhat depending on the type of mutation done. *
* @param type The [Type] of the change to the internal queue state.
* @param instructions The update done to the resolved queue list.
*/ */
enum class ChangeResult { data class Change(val type: Type, val instructions: UpdateInstructions) {
/** Only the mapping has changed. */ enum class Type {
MAPPING, /** Only the mapping has changed. */
/** The mapping has changed, and the index also changed to align with it. */ MAPPING,
INDEX,
/** /** The mapping has changed, and the index also changed to align with it. */
* The current song has changed, possibly alongside the mapping and index depending on the INDEX,
* context.
*/ /**
SONG * The current song has changed, possibly alongside the mapping and index depending on
* the context.
*/
SONG
}
} }
/** /**
@ -98,26 +105,6 @@ interface Queue {
} }
} }
data class QueueChange(val internal: InternalChange, val externalChange: ExternalChange) {
enum class InternalChange {
/** Only the mapping has changed. */
MAPPING,
/** The mapping has changed, and the index also changed to align with it. */
INDEX,
/**
* The current song has changed, possibly alongside the mapping and index depending on the
* context.
*/
SONG
}
sealed class ExternalChange {
data class Add(val at: Int, val amount: Int) : ExternalChange()
data class Remove(val at: Int) : ExternalChange()
data class Move(val from: Int, val to: Int) : ExternalChange()
}
}
class EditableQueue : Queue { class EditableQueue : Queue {
@Volatile private var heap = mutableListOf<Song>() @Volatile private var heap = mutableListOf<Song>()
@Volatile private var orderedMapping = mutableListOf<Int>() @Volatile private var orderedMapping = mutableListOf<Int>()
@ -213,17 +200,9 @@ class EditableQueue : Queue {
* Add [Song]s to the top of the queue. Will start playback if nothing is playing. * Add [Song]s to the top of the queue. Will start playback if nothing is playing.
* *
* @param songs The [Song]s to add. * @param songs The [Song]s to add.
* @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or * @return A [Queue.Change] instance that reflects the changes made.
* [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start
* new playback.
*/ */
fun playNext(songs: List<Song>): Queue.ChangeResult { fun playNext(songs: List<Song>): Queue.Change {
if (orderedMapping.isEmpty()) {
// No playback, start playing these songs.
start(songs[0], songs, false)
return Queue.ChangeResult.SONG
}
val heapIndices = songs.map(::addSongToHeap) val heapIndices = songs.map(::addSongToHeap)
if (shuffledMapping.isNotEmpty()) { if (shuffledMapping.isNotEmpty()) {
// Add the new songs in front of the current index in the shuffled mapping and in front // Add the new songs in front of the current index in the shuffled mapping and in front
@ -236,24 +215,17 @@ class EditableQueue : Queue {
orderedMapping.addAll(index + 1, heapIndices) orderedMapping.addAll(index + 1, heapIndices)
} }
check() check()
return Queue.ChangeResult.MAPPING return Queue.Change(
Queue.Change.Type.MAPPING, UpdateInstructions.Add(index + 1, songs.size))
} }
/** /**
* Add [Song]s to the end of the queue. Will start playback if nothing is playing. * Add [Song]s to the end of the queue. Will start playback if nothing is playing.
* *
* @param songs The [Song]s to add. * @param songs The [Song]s to add.
* @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or * @return A [Queue.Change] instance that reflects the changes made.
* [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start
* new playback.
*/ */
fun addToQueue(songs: List<Song>): Queue.ChangeResult { fun addToQueue(songs: List<Song>): Queue.Change {
if (orderedMapping.isEmpty()) {
// No playback, start playing these songs.
start(songs[0], songs, false)
return Queue.ChangeResult.SONG
}
val heapIndices = songs.map(::addSongToHeap) val heapIndices = songs.map(::addSongToHeap)
// Can simple append the new songs to the end of both mappings. // Can simple append the new songs to the end of both mappings.
orderedMapping.addAll(heapIndices) orderedMapping.addAll(heapIndices)
@ -261,7 +233,8 @@ class EditableQueue : Queue {
shuffledMapping.addAll(heapIndices) shuffledMapping.addAll(heapIndices)
} }
check() check()
return Queue.ChangeResult.MAPPING return Queue.Change(
Queue.Change.Type.MAPPING, UpdateInstructions.Add(index + 1, songs.size))
} }
/** /**
@ -269,11 +242,9 @@ class EditableQueue : Queue {
* *
* @param src The position of the [Song] to move. * @param src The position of the [Song] to move.
* @param dst The destination position of the [Song]. * @param dst The destination position of the [Song].
* @return [Queue.ChangeResult.MAPPING] if the move occurred after the current index, * @return A [Queue.Change] instance that reflects the changes made.
* [Queue.ChangeResult.INDEX] if the move occurred before or at the current index, requiring
* it to be mutated.
*/ */
fun move(src: Int, dst: Int): Queue.ChangeResult { fun move(src: Int, dst: Int): Queue.Change {
if (shuffledMapping.isNotEmpty()) { if (shuffledMapping.isNotEmpty()) {
// Move songs only in the shuffled mapping. There is no sane analogous form of // Move songs only in the shuffled mapping. There is no sane analogous form of
// this for the ordered mapping. // this for the ordered mapping.
@ -293,22 +264,20 @@ class EditableQueue : Queue {
else -> { else -> {
// Nothing to do. // Nothing to do.
check() check()
return Queue.ChangeResult.MAPPING return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Move(src, dst))
} }
} }
check() check()
return Queue.ChangeResult.INDEX return Queue.Change(Queue.Change.Type.INDEX, UpdateInstructions.Move(src, dst))
} }
/** /**
* Remove a [Song] at the given position. * Remove a [Song] at the given position.
* *
* @param at The position of the [Song] to remove. * @param at The position of the [Song] to remove.
* @return [Queue.ChangeResult.MAPPING] if the removed [Song] was after the current index, * @return A [Queue.Change] instance that reflects the changes made.
* [Queue.ChangeResult.INDEX] if the removed [Song] was before the current index, and
* [Queue.ChangeResult.SONG] if the currently playing [Song] was removed.
*/ */
fun remove(at: Int): Queue.ChangeResult { fun remove(at: Int): Queue.Change {
if (shuffledMapping.isNotEmpty()) { if (shuffledMapping.isNotEmpty()) {
// Remove the specified index in the shuffled mapping and the analogous song in the // Remove the specified index in the shuffled mapping and the analogous song in the
// ordered mapping. // ordered mapping.
@ -323,20 +292,20 @@ class EditableQueue : Queue {
// of the player to be completely invalidated. It's generally easier to not remove the // of the player to be completely invalidated. It's generally easier to not remove the
// song and retain player state consistency. // song and retain player state consistency.
val result = val type =
when { when {
// We just removed the currently playing song. // We just removed the currently playing song.
index == at -> Queue.ChangeResult.SONG index == at -> Queue.Change.Type.SONG
// Index was ahead of removed song, shift back to preserve consistency. // Index was ahead of removed song, shift back to preserve consistency.
index > at -> { index > at -> {
index -= 1 index -= 1
Queue.ChangeResult.INDEX Queue.Change.Type.INDEX
} }
// Nothing to do // Nothing to do
else -> Queue.ChangeResult.MAPPING else -> Queue.Change.Type.MAPPING
} }
check() check()
return result return Queue.Change(type, UpdateInstructions.Remove(at))
} }
/** /**

View file

@ -58,6 +58,7 @@ class QueueAdapter(private val listener: EditableListListener<Song>) :
position: Int, position: Int,
payload: List<Any> payload: List<Any>
) { ) {
logD("$position ${getItem(position).rawName}")
if (payload.isEmpty()) { if (payload.isEmpty()) {
viewHolder.bind(getItem(position), listener) viewHolder.bind(getItem(position), listener)
} }

View file

@ -127,9 +127,11 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder target: RecyclerView.ViewHolder
) = ): Boolean {
playbackModel.moveQueueDataItems( logD("${viewHolder.bindingAdapterPosition} ${target.bindingAdapterPosition}")
return playbackModel.moveQueueDataItems(
viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
playbackModel.removeQueueDataItem(viewHolder.bindingAdapterPosition) playbackModel.removeQueueDataItem(viewHolder.bindingAdapterPosition)

View file

@ -80,6 +80,9 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
touchHelper = null touchHelper = null
binding.queueRecycler.adapter = null binding.queueRecycler.adapter = null
// Avoid possible race conditions that could cause a bad instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
queueModel.queueInstructions.consume()
} }
override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) {

View file

@ -63,12 +63,11 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
_index.value = queue.index _index.value = queue.index
} }
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { override fun onQueueChanged(queue: Queue, change: Queue.Change) {
// Queue changed trivially due to item mo -> Diff queue, stay at current index. // Queue changed trivially due to item mo -> Diff queue, stay at current index.
// TODO: Terrible idea, need to manually deliver updates _queueInstructions.put(change.instructions)
_queueInstructions.put(UpdateInstructions.Diff)
_queue.value = queue.resolve() _queue.value = queue.resolve()
if (change != Queue.ChangeResult.MAPPING) { if (change.type != Queue.Change.Type.MAPPING) {
// Index changed, make sure it remains updated without actually scrolling to it. // Index changed, make sure it remains updated without actually scrolling to it.
_index.value = queue.index _index.value = queue.index
} }

View file

@ -244,12 +244,12 @@ interface PlaybackStateManager {
fun onIndexMoved(queue: Queue) {} fun onIndexMoved(queue: Queue) {}
/** /**
* Called when the [Queue] changed in a manner outlined by the given [Queue.ChangeResult]. * Called when the [Queue] changed in a manner outlined by the given [Queue.Change].
* *
* @param queue The new [Queue]. * @param queue The new [Queue].
* @param change The type of [Queue.ChangeResult] that occurred. * @param change The type of [Queue.Change] that occurred.
*/ */
fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {} fun onQueueChanged(queue: Queue, change: Queue.Change) {}
/** /**
* Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
@ -423,31 +423,19 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
@Synchronized @Synchronized
override fun playNext(songs: List<Song>) { override fun playNext(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return if (queue.currentSong == null) {
when (queue.playNext(songs)) { play(songs[0], null, songs, false)
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) } else {
Queue.ChangeResult.SONG -> { notifyQueueChanged(queue.playNext(songs))
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
} }
} }
@Synchronized @Synchronized
override fun addToQueue(songs: List<Song>) { override fun addToQueue(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return if (queue.currentSong == null) {
when (queue.addToQueue(songs)) { play(songs[0], null, songs, false)
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) } else {
Queue.ChangeResult.SONG -> { notifyQueueChanged(queue.addToQueue(songs))
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
} }
} }
@ -462,7 +450,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
val internalPlayer = internalPlayer ?: return val internalPlayer = internalPlayer ?: return
logD("Removing item at $at") logD("Removing item at $at")
val change = queue.remove(at) val change = queue.remove(at)
if (change == Queue.ChangeResult.SONG) { if (change.type == Queue.Change.Type.SONG) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
} }
notifyQueueChanged(change) notifyQueueChanged(change)
@ -568,7 +556,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
} }
} }
private fun notifyQueueChanged(change: Queue.ChangeResult) { private fun notifyQueueChanged(change: Queue.Change) {
for (callback in listeners) { for (callback in listeners) {
callback.onQueueChanged(queue, change) callback.onQueueChanged(queue, change)
} }

View file

@ -118,16 +118,15 @@ constructor(
invalidateSessionState() invalidateSessionState()
} }
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { override fun onQueueChanged(queue: Queue, change: Queue.Change) {
updateQueue(queue) updateQueue(queue)
when (change) { when (change.type) {
// Nothing special to do with mapping changes. // Nothing special to do with mapping changes.
Queue.ChangeResult.MAPPING -> {} Queue.Change.Type.MAPPING -> {}
// Index changed, ensure playback state's index changes. // Index changed, ensure playback state's index changes.
Queue.ChangeResult.INDEX -> invalidateSessionState() Queue.Change.Type.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes. // Song changed, ensure metadata changes.
Queue.ChangeResult.SONG -> Queue.Change.Type.SONG -> updateMediaMetadata(queue.currentSong, playbackManager.parent)
updateMediaMetadata(queue.currentSong, playbackManager.parent)
} }
} }