diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 5e4d9fbce..57f8cb282 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt index 6a67bf044..72d2c3109 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt @@ -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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index b53ae6cba..e3fd8d617 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -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) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index ae4fd09d8..f3f395dd9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -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(DiffCallback()) { +// FIXME: Build a Diff function so that QueueAdapter doesn't scroll wildly when things are moved +class QueueAdapter(private val dragCallback: ItemTouchHelper) : + ListAdapter(DiffCallback()) { - 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(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() + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt new file mode 100644 index 000000000..d57f54516 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -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 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 1136ab3dc..0e9dddfba 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -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 diff --git a/app/src/main/res/drawable/ic_handle.xml b/app/src/main/res/drawable/ic_handle.xml new file mode 100644 index 000000000..6f12a4f85 --- /dev/null +++ b/app/src/main/res/drawable/ic_handle.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_queue_song.xml b/app/src/main/res/layout/item_queue_song.xml new file mode 100644 index 000000000..90d788095 --- /dev/null +++ b/app/src/main/res/layout/item_queue_song.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index dd488f8f4..ef43909fa 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -10,17 +10,18 @@ true - + + - + +