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

@ -20,7 +20,7 @@ import org.oxycblt.auxio.theme.disable
import org.oxycblt.auxio.theme.enable import org.oxycblt.auxio.theme.enable
import org.oxycblt.auxio.theme.toColor 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 { class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
private val playbackModel: PlaybackViewModel by activityViewModels() 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
import kotlin.random.Random.Default.nextLong import kotlin.random.Random.Default.nextLong
// TODO: Queue // TODO: Editing/Adding to Queue
// TODO: Add the playback service itself // TODO: Add the playback service itself
// TODO: Add loop control [From playback] // TODO: Add loop control [From playback]
// TODO: Implement persistence through Bundles [I want to keep my shuffles, okay?] // TODO: Implement persistence through Bundles [I want to keep my shuffles, okay?]
@ -237,8 +237,7 @@ class PlaybackViewModel : ViewModel() {
updatePlayback(mQueue.value!![mCurrentIndex.value!!]) updatePlayback(mQueue.value!![mCurrentIndex.value!!])
// Force the observers to actually update. forceQueueUpdate()
mQueue.value = mQueue.value
} }
// Skip to last song // Skip to last song
@ -249,14 +248,61 @@ class PlaybackViewModel : ViewModel() {
updatePlayback(mQueue.value!![mCurrentIndex.value!!]) updatePlayback(mQueue.value!![mCurrentIndex.value!!])
// Force the observers to actually update. forceQueueUpdate()
mQueue.value = mQueue.value
} }
fun resetAnimStatus() { fun resetAnimStatus() {
mCanAnimate = false 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. // Generic function for updating the playback with a new song.
// Use this instead of manually updating the values each time. // Use this instead of manually updating the values each time.
private fun updatePlayback(song: Song) { private fun updatePlayback(song: Song) {

View file

@ -1,20 +1,49 @@
package org.oxycblt.auxio.playback.queue package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.DiffCallback import org.oxycblt.auxio.recycler.DiffCallback
import org.oxycblt.auxio.recycler.viewholders.SongViewHolder import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
class QueueAdapter( // FIXME: Build a Diff function so that QueueAdapter doesn't scroll wildly when things are moved
private val doOnClick: (Song) -> Unit class QueueAdapter(private val dragCallback: ItemTouchHelper) :
) : ListAdapter<Song, SongViewHolder>(DiffCallback<Song>()) { ListAdapter<Song, QueueAdapter.ViewHolder>(DiffCallback<Song>()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return SongViewHolder.from(parent.context, doOnClick) 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)) 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.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
@ -25,7 +27,9 @@ class QueueFragment : BottomSheetDialogFragment() {
): View? { ): View? {
val binding = FragmentQueueBinding.inflate(inflater) val binding = FragmentQueueBinding.inflate(inflater)
val queueAdapter = QueueAdapter {} val helper = ItemTouchHelper(QueueDragCallback(playbackModel))
val queueAdapter = QueueAdapter(helper)
// --- UI SETUP --- // --- UI SETUP ---
@ -34,12 +38,15 @@ class QueueFragment : BottomSheetDialogFragment() {
adapter = queueAdapter adapter = queueAdapter
applyDivider() applyDivider()
setHasFixedSize(true) setHasFixedSize(true)
itemAnimator = DefaultItemAnimator()
helper.attachToRecyclerView(this)
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
playbackModel.formattedQueue.observe(viewLifecycleOwner) { playbackModel.formattedQueue.observe(viewLifecycleOwner) {
queueAdapter.submitList(it) queueAdapter.submitList(it.toMutableList())
} }
return binding.root 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> <item name="android:fitsSystemWindows">true</item>
</style> </style>
<!-- Toolbar Themes --> <!-- Hack to fix the weird icon/underline with LibraryFragment's SearchView -->
<style name="Toolbar.Style" parent="ThemeOverlay.MaterialComponents.ActionBar"> <style name="Toolbar.Style" parent="ThemeOverlay.MaterialComponents.ActionBar">
<item name="android:searchViewStyle">@style/Widget.AppCompat.SearchView</item> <item name="android:searchViewStyle">@style/Widget.AppCompat.SearchView</item>
</style> </style>
<!-- Toolbar Title Theme -->
<style name="TextAppearance.Toolbar.Header" parent="TextAppearance.Widget.AppCompat.Toolbar.Title"> <style name="TextAppearance.Toolbar.Header" parent="TextAppearance.Widget.AppCompat.Toolbar.Title">
<item name="android:fontFamily">@font/inter_black</item> <item name="android:fontFamily">@font/inter_black</item>
<item name="android:textColor">?android:attr/colorPrimary</item> <item name="android:textColor">?android:attr/colorPrimary</item>
</style> </style>
<!-- Header Themes --> <!-- Title theme for Detail Fragments -->
<style name="DetailHeader"> <style name="DetailHeader">
<item name="android:textAppearance">?android:attr/textAppearanceLarge</item> <item name="android:textAppearance">?android:attr/textAppearanceLarge</item>
<item name="android:textColor">?android:attr/colorPrimary</item> <item name="android:textColor">?android:attr/colorPrimary</item>
@ -28,6 +29,7 @@
<item name="android:textSize">@dimen/text_size_header_max</item> <item name="android:textSize">@dimen/text_size_header_max</item>
</style> </style>
<!-- Smaller Title theme that is used for headers -->
<style name="TextAppearance.SmallHeader" parent="TextAppearance.MaterialComponents.Body2"> <style name="TextAppearance.SmallHeader" parent="TextAppearance.MaterialComponents.Body2">
<item name="android:fontFamily">@font/inter_semibold</item> <item name="android:fontFamily">@font/inter_semibold</item>
</style> </style>