queue: redocument

Redocument the queue module.
This commit is contained in:
Alexander Capehart 2022-12-25 19:27:40 -07:00
parent b086c44b59
commit 7394e87471
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
24 changed files with 258 additions and 148 deletions

View file

@ -48,10 +48,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
*
* TODO: Migrate to material animation system
*
* TODO: Re-document project
*
* TODO: Unit testing
*
* TODO: Standardize from/new usage
*
* TODO: Standardize companion object usage
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MainActivity : AppCompatActivity() {

View file

@ -27,7 +27,7 @@ import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.detail.DiscHeader
import org.oxycblt.auxio.list.ExtendedListListener
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
@ -224,10 +224,10 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
/**
* Bind new data to this instance.
* @param song The new [Song] to bind.
* @param listener A [ExtendedListListener] to bind interactions to.
* @param listener A [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: ExtendedListListener) {
listener.bind(song, binding.root, binding.songMenu)
fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu)
binding.songTrack.apply {
if (song.track != null) {

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.ExtendedListListener
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
@ -181,10 +181,10 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
/**
* Bind new data to this instance.
* @param album The new [Album] to bind.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: ExtendedListListener) {
listener.bind(album, binding.root, binding.parentMenu)
fun bind(album: Album, listener: SelectableListListener) {
listener.bind(this, album, binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text =
@ -232,10 +232,10 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
/**
* Bind new data to this instance.
* @param song The new [Song] to bind.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: ExtendedListListener) {
listener.bind(song, binding.root, binding.songMenu)
fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.album.resolveName(binding.context)

View file

@ -26,7 +26,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.detail.SortHeader
import org.oxycblt.auxio.list.ExtendedListListener
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.recycler.*
@ -87,8 +87,8 @@ abstract class DetailAdapter(
differ.submitList(newList)
}
/** An extended [ExtendedListListener] for [DetailAdapter] implementations. */
interface Listener : ExtendedListListener {
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
interface Listener : SelectableListListener {
// TODO: Split off into sub-listeners if a collapsing toolbar is implemented.
/**
* Called when the play button in a detail header is pressed, requesting that the current

View file

@ -139,9 +139,9 @@ class AlbumListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRec
/**
* A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class AlbumAdapter(private val listener: ExtendedListListener) :
private class AlbumAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<AlbumViewHolder>() {
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK)

View file

@ -114,9 +114,9 @@ class ArtistListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRe
/**
* A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class ArtistAdapter(private val listener: ExtendedListListener) :
private class ArtistAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<ArtistViewHolder>() {
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK)

View file

@ -113,9 +113,9 @@ class GenreListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRec
/**
* A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class GenreAdapter(private val listener: ExtendedListListener) :
private class GenreAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<GenreViewHolder>() {
private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK)

View file

@ -153,9 +153,9 @@ class SongListFragment : ListFragment<FragmentHomeListBinding>(), FastScrollRecy
/**
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class SongAdapter(private val listener: ExtendedListListener) :
private class SongAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<SongViewHolder>() {
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)

View file

@ -85,10 +85,11 @@ class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewH
fun onToggleVisibility(tabMode: MusicMode)
/**
* Called when the drag handle is pressed, requesting that a drag should be started.
* Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
* drag should be started.
* @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
*/
fun onPickUpTab(viewHolder: RecyclerView.ViewHolder)
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
}
companion object {
@ -105,7 +106,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
/**
* Bind new data to this instance.
* @param tab The new [Tab] to bind.
* @param listener An [TabAdapter.Listener] to bind interactions to.
* @param listener A [TabAdapter.Listener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: TabAdapter.Listener) {
@ -130,14 +131,13 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onPickUpTab(this)
listener.onPickUp(this)
true
} else false
}
}
companion object {
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.

View file

@ -104,7 +104,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
tabAdapter.tabs.filterIsInstance<Tab.Visible>().isNotEmpty()
}
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) {
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder)
}

View file

@ -2,7 +2,6 @@ package org.oxycblt.auxio.list
import androidx.annotation.StringRes
/**
* A marker for something that is a RecyclerView item. Has no functionality on it's own.
*/

View file

@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.showToast
* A Fragment containing a selectable list.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), ExtendedListListener {
abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), SelectableListListener {
protected val navModel: NavigationViewModel by activityViewModels()
private var currentMenu: PopupMenu? = null

View file

@ -1,12 +1,17 @@
package org.oxycblt.auxio.list
import android.view.MotionEvent
import android.view.View
import android.widget.Button
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
/**
* A basic listener for list interactions.
* TODO: Supply a ViewHolder on clicks (allows editable lists to be standardized into a listener.)
* @author Alexander Capehart (OxygenCobalt)
*/
interface BasicListListener {
interface ClickableListListener {
/**
* Called when an [Item] in the list is clicked.
* @param item The [Item] that was clicked.
@ -15,9 +20,10 @@ interface BasicListListener {
}
/**
* An extension of [BasicListListener] that enables menu and selection functionality.
* An extension of [ClickableListListener] that enables menu and selection functionality.
* @author Alexander Capehart (OxygenCobalt)
*/
interface ExtendedListListener : BasicListListener {
interface SelectableListListener : ClickableListListener {
/**
* Called when an [Item] in the list requests that a menu related to it should be opened.
* @param item The [Item] to show a menu for.
@ -33,12 +39,12 @@ interface ExtendedListListener : BasicListListener {
/**
* Binds this instance to a list item.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param item The [Item] that this list entry is bound to.
* @param root The root of the list [View].
* @param menuButton A [Button] that opens a menu.
*/
fun bind(item: Item, root: View, menuButton: Button) {
root.apply {
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
viewHolder.itemView.apply {
// Map clicks to the click callback.
setOnClickListener { onClick(item) }
// Map long clicks to the selection callback.
@ -47,7 +53,6 @@ interface ExtendedListListener : BasicListListener {
true
}
}
// Map the menu button to the menu opening callback.
menuButton.setOnClickListener { onOpenMenu(item, it) }
}

View file

@ -24,7 +24,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.ExtendedListListener
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -43,10 +43,10 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
/**
* Bind new data to this instance.
* @param song The new [Song] to bind.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: ExtendedListListener) {
listener.bind(song, binding.root, binding.songMenu)
fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.resolveArtistContents(binding.context)
@ -90,10 +90,10 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
/**
* Bind new data to this instance.
* @param album The new [Album] to bind.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: ExtendedListListener) {
listener.bind(album, binding.root, binding.parentMenu)
fun bind(album: Album, listener: SelectableListListener) {
listener.bind(this, album, binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text = album.resolveArtistContents(binding.context)
@ -139,10 +139,10 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
/**
* Bind new data to this instance.
* @param artist The new [Artist] to bind.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: ExtendedListListener) {
listener.bind(artist, binding.root, binding.parentMenu)
fun bind(artist: Artist, listener: SelectableListListener) {
listener.bind(this, artist, binding.parentMenu)
binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context)
binding.parentInfo.text =
@ -197,10 +197,10 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/**
* Bind new data to this instance.
* @param genre The new [Genre] to bind.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: ExtendedListListener) {
listener.bind(genre, binding.root, binding.parentMenu)
fun bind(genre: Genre, listener: SelectableListListener) {
listener.bind(this, genre, binding.parentMenu)
binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context)
binding.parentInfo.text =

View file

@ -21,7 +21,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding
import org.oxycblt.auxio.list.BasicListListener
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.context
@ -29,10 +29,10 @@ import org.oxycblt.auxio.util.inflater
/**
* An adapter responsible for showing a list of [Artist] choices in [ArtistPickerDialog].
* @param listener A [BasicListListener] to bind interactions to.
* @param listener A [ClickableListListener] to bind interactions to.
* @author OxygenCobalt.
*/
class ArtistChoiceAdapter(private val listener: BasicListListener) :
class ArtistChoiceAdapter(private val listener: ClickableListListener) :
RecyclerView.Adapter<ArtistChoiceViewHolder>() {
private var artists = listOf<Artist>()
@ -65,9 +65,9 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
/**
* Bind new data to this instance.
* @param artist The new [Artist] to bind.
* @param listener A [BasicListListener] to bind interactions to.
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: BasicListListener) {
fun bind(artist: Artist, listener: ClickableListListener) {
binding.root.setOnClickListener { listener.onClick(artist) }
binding.pickerImage.bind(artist)
binding.pickerName.text = artist.resolveName(binding.context)

View file

@ -24,7 +24,7 @@ import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.BasicListListener
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.shared.ViewBindingDialogFragment
@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.collectImmediately
* multiple [Artist]'s to choose from.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ArtistPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), BasicListListener {
abstract class ArtistPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
protected val pickerModel: PickerViewModel by viewModels()
// Okay to leak this since the Listener will not be called until after initialization.
private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this)

View file

@ -36,9 +36,17 @@ import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
class QueueAdapter(private val listener: QueueItemListener) :
/**
* A [RecyclerView.Adapter] that shows an editable list of queue items.
* @param listener A [Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueAdapter(private val listener: Listener) :
RecyclerView.Adapter<QueueSongViewHolder>() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
// adapter, as one item can appear at several points in the UI. Use a similar implementation
// with an index value instead.
private var currentIndex = 0
private var isPlaying = false
@ -59,63 +67,106 @@ class QueueAdapter(private val listener: QueueItemListener) :
viewHolder.bind(differ.currentList[position], listener)
}
viewHolder.isEnabled = position > currentIndex
viewHolder.isFuture = position > currentIndex
viewHolder.updatePlayingIndicator(position == currentIndex, isPlaying)
}
/**
* Synchronously update the list with new items. This is exceedingly slow for large diffs,
* so only use it for trivial updates.
* @param newList The new [Song]s for the adapter to display.
*/
fun submitList(newList: List<Song>) {
differ.submitList(newList)
}
/**
* Replace the list with a new list. This is exceedingly slow for large diffs,
* so only use it for trivial updates.
* @param newList The new [Song]s for the adapter to display.
*/
fun replaceList(newList: List<Song>) {
differ.replaceList(newList)
}
fun updateIndicator(index: Int, isPlaying: Boolean) {
/**
* Set the position of the currently playing item in the queue. This will mark the item
* as playing and any previous items as played.
* @param index The position of the currently playing item in the queue.
* @param isPlaying Whether playback is ongoing or paused.
*/
fun setPosition(index: Int, isPlaying: Boolean) {
var updatedIndex = false
if (index != currentIndex) {
when {
index < currentIndex -> {
val lastIndex = currentIndex
currentIndex = index
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_INDEX)
}
else -> {
currentIndex = index
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_INDEX)
}
}
val lastIndex = currentIndex
currentIndex = index
updatedIndex = true
// Have to update not only the currently playing item, but also all items marked
// as playing.
if (currentIndex < lastIndex) {
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION)
} else {
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION)
}
}
if (this.isPlaying != isPlaying) {
this.isPlaying = isPlaying
// Don't need to do anything if we've already sent an update from changing the
// index.
if (!updatedIndex) {
notifyItemChanged(index, PAYLOAD_UPDATE_INDEX)
notifyItemChanged(index, PAYLOAD_UPDATE_POSITION)
}
}
}
/**
* A listener for queue list events.
*/
interface Listener {
/**
* Called when a [RecyclerView.ViewHolder] in the list as clicked.
* @param viewHolder The [RecyclerView.ViewHolder] that was clicked.
*/
fun onClick(viewHolder: RecyclerView.ViewHolder)
/**
* Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
* drag should be started.
* @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
*/
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
}
companion object {
val PAYLOAD_UPDATE_INDEX = Any()
private val PAYLOAD_UPDATE_POSITION = Any()
}
}
interface QueueItemListener {
fun onClick(viewHolder: RecyclerView.ViewHolder)
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
}
/**
* A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [new] to create an
* instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueSongViewHolder private constructor(private val binding: ItemQueueSongBinding) :
PlayingIndicatorAdapter.ViewHolder(binding.root) {
/**
* The "body" view of this [QueueSongViewHolder] that shows the [Song] information.
*/
val bodyView: View
get() = binding.body
/**
* The background view of this [QueueSongViewHolder] that shows the delete icon.
*/
val backgroundView: View
get() = binding.background
/**
* The actual background drawable of this [QueueSongViewHolder] that can be manipulated.
*/
val backgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
@ -123,6 +174,19 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
alpha = 0
}
/**
* If this queue item is considered "in the future" (i.e has not played yet).
*/
var isFuture: Boolean
get() = binding.songAlbumCover.isEnabled
set(value) {
// Don't want to disable clicking, just indicate the body and handle is disabled
binding.songAlbumCover.isEnabled = value
binding.songName.isEnabled = value
binding.songInfo.isEnabled = value
binding.songDragHandle.isEnabled = value
}
init {
binding.body.background =
LayerDrawable(
@ -134,17 +198,24 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
backgroundDrawable))
}
/**
* Bind new data to this instance.
* @param song The new [Song] to bind.
* @param listener A [QueueAdapter.Listener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(item: Song, listener: QueueItemListener) {
binding.songAlbumCover.bind(item)
binding.songName.text = item.resolveName(binding.context)
binding.songInfo.text = item.resolveArtistContents(binding.context)
fun bind(song: Song, listener: QueueAdapter.Listener) {
binding.body.setOnClickListener {
listener.onClick(this)
}
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.resolveArtistContents(binding.context)
// TODO: Why is this here?
binding.background.isInvisible = true
binding.body.setOnClickListener { listener.onClick(this) }
// Roll our own drag handlers as the default ones suck
// Set up the drag handle to start a drag whenever it is touched.
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
@ -154,25 +225,21 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
}
}
var isEnabled: Boolean
get() = binding.songAlbumCover.isEnabled
set(value) {
// Don't want to disable clicking, just indicate the body and handle is disabled
binding.songAlbumCover.isEnabled = value
binding.songName.isEnabled = value
binding.songInfo.isEnabled = value
binding.songDragHandle.isEnabled = value
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.interactBody.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
companion object {
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
}
}

View file

@ -32,7 +32,8 @@ import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* The bottom sheet behavior designed for the queue in particular.
* The [BaseBottomSheetBehavior] for the queue bottom sheet. This is placed within the playback
* sheet and automatically arranges itself to show the playback bar at the top.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
@ -41,6 +42,7 @@ class QueueBottomSheetBehavior<V : View>(context: Context, attributeSet: Attribu
private var barSpacing = context.getDimenPixels(R.dimen.spacing_small)
init {
// Not hide-able (and not programmatically hide-able)
isHideable = false
}
@ -53,18 +55,19 @@ class QueueBottomSheetBehavior<V : View>(context: Context, attributeSet: Attribu
dependency: View
): Boolean {
barHeight = dependency.height
return false // No change, just grabbed the height
// No change, just grabbed the height
return false
}
override fun createBackground(context: Context) =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
// The queue sheet's background is a static elevated background.
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal)
}
override fun applyWindowInsets(child: View, insets: WindowInsets): WindowInsets {
super.applyWindowInsets(child, insets)
// Offset our expanded panel by the size of the playback bar, as that is shown when
// we slide up the panel.
val bars = insets.systemBarInsetsCompat

View file

@ -24,12 +24,12 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
/**
* A highly customized [ItemTouchHelper.Callback] that handles the queue system while basically
* rebuilding most the "Material-y" aspects of an editable list because Google's implementations are
* hot garbage. This shouldn't have *too many* UI bugs. I hope.
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in the queue
* UI, such as an animation when lifting items.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
@ -40,11 +40,13 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
viewHolder: RecyclerView.ViewHolder
): Int {
val queueHolder = viewHolder as QueueSongViewHolder
return if (queueHolder.isEnabled) {
return if (queueHolder.isFuture) {
makeFlag(
ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or
makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START)
} else {
// Avoid allowing any touch actions for already-played queue items, as the playback
// system does not currently allow for this.
0
}
}
@ -58,12 +60,11 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
actionState: Int,
isCurrentlyActive: Boolean
) {
// The material design page on elevation has a cool example of draggable items elevating
// themselves when being dragged. Too bad google's implementation of this doesn't even
// work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change.
val holder = viewHolder as QueueSongViewHolder
// Hook drag events to "lifting" the queue item (i.e raising it's elevation). Make sure
// this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting queue item")
@ -72,7 +73,7 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
holder.itemView
.animate()
.translationZ(elevation)
.setDuration(100)
.setDuration(recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.setUpdateListener {
bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt()
}
@ -82,20 +83,19 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
shouldLift = false
}
// We show a background with a clear icon behind the queue song each time one is swiped
// away. To avoid any canvas shenanigans, we just place a custom background view behind the
// main "body" layout of the queue item and then translate that.
//
// We show a background with a delete icon behind the queue song each time one is swiped
// away. To avoid working with canvas, this is simply placed behind the queue body.
// That comes with a couple of problems, however. For one, the background view will always
// lag behind the body view, resulting in a noticeable pixel offset when dragging. To fix
// this, we make this a separate view and make this view invisible whenever the item is
// not being swiped. We cannot merge this view with the FrameLayout, as that will cause
// another weird pixel desynchronization issue that is less visible but still incredibly
// annoying.
// not being swiped. This issue is also the reason why the background is not merged with
// the FrameLayout within the queue item.
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
holder.backgroundView.isInvisible = dX == 0f
}
// Update other translations. We do not call the default implementation, so we must do
// this ourselves.
holder.bodyView.translationX = dX
holder.itemView.translationY = dY
}
@ -104,6 +104,8 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
// When an elevated item is cleared, we reset the elevation using another animation.
val holder = viewHolder as QueueSongViewHolder
// This function can be called multiple times, so only start the animation when the view's
// translationZ is already non-zero.
if (holder.itemView.translationZ != 0f) {
logD("Dropping queue item")
@ -112,7 +114,7 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
holder.itemView
.animate()
.translationZ(0f)
.setDuration(100)
.setDuration(recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.setUpdateListener {
bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt()
}
@ -122,6 +124,8 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
shouldLift = true
// Reset translations. We do not call the default implementation, so we must do
// this ourselves.
holder.bodyView.translationX = 0f
holder.itemView.translationY = 0f
}
@ -138,5 +142,6 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
playbackModel.removeQueueDataItem(viewHolder.bindingAdapterPosition)
}
// Long-press events are too buggy, only allow dragging with the handle.
override fun isLongPressDragEnabled() = false
}

View file

@ -19,7 +19,6 @@ 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.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
@ -33,11 +32,10 @@ import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/**
* A [Fragment] that shows the queue and enables editing as well.
*
* A [ViewBindingFragment] that displays an editable queue.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.Listener {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this)
@ -48,13 +46,15 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.queueRecycler.apply {
adapter = queueAdapter
touchHelper.attachToRecyclerView(this)
}
// --- VIEWMODEL SETUP ----
collectImmediately(
queueModel.queue, queueModel.index, playbackModel.isPlaying, ::updateQueue)
}
@ -65,6 +65,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
}
override fun onClick(viewHolder: RecyclerView.ViewHolder) {
// Clicking on a queue item should start playing it.
queueModel.goto(viewHolder.bindingAdapterPosition)
}
@ -75,31 +76,34 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
private fun updateQueue(queue: List<Song>, index: Int, isPlaying: Boolean) {
val binding = requireBinding()
val replaceQueue = queueModel.replaceQueue
if (replaceQueue == true) {
// Replace or diff the queue depending on the type of change it is.
// TODO: Extend this to the whole app.
if (queueModel.replaceQueue == true) {
logD("Replacing queue")
queueAdapter.replaceList(queue)
} else {
logD("Diffing queue")
queueAdapter.submitList(queue)
}
queueModel.finishReplace()
// If requested, scroll to a new item (occurs when the index moves)
val scrollTo = queueModel.scrollTo
if (scrollTo != null) {
// Do not scroll to indices that are not in the currently visible range.
// This prevents the queue from jumping around when the user is trying to
// navigate the queue.
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition()
val end = lmm.findLastCompletelyVisibleItemPosition()
if (scrollTo !in start..end) {
logD("Scrolling to new position")
binding.queueRecycler.scrollToPosition(scrollTo)
}
}
queueModel.finishScrollTo()
queueAdapter.updateIndicator(index, isPlaying)
// Update currently playing item
queueAdapter.setPosition(index, isPlaying)
}
}

View file

@ -25,21 +25,25 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
/**
* Class enabling more advanced queue list functionality and queue editing. TODO: Allow editing
* previous parts of the queue
* A [ViewModel] that manages the current queue state and allows navigation through the queue.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private val _queue = MutableStateFlow(listOf<Song>())
/** The current queue. */
val queue: StateFlow<List<Song>> = _queue
private val _index = MutableStateFlow(playbackManager.index)
/** The index of the currently playing song in the queue. */
val index: StateFlow<Int>
get() = _index
/** Whether to replace or diff the queue list when updating it. Is null if not specified. */
var replaceQueue: Boolean? = null
/** Flag to scroll to a particular queue item. Is null if no command has been specified. */
var scrollTo: Int? = null
init {
@ -47,58 +51,78 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
}
/**
* Go to an item in the queue using it's recyclerview adapter index. No-ops if out of bounds.
* Start playing the the queue item at the given index.
* @param adapterIndex The index of the queue item to play. Does nothing if the index is out
* of range.
*/
fun goto(adapterIndex: Int) {
if (adapterIndex !in playbackManager.queue.indices) {
// Invalid input. Nothing to do.
return
}
playbackManager.goto(adapterIndex)
}
/** Remove a queue item using it's recyclerview adapter index. */
/**
* Remove a queue item at the given index.
* @param adapterIndex The index of the queue item to play. Does nothing if the index is
* out of range.
*/
fun removeQueueDataItem(adapterIndex: Int) {
if (adapterIndex <= playbackManager.index ||
adapterIndex !in playbackManager.queue.indices) {
// Invalid input. Nothing to do.
// TODO: Allow editing played queue items.
return
}
playbackManager.removeQueueItem(adapterIndex)
}
/** Move queue items using their recyclerview adapter indices. */
/**
* Move a queue item from one index to another index.
* @param adapterFrom The index of the queue item to move.
* @param adapterTo The destination index for the queue item.
* @return true if the items were moved, false otherwise.
*/
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
if (adapterFrom <= playbackManager.index || adapterTo <= playbackManager.index) {
// Invalid input. Nothing to do.
return false
}
playbackManager.moveQueueItem(adapterFrom, adapterTo)
return true
}
/**
* Finish a replace flag specified by [replaceQueue].
*/
fun finishReplace() {
replaceQueue = null
}
/**
* Finish a scroll operation started by [scrollTo].
*/
fun finishScrollTo() {
scrollTo = null
}
override fun onIndexMoved(index: Int) {
// Index moved -> Scroll to new index
replaceQueue = null
scrollTo = index
_index.value = index
}
override fun onQueueChanged(queue: List<Song>) {
// Queue changed trivially due to item move -> Diff queue, stay at current index.
replaceQueue = false
scrollTo = null
_queue.value = playbackManager.queue.toMutableList()
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
// Queue changed completely -> Replace queue, update index
replaceQueue = true
scrollTo = index
_queue.value = playbackManager.queue.toMutableList()
@ -106,6 +130,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
replaceQueue = true
scrollTo = index
_queue.value = playbackManager.queue.toMutableList()

View file

@ -29,10 +29,10 @@ import org.oxycblt.auxio.music.Song
/**
* An adapter that displays search results.
* @param listener An [ExtendedListListener] to bind interactions to.
* @param listener An [SelectableListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class SearchAdapter(private val listener: ExtendedListListener) :
class SearchAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)

View file

@ -23,17 +23,17 @@ import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAccentBinding
import org.oxycblt.auxio.list.BasicListListener
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays [Accent] choices.
* @param listener A [BasicListListener] to bind interactions to.
* @param listener A [ClickableListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentAdapter(private val listener: BasicListListener) :
class AccentAdapter(private val listener: ClickableListListener) :
RecyclerView.Adapter<AccentViewHolder>() {
/** The currently selected [Accent]. */
var selectedAccent: Accent? = null
@ -90,9 +90,9 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
/**
* Bind new data to this instance.
* @param accent The new [Accent] to bind.
* @param listener A [BasicListListener] to bind interactions to.
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(accent: Accent, listener: BasicListListener) {
fun bind(accent: Accent, listener: ClickableListListener) {
binding.accent.apply {
setOnClickListener { listener.onClick(accent) }
backgroundTintList = context.getColorCompat(accent.primary)

View file

@ -23,7 +23,7 @@ import androidx.appcompat.app.AlertDialog
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogAccentBinding
import org.oxycblt.auxio.list.BasicListListener
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.shared.ViewBindingDialogFragment
@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* A [ViewBindingDialogFragment] that allows the user to configure the current [Accent].
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(), BasicListListener {
class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(), ClickableListListener {
private var accentAdapter = AccentAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }