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
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.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
|
||||||
|
|
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>
|
<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>
|
||||||
|
|
Loading…
Reference in a new issue