queue: reimplement with bottom sheet

Re-implement the queue, now leveraging a bottom sheet too.

This makes the queue much easier to open, and actually plays along with
the new transition system. I really hope this doesn't have a stupid
gotcha that ruins the UX. Please. Please. Please.
This commit is contained in:
OxygenCobalt 2022-07-29 14:33:36 -06:00
parent cc3cb343b0
commit a4fa8a84fa
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 292 additions and 157 deletions

3
.gitignore vendored
View file

@ -15,6 +15,3 @@ captures/
.externalNativeBuild
*.iml
.cxx
# Patched material
app/src/main/com/google/android/material

View file

@ -9,6 +9,10 @@ at the cost of longer loading times
- Added support for date tags, including more fine-grained dates [#159, dependent on this feature]
- Added support for release types signifying EPs, Singles, Compilations, and more [#158, dependent on this feature]
- Added basic awareness of multi-value vorbis tags [#197, dependent on this feature]
- Completely reworked the main playback UI
- Queue can now be swiped up [#92]
- Playing song is now shown in queue [#92]
- Added ability to play songs from queue [#92]
- Added Last Added sorting
- Search now takes sort tags and file names in account [#184]
- Added option to clear playback state in settings

View file

@ -739,9 +739,8 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
nestedScrolled = true;
}
@Override
public void onStopNestedScroll(
@Override
public void onStopNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
@ -761,13 +760,12 @@ public void onStopNestedScroll(
if (fitToContents) {
targetState = STATE_EXPANDED;
} else {
// MODIFICATION: Make nested scrolling respond to shouldSkipHalfExpandedStateWhenDragging
int currentTop = child.getTop();
if (currentTop < halfExpandedOffset) {
targetState = STATE_EXPANDED;
} else {
if (shouldSkipHalfExpandedStateWhenDragging()) {
targetState = STATE_COLLAPSED;
targetState = STATE_EXPANDED;
} else {
targetState = STATE_HALF_EXPANDED;
}
@ -795,15 +793,10 @@ public void onStopNestedScroll(
}
}
} else {
// MODIFICATION: Make nested scrolling respond to shouldSkipHalfExpandedStateWhenDragging
if (shouldSkipHalfExpandedStateWhenDragging()) {
if (shouldSkipHalfExpandedStateWhenDragging() || Math.abs(currentTop - halfExpandedOffset) >= Math.abs(currentTop - collapsedOffset)) {
targetState = STATE_COLLAPSED;
} else {
if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) {
targetState = STATE_HALF_EXPANDED;
} else {
targetState = STATE_COLLAPSED;
}
}
}
}
@ -812,22 +805,17 @@ public void onStopNestedScroll(
targetState = STATE_COLLAPSED;
} else {
// Settle to nearest height.
// MODIFICATION: Make nested scrolling respond to shouldSkipHalfExpandedStateWhenDragging
int currentTop = child.getTop();
if (shouldSkipHalfExpandedStateWhenDragging()) {
if (shouldSkipHalfExpandedStateWhenDragging() || Math.abs(currentTop - halfExpandedOffset) >= Math.abs(currentTop - collapsedOffset)) {
targetState = STATE_COLLAPSED;
} else {
if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) {
targetState = STATE_HALF_EXPANDED;
} else {
targetState = STATE_COLLAPSED;
}
}
}
}
startSettling(child, targetState, false);
nestedScrolled = false;
}
}
@Override
public void onNestedScroll(

View file

@ -26,8 +26,10 @@ import androidx.core.view.isInvisible
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
import com.google.android.material.transition.MaterialFadeThrough
import java.util.*
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
@ -35,13 +37,11 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueSheetBehavior
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.*
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
@ -85,7 +85,27 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
override fun onStateChanged(bottomSheet: View, newState: Int) {}
})
binding.root.post { handleSheetTransitions() }
val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior
queueSheetBehavior.addBottomSheetCallback(
object : NeoBottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
handleSheetTransitions()
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
playbackSheetBehavior.isDraggable =
!playbackSheetBehavior.isHideable &&
newState == BottomSheetBehavior.STATE_COLLAPSED
}
})
binding.root.post {
handleSheetTransitions()
playbackSheetBehavior.isDraggable =
!playbackSheetBehavior.isHideable &&
queueSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED
}
// --- VIEWMODEL SETUP ---
@ -109,8 +129,10 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
val queueRatio = 0f
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
val outRatio = 1 - playbackRatio
val halfOutRatio = min(playbackRatio * 2, 1f)
@ -118,20 +140,26 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
val halfOutQueueRatio = min(queueRatio * 2, 1f)
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outRatio * 255).toInt()
binding.playbackSheet.translationZ = 3f * outRatio
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
// binding.queueRecycler.alpha = max(queueOffset, 0f)
binding.exploreNavHost.apply {
alpha = outRatio
isInvisible = alpha == 0f
}
binding.playbackSheet.translationZ = 3f * outRatio
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outRatio * 255).toInt()
binding.playbackBarFragment.apply {
alpha = max(1 - halfOutRatio, halfInQueueRatio)
lastInsets?.let { translationY = it.systemWindowInsetTop * halfOutRatio }
isInvisible = alpha == 0f
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
}
binding.playbackPanelFragment.apply {
alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
isInvisible = alpha == 0f
}
binding.queueFragment.alpha = queueRatio
}
private fun handleMainNavigation(action: MainNavigationAction?) {
@ -139,16 +167,8 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
val binding = requireBinding()
when (action) {
is MainNavigationAction.Expand -> {
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
}
is MainNavigationAction.Collapse -> {
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
}
is MainNavigationAction.Expand -> tryExpandAll()
is MainNavigationAction.Collapse -> tryCollapseAll()
is MainNavigationAction.Settings ->
findNavController().navigate(MainFragmentDirections.actionShowSettings())
is MainNavigationAction.About ->
@ -163,13 +183,7 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
private fun handleExploreNavigation(item: Music?) {
if (item != null) {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
}
tryCollapseAll()
}
}
@ -193,11 +207,7 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
} else {
if (!tryCollapseAll()) {
val navController = binding.exploreNavHost.findNavController()
if (navController.currentDestination?.id ==
@ -211,4 +221,38 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
}
}
}
private fun tryExpandAll(): Boolean {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
if (playbackSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN &&
playbackSheetBehavior.state != BottomSheetBehavior.STATE_EXPANDED) {
playbackSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
return true
}
return false
}
private fun tryCollapseAll(): Boolean {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
if (playbackSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN &&
playbackSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) {
playbackSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior
queueSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
return true
}
return false
}
}

View file

@ -24,7 +24,6 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent
@ -38,7 +37,6 @@ import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getDrawableSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.systemGestureInsetsCompat
import org.oxycblt.auxio.util.textSafe
/**
@ -65,13 +63,8 @@ class PlaybackPanelFragment :
// --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { view, insets ->
// The playback controls should be inset upwards at least a little bit more than usual,
// just for quality of life. While the old 3-button navigation does this for us, when
// bar navigation is used, we use the gesture padding to add that extra portion of
// space.
val bars = insets.systemBarInsetsCompat
val gestures = insets.systemGestureInsetsCompat
view.updatePadding(top = bars.top, bottom = max(gestures.bottom, bars.bottom))
view.updatePadding(top = bars.top, bottom = bars.bottom)
insets
}

View file

@ -24,8 +24,11 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import androidx.coordinatorlayout.widget.CoordinatorLayout
import kotlin.math.max
import org.oxycblt.auxio.ui.AuxioSheetBehavior
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.systemGestureInsetsCompat
class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioSheetBehavior<V>(context, attributeSet) {
@ -45,7 +48,9 @@ class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeS
(child as ViewGroup).apply {
setOnApplyWindowInsetsListener { v, insets ->
lastInsets = insets
peekHeight = getChildAt(0).measuredHeight + insets.systemGestureInsets.bottom
val bars = insets.systemBarInsetsCompat
val gestures = insets.systemGestureInsetsCompat
peekHeight = getChildAt(0).measuredHeight + max(gestures.bottom, bars.bottom)
insets
}
}

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.LayerDrawable
import android.view.MotionEvent
import android.view.View
import androidx.core.view.isInvisible
@ -54,10 +55,20 @@ private constructor(
val backgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorSafe(R.attr.colorSurface).stateList
elevation = binding.context.getDimenSafe(R.dimen.elevation_normal) * 5
}
init {
binding.body.background = backgroundDrawable
binding.body.background =
LayerDrawable(
arrayOf(
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
fillColor = binding.context.getAttrColorSafe(R.attr.colorSurface).stateList
elevation = binding.context.getDimenSafe(R.dimen.elevation_normal)
},
backgroundDrawable))
backgroundDrawable.alpha = 0
}
@SuppressLint("ClickableViewAccessibility")

View file

@ -86,12 +86,15 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
logD("Lifting queue item")
val bg = holder.backgroundDrawable
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_small)
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_normal)
holder.itemView
.animate()
.translationZ(elevation)
.setDuration(100)
.setUpdateListener { bg.elevation = holder.itemView.translationZ }
.setUpdateListener {
bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt()
logD("in ${bg.alpha} ${holder.itemView.translationZ}")
}
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
@ -124,11 +127,15 @@ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHe
logD("Dropping queue item")
val bg = holder.backgroundDrawable
val elevation = recyclerView.context.getDimenSafe(R.dimen.elevation_normal)
holder.itemView
.animate()
.translationZ(0.0f)
.translationZ(0f)
.setDuration(100)
.setUpdateListener { bg.elevation = holder.itemView.translationZ }
.setUpdateListener {
bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt()
logD("out ${bg.alpha} ${holder.itemView.translationZ} ${elevation}")
}
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
}

View file

@ -21,7 +21,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentQueueBinding
@ -42,8 +41,6 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) {
binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.queueRecycler.apply {
adapter = queueAdapter
touchHelper.attachToRecyclerView(this)

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.queue
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AuxioSheetBehavior
import org.oxycblt.auxio.util.*
class QueueSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioSheetBehavior<V>(context, attributeSet) {
private var barHeight = 0
private var barSpacing = context.getDimenSizeSafe(R.dimen.spacing_small)
init {
sheetBackgroundDrawable.setCornerSize(context.getDimenSafe(R.dimen.size_corners_medium))
}
override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View) =
dependency.id == R.id.playback_bar_fragment
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: V,
dependency: View
): Boolean {
val ok = super.onDependentViewChanged(parent, child, dependency)
barHeight = dependency.height
return ok
}
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
val success = super.onLayoutChild(parent, child, layoutDirection)
child.setOnApplyWindowInsetsListener { _, insets ->
val bars = insets.systemBarInsetsCompat
val gestures = insets.systemGestureInsetsCompat
expandedOffset = bars.top + barHeight + barSpacing
peekHeight =
(child as ViewGroup).getChildAt(0).height + max(gestures.bottom, bars.bottom)
insets.replaceSystemBarInsetsCompat(
bars.left, bars.top, bars.right, expandedOffset + bars.bottom)
}
return success
}
}

View file

@ -35,7 +35,7 @@ abstract class AuxioSheetBehavior<V : View>(context: Context, attributeSet: Attr
val sheetBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
elevation = context.pxOfDp(elevationNormal).toFloat()
elevation = elevationNormal
}
init {

View file

@ -16,7 +16,7 @@
app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" />
<FrameLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/playback_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -32,8 +32,51 @@
android:id="@+id/playback_panel_fragment"
android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentViewBehavior" />
</FrameLayout>
<LinearLayout
android:id="@+id/queue_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_behavior="org.oxycblt.auxio.playback.queue.QueueSheetBehavior"
app:behavior_hideable="false">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/handle_wrapper"
android:layout_width="match_parent"
android:layout_height="64dp">
<ImageView
android:id="@+id/handle"
android:layout_width="match_parent"
android:layout_height="48dp"
android:scaleType="center"
android:paddingBottom="@dimen/spacing_small"
android:src="@drawable/ic_down_24"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/lbl_queue"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
app:layout_constraintBottom_toBottomOf="@+id/handle"
app:layout_constraintEnd_toEndOf="@+id/handle"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/queue_fragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:name="org.oxycblt.auxio.playback.queue.QueueFragment" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,28 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<org.oxycblt.auxio.ui.coordinator.EdgeCoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<org.oxycblt.auxio.ui.recycler.EdgeRecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
<org.oxycblt.auxio.ui.coordinator.EdgeAppBarLayout
style="@style/Widget.Auxio.AppBarLayout"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/queue_recycler">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/queue_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="0dp"
app:navigationIcon="@drawable/ic_down_24"
app:title="@string/lbl_queue" />
</org.oxycblt.auxio.ui.coordinator.EdgeAppBarLayout>
<org.oxycblt.auxio.ui.recycler.EdgeRecyclerView
android:id="@+id/queue_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -30,5 +10,3 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_queue_song" />
</org.oxycblt.auxio.ui.coordinator.EdgeCoordinatorLayout>