Add ability to edit queue
Add the [somewhat rough] ability to move/remove items from the queue.
This commit is contained in:
parent
219d1c2c24
commit
912d296e92
9 changed files with 247 additions and 18 deletions
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
11
app/src/main/res/drawable/ic_handle.xml
Normal file
11
app/src/main/res/drawable/ic_handle.xml
Normal 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>
|
79
app/src/main/res/layout/item_queue_song.xml
Normal file
79
app/src/main/res/layout/item_queue_song.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue