Add ability to edit queue

Add the [somewhat rough] ability to move/remove items from the queue.
This commit is contained in:
OxygenCobalt 2020-10-21 20:16:11 -06:00
parent 219d1c2c24
commit 912d296e92
9 changed files with 247 additions and 18 deletions

View file

@ -10,7 +10,7 @@ import org.oxycblt.auxio.recycler.SortMode
// ViewModel for the Detail Fragments.
// TODO:
// - Implement a system where the Toolbar will update with some infowhen
// - Implement a system where the Toolbar will update with some info when
// the main detail header is obscured.
class DetailViewModel : ViewModel() {
private var mIsNavigating = false

View file

@ -20,7 +20,7 @@ import org.oxycblt.auxio.theme.disable
import org.oxycblt.auxio.theme.enable
import org.oxycblt.auxio.theme.toColor
// TODO: Possibly add some swipe-to-next-track function, could require a ViewPager.
// TODO: Add a swipe-to-next-track function using a ViewPager
class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
private val playbackModel: PlaybackViewModel by activityViewModels()

View file

@ -15,7 +15,7 @@ import org.oxycblt.auxio.music.toDuration
import kotlin.random.Random
import kotlin.random.Random.Default.nextLong
// TODO: Queue
// TODO: Editing/Adding to Queue
// TODO: Add the playback service itself
// TODO: Add loop control [From playback]
// TODO: Implement persistence through Bundles [I want to keep my shuffles, okay?]
@ -237,8 +237,7 @@ class PlaybackViewModel : ViewModel() {
updatePlayback(mQueue.value!![mCurrentIndex.value!!])
// Force the observers to actually update.
mQueue.value = mQueue.value
forceQueueUpdate()
}
// Skip to last song
@ -249,14 +248,61 @@ class PlaybackViewModel : ViewModel() {
updatePlayback(mQueue.value!![mCurrentIndex.value!!])
// Force the observers to actually update.
mQueue.value = mQueue.value
forceQueueUpdate()
}
fun resetAnimStatus() {
mCanAnimate = false
}
// Move two queue items. Called by QueueDragCallback.
fun moveQueueItems(adapterFrom: Int, adapterTo: Int) {
// Translate the adapter indices into the correct queue indices
val delta = mQueue.value!!.size - formattedQueue.value!!.size
val from = adapterFrom + delta
val to = adapterTo + delta
try {
val currentItem = mQueue.value!![from]
val targetItem = mQueue.value!![to]
// Then swap the items manually since kotlin does have a swap function.
mQueue.value!![to] = currentItem
mQueue.value!![from] = targetItem
} catch (exception: IndexOutOfBoundsException) {
Log.e(this::class.simpleName, "Indices were out of bounds, did not swap queue items")
return
}
forceQueueUpdate()
}
// Remove a queue item. Called by QueueDragCallback.
fun removeQueueItem(adapterIndex: Int) {
// Translate the adapter index into the correct queue index
val delta = mQueue.value!!.size - formattedQueue.value!!.size
val properIndex = adapterIndex + delta
Log.d(this::class.simpleName, "Removing item ${mQueue.value!![properIndex].name}.")
if (properIndex > mQueue.value!!.size || properIndex < 0) {
Log.e(this::class.simpleName, "Index is out of bounds, did not remove queue item.")
return
}
mQueue.value!!.removeAt(properIndex)
forceQueueUpdate()
}
// Force the observers of the queue to actually update after making changes.
private fun forceQueueUpdate() {
mQueue.value = mQueue.value
}
// Generic function for updating the playback with a new song.
// Use this instead of manually updating the values each time.
private fun updatePlayback(song: Song) {

View file

@ -1,20 +1,49 @@
package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ListAdapter
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.SongViewHolder
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
class QueueAdapter(
private val doOnClick: (Song) -> Unit
) : ListAdapter<Song, SongViewHolder>(DiffCallback<Song>()) {
// FIXME: Build a Diff function so that QueueAdapter doesn't scroll wildly when things are moved
class QueueAdapter(private val dragCallback: ItemTouchHelper) :
ListAdapter<Song, QueueAdapter.ViewHolder>(DiffCallback<Song>()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
return SongViewHolder.from(parent.context, doOnClick)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemQueueSongBinding.inflate(LayoutInflater.from(parent.context)))
}
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
// Generic ViewHolder for a detail album
inner class ViewHolder(
private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding, null) {
@SuppressLint("ClickableViewAccessibility")
override fun onBind(model: Song) {
binding.song = model
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
dragCallback.startDrag(this)
return@setOnTouchListener true
}
false
}
binding.songName.requestLayout()
binding.songInfo.requestLayout()
}
}
}

View file

@ -0,0 +1,55 @@
package org.oxycblt.auxio.playback.queue
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.playback.PlaybackViewModel
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sign
class QueueDragCallback(private val playbackModel: PlaybackViewModel) :
ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
ItemTouchHelper.START
) {
override fun interpolateOutOfBoundsScroll(
recyclerView: RecyclerView,
viewSize: Int,
viewSizeOutOfBounds: Int,
totalSize: Int,
msSinceStartScroll: Long
): Int {
val standardSpeed = super.interpolateOutOfBoundsScroll(
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll
)
val clampedAbsVelocity = Math.max(
MINIMUM_INITIAL_DRAG_VELOCITY,
min(
abs(standardSpeed),
MAXIMUM_INITIAL_DRAG_VELOCITY
)
)
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
playbackModel.moveQueueItems(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
playbackModel.removeQueueItem(viewHolder.adapterPosition)
}
companion object {
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
}
}

View file

@ -5,6 +5,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentQueueBinding
@ -25,7 +27,9 @@ class QueueFragment : BottomSheetDialogFragment() {
): View? {
val binding = FragmentQueueBinding.inflate(inflater)
val queueAdapter = QueueAdapter {}
val helper = ItemTouchHelper(QueueDragCallback(playbackModel))
val queueAdapter = QueueAdapter(helper)
// --- UI SETUP ---
@ -34,12 +38,15 @@ class QueueFragment : BottomSheetDialogFragment() {
adapter = queueAdapter
applyDivider()
setHasFixedSize(true)
itemAnimator = DefaultItemAnimator()
helper.attachToRecyclerView(this)
}
// --- VIEWMODEL SETUP ---
playbackModel.formattedQueue.observe(viewLifecycleOwner) {
queueAdapter.submitList(it)
queueAdapter.submitList(it.toMutableList())
}
return binding.root

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
</vector>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".recycler.viewholders.SongViewHolder">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/padding_medium">
<ImageView
android:id="@+id/album_cover"
android:layout_width="@dimen/size_cover_compact"
android:layout_height="@dimen/size_cover_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/song_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_medium"
android:layout_marginEnd="@dimen/margin_medium"
android:ellipsize="end"
android:maxLines="1"
android:text="@{song.name}"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintBottom_toTopOf="@+id/song_info"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/song_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_medium"
android:layout_marginEnd="@dimen/margin_medium"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:text="@{@string/format_info(song.album.artist.name, song.album.name)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toBottomOf="@+id/song_name"
tools:text="Artist / Album" />
<ImageView
android:id="@+id/song_drag_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_handle"
android:clickable="true"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -10,17 +10,18 @@
<item name="android:fitsSystemWindows">true</item>
</style>
<!-- Toolbar Themes -->
<!-- Hack to fix the weird icon/underline with LibraryFragment's SearchView -->
<style name="Toolbar.Style" parent="ThemeOverlay.MaterialComponents.ActionBar">
<item name="android:searchViewStyle">@style/Widget.AppCompat.SearchView</item>
</style>
<!-- Toolbar Title Theme -->
<style name="TextAppearance.Toolbar.Header" parent="TextAppearance.Widget.AppCompat.Toolbar.Title">
<item name="android:fontFamily">@font/inter_black</item>
<item name="android:textColor">?android:attr/colorPrimary</item>
</style>
<!-- Header Themes -->
<!-- Title theme for Detail Fragments -->
<style name="DetailHeader">
<item name="android:textAppearance">?android:attr/textAppearanceLarge</item>
<item name="android:textColor">?android:attr/colorPrimary</item>
@ -28,6 +29,7 @@
<item name="android:textSize">@dimen/text_size_header_max</item>
</style>
<!-- Smaller Title theme that is used for headers -->
<style name="TextAppearance.SmallHeader" parent="TextAppearance.MaterialComponents.Body2">
<item name="android:fontFamily">@font/inter_semibold</item>
</style>