queue: rework internal queue system

Rework the queue internally to decouple the queue from playback and
better respond to reshuffling.

This is being implemented under the assumption that I will be
implementing the sliding queue eventually.
This commit is contained in:
OxygenCobalt 2022-07-26 10:57:24 -06:00
parent 496b72ca78
commit 6c59a03042
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 178 additions and 91 deletions

View file

@ -28,10 +28,10 @@ import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.ui.recycler.AsyncBackingData
import org.oxycblt.auxio.ui.recycler.BindingViewHolder
import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.MultiAdapter
import org.oxycblt.auxio.ui.recycler.NewHeaderViewHolder
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
@ -70,14 +70,14 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
override fun getCreatorFromItem(item: Item) =
when (item) {
is Header -> NewHeaderViewHolder.CREATOR
is Header -> HeaderViewHolder.CREATOR
is SortHeader -> SortHeaderViewHolder.CREATOR
else -> null
}
override fun getCreatorFromViewType(viewType: Int) =
when (viewType) {
NewHeaderViewHolder.CREATOR.viewType -> NewHeaderViewHolder.CREATOR
HeaderViewHolder.CREATOR.viewType -> HeaderViewHolder.CREATOR
SortHeaderViewHolder.CREATOR.viewType -> SortHeaderViewHolder.CREATOR
else -> null
}
@ -90,7 +90,7 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
) {
if (payload.isEmpty()) {
when (item) {
is Header -> (viewHolder as NewHeaderViewHolder).bind(item, Unit)
is Header -> (viewHolder as HeaderViewHolder).bind(item, Unit)
is SortHeader -> (viewHolder as SortHeaderViewHolder).bind(item, listener)
}
}
@ -111,7 +111,7 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Header && newItem is Header ->
NewHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> false

View file

@ -114,8 +114,6 @@ class PlaybackPanelFragment :
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
collectImmediately(playbackModel.nextUp, ::updateNextUp)
logD("Fragment Created")
}
@ -195,8 +193,4 @@ class PlaybackPanelFragment :
private fun updateShuffled(isShuffled: Boolean) {
requireBinding().playbackShuffle.isActivated = isShuffled
}
private fun updateNextUp(nextUp: List<Song>) {
queueItem.isEnabled = nextUp.isNotEmpty()
}
}

View file

@ -36,7 +36,6 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/**
@ -77,11 +76,6 @@ class PlaybackViewModel(application: Application) :
val isShuffled: StateFlow<Boolean>
get() = _isShuffled
private val _nextUp = MutableStateFlow(listOf<Song>())
/** The queue, without the previous items. */
val nextUp: StateFlow<List<Song>>
get() = _nextUp
init {
musicStore.addCallback(this)
playbackManager.addCallback(this)
@ -196,39 +190,6 @@ class PlaybackViewModel(application: Application) :
playbackManager.prev()
}
/**
* Go to an item in the queue using it's recyclerview adapter index. No-ops if out of bounds.
*/
fun goto(adapterIndex: Int) {
val index = adapterIndex + (playbackManager.queue.size - _nextUp.value.size)
logD(adapterIndex)
logD(playbackManager.queue.size - _nextUp.value.size)
if (index in playbackManager.queue.indices) {
playbackManager.goto(index)
}
}
/** Remove a queue item using it's recyclerview adapter index. */
fun removeQueueDataItem(adapterIndex: Int) {
val index = adapterIndex + (playbackManager.queue.size - _nextUp.value.size)
if (index in playbackManager.queue.indices) {
playbackManager.removeQueueItem(index)
}
}
/** Move queue items using their recyclerview adapter indices. */
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
val delta = (playbackManager.queue.size - _nextUp.value.size)
val from = adapterFrom + delta
val to = adapterTo + delta
if (from in playbackManager.queue.indices && to in playbackManager.queue.indices) {
playbackManager.moveQueueItem(from, to)
return true
}
return false
}
/** Add a [Song] to the top of the queue. */
fun playNext(song: Song) {
playbackManager.playNext(song)
@ -334,17 +295,11 @@ class PlaybackViewModel(application: Application) :
override fun onIndexMoved(index: Int) {
_song.value = playbackManager.song
_nextUp.value = playbackManager.queue.slice(index + 1 until playbackManager.queue.size)
}
override fun onQueueChanged(index: Int, queue: List<Song>) {
_nextUp.value = queue.slice(index + 1 until queue.size)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
_song.value = playbackManager.song
_parent.value = playbackManager.parent
_nextUp.value = queue.slice(index + 1 until queue.size)
}
override fun onPositionChanged(positionMs: Long) {

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.recycler.*
import org.oxycblt.auxio.util.*
class QueueAdapter(private val listener: QueueItemListener) :
class QueueAdapter(listener: QueueItemListener) :
MonoAdapter<Song, QueueItemListener, QueueSongViewHolder>(listener) {
override val data = SyncBackingData(this, QueueSongViewHolder.DIFFER)
override val creator = QueueSongViewHolder.CREATOR

View file

@ -27,7 +27,6 @@ import kotlin.math.max
import kotlin.math.min
import kotlin.math.sign
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenSafe
import org.oxycblt.auxio.util.logD
@ -37,7 +36,7 @@ import org.oxycblt.auxio.util.logD
* hot garbage. This shouldn't have *too many* UI bugs. I hope.
* @author OxygenCobalt
*/
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
private var shouldLift = true
override fun getMovementFlags(

View file

@ -20,14 +20,12 @@ package org.oxycblt.auxio.playback.queue
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
/**
@ -35,10 +33,10 @@ import org.oxycblt.auxio.util.collectImmediately
* @author OxygenCobalt
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueModel: QueueViewModel by activityViewModels()
private val queueAdapter = QueueAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(QueueDragCallback(playbackModel))
ItemTouchHelper(QueueDragCallback(queueModel))
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
@ -53,7 +51,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
// --- VIEWMODEL SETUP ----
collectImmediately(playbackModel.nextUp, ::updateQueue)
collectImmediately(queueModel.queue, ::updateQueue)
}
override fun onDestroyBinding(binding: FragmentQueueBinding) {
@ -62,19 +60,20 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
}
override fun onClick(viewHolder: RecyclerView.ViewHolder) {
playbackModel.goto(viewHolder.bindingAdapterPosition)
queueModel.goto(viewHolder.bindingAdapterPosition)
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder)
}
private fun updateQueue(queue: List<Song>) {
if (queue.isEmpty()) {
findNavController().navigateUp()
return
private fun updateQueue(queue: QueueViewModel.QueueData) {
if (queue.nonTrivial) {
// nonTrivial implies that using a synced submitList would be slow, replace the list
// instead.
queueAdapter.data.replaceList(queue.queue)
} else {
queueAdapter.data.submitList(queue.queue)
}
queueAdapter.data.submitList(queue.toMutableList())
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2022 Auxio Project
*
* 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.queue
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD
class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
data class QueueData(val queue: List<Song>, val nonTrivial: Boolean)
private val _queue = MutableStateFlow(QueueData(listOf(), false))
val queue: StateFlow<QueueData> = _queue
init {
playbackManager.addCallback(this)
}
/**
* Go to an item in the queue using it's recyclerview adapter index. No-ops if out of bounds.
*/
fun goto(adapterIndex: Int) {
val index = adapterIndex + (playbackManager.queue.size - _queue.value.queue.size)
logD(adapterIndex)
logD(playbackManager.queue.size - _queue.value.queue.size)
if (index in playbackManager.queue.indices) {
playbackManager.goto(index)
}
}
/** Remove a queue item using it's recyclerview adapter index. */
fun removeQueueDataItem(adapterIndex: Int) {
val index = adapterIndex + (playbackManager.queue.size - _queue.value.queue.size)
if (index in playbackManager.queue.indices) {
playbackManager.removeQueueItem(index)
}
}
/** Move queue items using their recyclerview adapter indices. */
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
val delta = (playbackManager.queue.size - _queue.value.queue.size)
val from = adapterFrom + delta
val to = adapterTo + delta
if (from in playbackManager.queue.indices && to in playbackManager.queue.indices) {
playbackManager.moveQueueItem(from, to)
return true
}
return false
}
override fun onIndexMoved(index: Int) {
_queue.value = QueueData(generateQueue(index, playbackManager.queue), false)
}
override fun onQueueChanged(queue: List<Song>) {
_queue.value = QueueData(generateQueue(playbackManager.index, queue), false)
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
_queue.value = QueueData(generateQueue(index, queue), true)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
_queue.value = QueueData(generateQueue(index, queue), true)
}
private fun generateQueue(index: Int, queue: List<Song>) =
queue.slice(index + 1..playbackManager.queue.lastIndex)
override fun onCleared() {
super.onCleared()
playbackManager.removeCallback(this)
}
}

View file

@ -280,7 +280,7 @@ class PlaybackStateManager private constructor() {
val library = musicStore.library ?: return
val song = song ?: return
applyNewQueue(library, settings, shuffled, song)
notifyQueueChanged()
notifyQueueReworked()
notifyShuffledChanged()
}
@ -464,7 +464,13 @@ class PlaybackStateManager private constructor() {
private fun notifyQueueChanged() {
for (callback in callbacks) {
callback.onQueueChanged(index, queue)
callback.onQueueChanged(queue)
}
}
private fun notifyQueueReworked() {
for (callback in callbacks) {
callback.onQueueReworked(index, queue)
}
}
@ -529,8 +535,11 @@ class PlaybackStateManager private constructor() {
/** Called when the index is moved, but the queue does not change. This changes the song. */
fun onIndexMoved(index: Int) {}
/** Called when the queue and/or index changed, but the song has not. */
fun onQueueChanged(index: Int, queue: List<Song>) {}
/** Called when the queue has changed in a way that does not change the index or song. */
fun onQueueChanged(queue: List<Song>) {}
/** Called when the queue and index has changed, but the song has not changed.. */
fun onQueueReworked(index: Int, queue: List<Song>) {}
/** Called when playback is changed completely, with a new index, queue, and parent. */
fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {}

View file

@ -20,6 +20,8 @@ package org.oxycblt.auxio.playback.system
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.os.SystemClock
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
@ -101,10 +103,15 @@ class MediaSessionComponent(
invalidateSessionState()
}
override fun onQueueChanged(index: Int, queue: List<Song>) {
override fun onQueueChanged(queue: List<Song>) {
updateQueue(queue)
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
updateQueue(queue)
invalidateSessionState()
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
updateMediaMetadata(playbackManager.song, parent)
updateQueue(queue)
@ -118,8 +125,8 @@ class MediaSessionComponent(
return
}
// We would leave the artist field null if it didn't exist and let downstream consumers
// handle it, but that would break the notification display.
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
val title = song.resolveName(context)
val artist = song.resolveIndividualArtistName(context)
val builder =
@ -180,11 +187,11 @@ class MediaSessionComponent(
private fun updateQueue(queue: List<Song>) {
val queueItems =
queue.mapIndexed { i, song ->
// Since we usually have to load many songs into the queue, use the Cover URI
// Since we usually have to load many songs into the queue, use the MediaStore URI
// instead of loading a bitmap.
val description =
MediaDescriptionCompat.Builder()
.setMediaId(song.id.toString())
.setMediaId("Song:${song.id}")
.setTitle(song.resolveName(context))
.setSubtitle(song.resolveIndividualArtistName(context))
.setIconUri(song.album.coverUri)
@ -245,8 +252,8 @@ class MediaSessionComponent(
invalidateSessionState()
if (!playbackManager.isPlaying) {
// Hack around issue where the position won't update after a seek (but only when it's
// paused). Apparently this can be fixed by re-posting the notification, but not always
// Hack around issue where the position won't update after a seek when paused.
// Apparently this can be fixed by re-posting the notification, but not always
// when we invalidate the state (that will cause us to be rate-limited), and also not
// always when we seek (that will also cause us to be rate-limited). Someone looked at
// this system and said it was well-designed.
@ -256,6 +263,31 @@ class MediaSessionComponent(
// --- MEDIASESSION CALLBACKS ---
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras)
// STUB: Unimplemented
}
override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
super.onPlayFromUri(uri, extras)
// STUB: Unimplemented
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras)
// STUB: Unimplemented
}
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
super.onAddQueueItem(description)
// STUB: Unimplemented
}
override fun onRemoveQueueItem(description: MediaDescriptionCompat?) {
super.onRemoveQueueItem(description)
// STUB: Unimplemented
}
override fun onPlay() {
playbackManager.isPlaying = true
}

View file

@ -27,10 +27,10 @@ import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.AsyncBackingData
import org.oxycblt.auxio.ui.recycler.GenreViewHolder
import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.MultiAdapter
import org.oxycblt.auxio.ui.recycler.NewHeaderViewHolder
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.ui.recycler.SongViewHolder
@ -43,7 +43,7 @@ class SearchAdapter(listener: MenuItemListener) : MultiAdapter<MenuItemListener>
is Album -> AlbumViewHolder.CREATOR
is Artist -> ArtistViewHolder.CREATOR
is Genre -> GenreViewHolder.CREATOR
is Header -> NewHeaderViewHolder.CREATOR
is Header -> HeaderViewHolder.CREATOR
else -> null
}
@ -53,7 +53,7 @@ class SearchAdapter(listener: MenuItemListener) : MultiAdapter<MenuItemListener>
AlbumViewHolder.CREATOR.viewType -> AlbumViewHolder.CREATOR
ArtistViewHolder.CREATOR.viewType -> ArtistViewHolder.CREATOR
GenreViewHolder.CREATOR.viewType -> GenreViewHolder.CREATOR
NewHeaderViewHolder.CREATOR.viewType -> NewHeaderViewHolder.CREATOR
HeaderViewHolder.CREATOR.viewType -> HeaderViewHolder.CREATOR
else -> null
}
@ -68,7 +68,7 @@ class SearchAdapter(listener: MenuItemListener) : MultiAdapter<MenuItemListener>
is Album -> (viewHolder as AlbumViewHolder).bind(item, listener)
is Artist -> (viewHolder as ArtistViewHolder).bind(item, listener)
is Genre -> (viewHolder as GenreViewHolder).bind(item, listener)
is Header -> (viewHolder as NewHeaderViewHolder).bind(item, Unit)
is Header -> (viewHolder as HeaderViewHolder).bind(item, Unit)
else -> {}
}
}
@ -87,7 +87,7 @@ class SearchAdapter(listener: MenuItemListener) : MultiAdapter<MenuItemListener>
oldItem is Genre && newItem is Genre ->
GenreViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
oldItem is Header && newItem is Header ->
NewHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
else -> false
}
}

View file

@ -201,7 +201,7 @@ private constructor(
* The Shared ViewHolder for a [Header].
* @author OxygenCobalt
*/
class NewHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
BindingViewHolder<Header, Unit>(binding.root) {
override fun bind(item: Header, listener: Unit) {
@ -210,12 +210,12 @@ class NewHeaderViewHolder private constructor(private val binding: ItemHeaderBin
companion object {
val CREATOR =
object : Creator<NewHeaderViewHolder> {
object : Creator<HeaderViewHolder> {
override val viewType: Int
get() = IntegerTable.ITEM_TYPE_HEADER
override fun create(context: Context) =
NewHeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
HeaderViewHolder(ItemHeaderBinding.inflate(context.inflater))
}
val DIFFER =

View file

@ -27,6 +27,7 @@
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/interact_body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">

View file

@ -81,6 +81,8 @@
<string name="lbl_shuffle">Shuffle</string>
<string name="lbl_queue">Queue</string>
<string name="lbl_next_up">Next up</string>
<string name="lbl_later">Later</string>
<string name="lbl_play_next">Play next</string>
<string name="lbl_queue_add">Add to queue</string>
<string name="lbl_queue_added">Added to queue</string>