playback: add slide up behavior

Completely refactor PlaybackBarLayout into PlaybackLayout, which now
not only handles the bar behavior but also allows for one to slide
up the bar layout into the full playback layout. This was largely
adapted from umano's AndroidSlidingUpPanel, albeit heavily minified
and mixed with the previous window inset tricks of the previous layout.
There are still some tweaks to be made, but this implementation seems
to be really good.
This commit is contained in:
OxygenCobalt 2021-11-24 15:13:34 -07:00
parent 06a7d8258b
commit cfc7352571
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
19 changed files with 739 additions and 533 deletions

View file

@ -28,13 +28,12 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackBarLayout import org.oxycblt.auxio.playback.PlaybackLayout
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -42,7 +41,7 @@ import org.oxycblt.auxio.util.logD
* A wrapper around the home fragment that shows the playback fragment and controls * A wrapper around the home fragment that shows the playback fragment and controls
* the more high-level navigation features. * the more high-level navigation features.
*/ */
class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback { class MainFragment : Fragment(), PlaybackLayout.ActionCallback {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels()
@ -66,6 +65,7 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
binding.mainBarLayout.setActionCallback(this) binding.mainBarLayout.setActionCallback(this)
binding.mainBarLayout.setSong(playbackModel.song.value) binding.mainBarLayout.setSong(playbackModel.song.value)
@ -73,7 +73,7 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback {
binding.mainBarLayout.setPosition(playbackModel.position.value!!) binding.mainBarLayout.setPosition(playbackModel.position.value!!)
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner) { song ->
binding.mainBarLayout.setSong(song, animate = true) binding.mainBarLayout.setSong(song)
} }
playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying -> playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying ->
@ -111,7 +111,7 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback {
) )
snackbar.view.apply { snackbar.view.apply {
// Change the font family to our semibold color // Change the font family to semibold
findViewById<Button>( findViewById<Button>(
com.google.android.material.R.id.snackbar_action com.google.android.material.R.id.snackbar_action
).typeface = ResourcesCompat.getFont(requireContext(), R.font.inter_semibold) ).typeface = ResourcesCompat.getFont(requireContext(), R.font.inter_semibold)
@ -142,12 +142,6 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback {
return binding.root return binding.root
} }
override fun onNavToPlayback() {
findNavController().navigate(
MainFragmentDirections.actionGoToPlayback()
)
}
override fun onNavToItem() { override fun onNavToItem() {
detailModel.navToItem(playbackModel.song.value ?: return) detailModel.navToItem(playbackModel.song.value ?: return)
} }

View file

@ -49,8 +49,6 @@ abstract class AuxioFetcher : Fetcher {
* https://github.com/kabouzeid/Phonograph * https://github.com/kabouzeid/Phonograph
*/ */
protected fun createMosaic(context: Context, streams: List<InputStream>): FetchResult? { protected fun createMosaic(context: Context, streams: List<InputStream>): FetchResult? {
logD("idiot")
if (streams.size < 4) { if (streams.size < 4) {
return streams.getOrNull(0)?.let { stream -> return streams.getOrNull(0)?.let { stream ->
return SourceResult( return SourceResult(

View file

@ -124,7 +124,7 @@ class ExcludedDialog : LifecycleDialog() {
if (path != null) { if (path != null) {
excludedModel.addPath(path) excludedModel.addPath(path)
} else { } else {
// TODO: Tolerate this once the excluded system is modernized // TODO: Maybe tolerate this?
requireContext().showToast(R.string.err_bad_dir) requireContext().showToast(R.string.err_bad_dir)
} }
} }

View file

@ -67,7 +67,6 @@ import kotlin.math.abs
* - Variable names are no longer prefixed with m * - Variable names are no longer prefixed with m
* - Added drag listener * - Added drag listener
* - TODO: Added documentation * - TODO: Added documentation
* - TODO: Popup will center itself to the thumb when possible
*/ */
class FastScrollRecyclerView @JvmOverloads constructor( class FastScrollRecyclerView @JvmOverloads constructor(
context: Context, context: Context,
@ -149,7 +148,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
isSingleLine = true isSingleLine = true
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge) TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(R.attr.colorSurface.resolveAttr(context)) setTextColor(R.attr.colorOnSecondary.resolveAttr(context))
} }
thumbWidth = thumbDrawable.intrinsicWidth thumbWidth = thumbDrawable.intrinsicWidth

View file

@ -118,11 +118,13 @@ class MusicLoader(private val context: Context) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun buildSelector() { private fun buildSelector() {
// TODO: Upgrade this to be compatible with Android Q.
val blacklistDatabase = ExcludedDatabase.getInstance(context) val blacklistDatabase = ExcludedDatabase.getInstance(context)
val paths = blacklistDatabase.readPaths() val paths = blacklistDatabase.readPaths()
// DATA was deprecated on Android Q, but is set to be un-deprecated in Android 12L
// The only reason we'd want to change this is to add external partitions support, but
// that's less efficent and there's no demand for that right now.
for (path in paths) { for (path in paths) {
selector += " AND ${Media.DATA} NOT LIKE ?" selector += " AND ${Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children args += "$path%" // Append % so that the selector properly detects children

View file

@ -1,423 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* PlaybackBarLayout.kt is part of Auxio.
*
* 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
import android.content.Context
import android.graphics.Insets
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.core.view.children
import androidx.customview.widget.ViewDragHelper
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.systemBarsCompat
/**
* A layout that manages the bottom playback bar while still enabling edge-to-edge to work
* properly. The mechanism is mostly inspired by Material Files' PersistentBarLayout, however
* this class was primarily written by me.
*
* TODO: Add a swipe-up behavior a la Phonograph. I think that would improve UX.
* - We need to use a separate drag helper to prevent issues
* TODO: Leverage this layout to make more tablet-friendly UIs
*
* @author OxygenCobalt
*/
class PlaybackBarLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
private val playbackView = PlaybackBarView(context)
private var barDragHelper = ViewDragHelper.create(this, BarDragCallback())
private var lastInsets: WindowInsets? = null
init {
addView(playbackView)
// playbackView is special as it's the view we treat as a bottom bar.
// Mark it as such.
(playbackView.layoutParams as LayoutParams).apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isBar = true
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(widthSize, heightSize)
// Measure the bar view so that it fills the whole screen and takes up the bottom views.
val barParams = playbackView.layoutParams as LayoutParams
val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width)
val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height)
playbackView.measure(barWidthSpec, barHeightSpec)
applyContentWindowInsets()
measureContent()
}
/**
* Measure the content views in this layout. This is done separately as at times we want
* to relayout the content views but not relayout the bar view.
*/
private fun measureContent() {
val barParams = playbackView.layoutParams as LayoutParams
val barHeightAdjusted = (playbackView.measuredHeight * barParams.offset).toInt()
val contentWidth = measuredWidth
val contentHeight = measuredHeight - barHeightAdjusted
for (child in children) {
if (child.visibility == View.GONE) continue
val childParams = child.layoutParams as LayoutParams
if (!childParams.isBar) {
val childWidthSpec = MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY)
val childHeightSpec = MeasureSpec.makeMeasureSpec(contentHeight, MeasureSpec.EXACTLY)
child.measure(childWidthSpec, childHeightSpec)
}
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val barHeight = playbackView.measuredHeight
val barParams = (playbackView.layoutParams as LayoutParams)
val barHeightAdjusted = (barHeight * barParams.offset).toInt()
// Again, lay out our view like we measured it.
playbackView.layout(
0, height - barHeightAdjusted,
width, height + (barHeight - barHeightAdjusted)
)
layoutContent()
}
/**
* Layout the content views in this layout. This is done separately as at times we want
* to relayout the content views but not relayout the bar view.
*/
private fun layoutContent() {
for (child in children) {
val childParams = child.layoutParams as LayoutParams
if (!childParams.isBar) {
child.layout(0, 0, child.measuredWidth, child.measuredHeight)
}
}
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Applying window insets is the real special sauce of this layout. The problem with
// having a bottom bar is that if you support edge-to-edge, applying insets to views
// will result in spacing being incorrect whenever the bar is shown. If you cleverly
// modify the insets however, you can make all content views remove their spacing as
// the bar enters. This function itself is unimportant, so you should probably take
// a look at applyContentWindowInsets and adjustInsets instead.
playbackView.onApplyWindowInsets(insets)
lastInsets = insets
applyContentWindowInsets()
return insets
}
/**
* Apply window insets to the content views in this layouts. This is done separately as at
* times we want to relayout the content views but not relayout the bar view.
*/
private fun applyContentWindowInsets() {
val insets = lastInsets
if (insets != null) {
val adjustedInsets = adjustInsets(insets)
for (child in children) {
val childParams = child.layoutParams as LayoutParams
if (!childParams.isBar) {
child.dispatchApplyWindowInsets(adjustedInsets)
}
}
}
}
/**
* Adjust window insets to line up with the playback bar
*/
private fun adjustInsets(insets: WindowInsets): WindowInsets {
// Find how much space the bar is consuming right now. We use this to modify
// the bottom window inset so that the spacing checks out, 0 if the bar is fully
// shown and the original value if the bar is hidden.
val barParams = playbackView.layoutParams as LayoutParams
val barConsumedInset = (playbackView.measuredHeight * barParams.offset).toInt()
val bars = insets.systemBarsCompat
val adjustedBottomInset = (bars.bottom - barConsumedInset).coerceAtLeast(0)
return when {
// Android R. Modify insets using their new method that exists for no reason
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
WindowInsets.Builder(insets)
.setInsets(
WindowInsets.Type.systemBars(),
Insets.of(bars.left, bars.top, bars.right, adjustedBottomInset)
)
.build()
}
// Android O. Modify insets using the original method
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> {
@Suppress("DEPRECATION")
insets.replaceSystemWindowInsets(
bars.left, bars.top, bars.right, adjustedBottomInset
)
}
else -> insets
}
}
override fun computeScroll() {
// Copied this from MaterialFiles.
// Don't know what this does, but it seems important so I just keep it around.
if (barDragHelper.continueSettling(true)) {
postInvalidateOnAnimation()
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// Prevent memory leaks
playbackView.clearCallback()
}
/**
* Update the song that this layout is showing. This will be reflected in the compact view
* at the bottom of the screen.
* @param animate Whether to animate bar showing/hiding events.
*/
fun setSong(song: Song?, animate: Boolean = false) {
if (song != null) {
showBar(animate)
playbackView.setSong(song)
} else {
hideBar(animate)
}
}
/**
* Update the playing status on this layout. This will be reflected in the compact view
* at the bottom of the screen.
*/
fun setPlaying(isPlaying: Boolean) {
playbackView.setPlaying(isPlaying)
}
/**
* Update the playback position on this layout. This will be reflected in the compact view
* at the bottom of the screen.
*/
fun setPosition(position: Long) {
playbackView.setPosition(position)
}
/**
* Add a callback for actions from the compact playback view in this layout.
*/
fun setActionCallback(callback: ActionCallback) {
playbackView.setCallback(callback)
}
private fun showBar(animate: Boolean) {
val barParams = playbackView.layoutParams as LayoutParams
if (barParams.shown || barParams.offset == 1f) {
// Already showed the bar, don't do it again.
return
}
barParams.shown = true
if (animate) {
// Animate, use our drag helper to slide the view upwards. All invalidation is done
// in the callback.
barDragHelper.smoothSlideViewTo(
playbackView, playbackView.left, height - playbackView.height
)
} else {
// Don't animate, snap the view and invalidate the content views if we are already
// laid out. Otherwise we will do it later so don't waste time now.
barParams.offset = 1f
if (isLaidOut) {
applyContentWindowInsets()
measureContent()
layoutContent()
}
}
invalidate()
}
private fun hideBar(animate: Boolean) {
val barParams = playbackView.layoutParams as LayoutParams
if (barParams.shown || barParams.offset == 0f) {
// Already hid the bar, don't do it again.
return
}
barParams.shown = false
if (animate) {
// Animate, use our drag helper to slide the view upwards. All invalidation is done
// in the callback.
barDragHelper.smoothSlideViewTo(
playbackView, playbackView.left, height
)
} else {
// Don't animate, snap the view and invalidate the content views if we are already
// laid out. Otherwise we will do it later so don't waste time now.
barParams.offset = 0f
if (isLaidOut) {
applyContentWindowInsets()
measureContent()
layoutContent()
}
}
invalidate()
}
// --- LAYOUT PARAMS ---
override fun generateLayoutParams(attrs: AttributeSet): ViewGroup.LayoutParams {
return LayoutParams(context, attrs)
}
override fun generateLayoutParams(
layoutParams: ViewGroup.LayoutParams
): ViewGroup.LayoutParams =
when (layoutParams) {
is LayoutParams -> LayoutParams(layoutParams)
is MarginLayoutParams -> LayoutParams(layoutParams)
else -> LayoutParams(layoutParams)
}
override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams =
LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean =
layoutParams is LayoutParams && super.checkLayoutParams(layoutParams)
/**
* A callback for actions done from this view. This fragment can inherit this and recieve
* updates from the compact playback view in this layout that can then be sent to the
* internal playback engine.
*
* There is no need to clear this callback when done, the view clears it itself when the
* view is detached.
*/
interface ActionCallback {
fun onPrev()
fun onPlayPauseClick()
fun onNext()
fun onNavToItem()
fun onNavToPlayback()
}
/**
* Layout parameters for this layout. This layout is meant to be a black box with only two
* types of views, so this implementation is kept private.
*/
private class LayoutParams : ViewGroup.LayoutParams {
var isBar = false
var shown = false
var offset = 0f
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(width: Int, height: Int) : super(width, height)
constructor(source: LayoutParams) : super(source)
constructor(source: ViewGroup.LayoutParams) : super(source)
}
/**
* Internal drag callback for animating the bar view showing/hiding.
*/
private inner class BarDragCallback : ViewDragHelper.Callback() {
// We aren't actually dragging things. Ignore this.
override fun tryCaptureView(child: View, pointerId: Int): Boolean = false
override fun onViewPositionChanged(
changedView: View,
left: Int,
top: Int,
dx: Int,
dy: Int
) {
val childRange = getViewVerticalDragRange(changedView)
val childParams = changedView.layoutParams as LayoutParams
// Find the new offset that this view takes up after an animation frame.
childParams.offset = (height - top).toFloat() / childRange
// Invalidate our content views so that they accurately reflect the bar now.
applyContentWindowInsets()
measureContent()
layoutContent()
}
override fun getViewVerticalDragRange(child: View): Int {
val childParams = child.layoutParams as LayoutParams
// Sanity check
check(childParams.isBar) { "This drag helper is only meant for content views" }
return child.height
}
// Don't really know what these do but they're needed
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = child.left
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
return top.coerceIn(height - getViewVerticalDragRange(child)..height)
}
}
}

View file

@ -19,20 +19,16 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.RippleDrawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ViewCompactPlaybackBinding import org.oxycblt.auxio.databinding.ViewCompactPlaybackBinding
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.resolveAttr import org.oxycblt.auxio.util.resolveAttr
import org.oxycblt.auxio.util.resolveDrawable
import org.oxycblt.auxio.util.systemBarsCompat import org.oxycblt.auxio.util.systemBarsCompat
/** /**
@ -45,37 +41,11 @@ class PlaybackBarView @JvmOverloads constructor(
defStyleAttr: Int = -1 defStyleAttr: Int = -1
) : ConstraintLayout(context, attrs, defStyleAttr) { ) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewCompactPlaybackBinding.inflate(context.inflater, this, true) private val binding = ViewCompactPlaybackBinding.inflate(context.inflater, this, true)
private var mCallback: PlaybackBarLayout.ActionCallback? = null private var mCallback: PlaybackLayout.ActionCallback? = null
init { init {
id = R.id.playback_bar id = R.id.playback_bar
elevation = resources.getDimensionPixelSize(R.dimen.elevation_normal).toFloat()
// To get a MaterialShapeDrawable to co-exist with a ripple drawable, we need to layer
// this drawable on top of the existing ripple drawable. RippleDrawable actually inherits
// LayerDrawable though, so we can do this. However, adding a new drawable layer directly
// is only available on API 23+, but we're on API 21. So we create a drawable resource with
// an empty drawable with a hard-coded ID, filling the drawable in with a
// MaterialShapeDrawable at runtime and allowing this code to work on API 21.
background = R.drawable.ui_shape_ripple.resolveDrawable(context).apply {
val backgroundDrawable = MaterialShapeDrawable.createWithElevationOverlay(context).apply {
elevation = this@PlaybackBarView.elevation
fillColor = ColorStateList.valueOf(R.attr.colorSurface.resolveAttr(context))
}
(this as RippleDrawable).setDrawableByLayerId(
android.R.id.background, backgroundDrawable
)
}
isClickable = true
isFocusable = true
setOnClickListener {
mCallback?.onNavToPlayback()
}
setOnLongClickListener { setOnLongClickListener {
mCallback?.onNavToItem() mCallback?.onNavToItem()
true true
@ -109,7 +79,7 @@ class PlaybackBarView @JvmOverloads constructor(
binding.playbackProgressBar.progress = position.toInt() binding.playbackProgressBar.progress = position.toInt()
} }
fun setCallback(callback: PlaybackBarLayout.ActionCallback) { fun setCallback(callback: PlaybackLayout.ActionCallback) {
mCallback = callback mCallback = callback
binding.callback = callback binding.callback = callback
} }

View file

@ -27,6 +27,7 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBinding import org.oxycblt.auxio.databinding.FragmentPlaybackBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
@ -73,12 +74,12 @@ class PlaybackFragment : Fragment() {
binding.playbackToolbar.apply { binding.playbackToolbar.apply {
setNavigationOnClickListener { setNavigationOnClickListener {
findNavController().navigateUp() navigateUp()
} }
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_queue) { if (item.itemId == R.id.action_queue) {
findNavController().navigate(PlaybackFragmentDirections.actionShowQueue()) findNavController().navigate(MainFragmentDirections.actionShowQueue())
true true
} else { } else {
@ -136,15 +137,25 @@ class PlaybackFragment : Fragment() {
detailModel.navToItem.observe(viewLifecycleOwner) { item -> detailModel.navToItem.observe(viewLifecycleOwner) { item ->
if (item != null) { if (item != null) {
findNavController().navigateUp() navigateUp()
} }
} }
binding.playbackPlayPause.post {
binding.playbackPlayPause.stateListAnimator = null
}
logD("Fragment Created.") logD("Fragment Created.")
return binding.root return binding.root
} }
private fun navigateUp() {
// This is a dumb and fragile hack but this fragment isn't part of the navigation stack
// so we can't really do much
(requireView().parent.parent.parent as PlaybackLayout).collapse()
}
private fun updateQueueIcon(queueItem: MenuItem) { private fun updateQueueIcon(queueItem: MenuItem) {
val userQueue = playbackModel.userQueue.value!! val userQueue = playbackModel.userQueue.value!!
val nextQueue = playbackModel.nextItemsInQueue.value!! val nextQueue = playbackModel.nextItemsInQueue.value!!

View file

@ -0,0 +1,683 @@
package org.oxycblt.auxio.playback
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Insets
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isInvisible
import androidx.customview.widget.ViewDragHelper
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.resolveAttr
import org.oxycblt.auxio.util.systemBarsCompat
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* This layout handles pretty much every aspect of the playback UI flow, notably the playback
* bar and it's ability to slide up into the playback view. It's a blend of Hai Zhang's
* PersistentBarLayout and Umano's SlidingUpPanelLayout, albeit heavily minified to remove
* extraneous use cases and updated to support the latest SDK level.
*
* **Note:** If you want to adapt this layout into your own app. Good luck. This layout has been
* heavily minified to Auxio's use case in particular and is really hard to understand since it
* has a ton of state and view magic. I tried my best to document it, but it's probably not the
* most friendly or extendable. You have been warned.
*/
class PlaybackLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : ViewGroup(context, attrs, defStyle) {
enum class PanelState {
EXPANDED, COLLAPSED, HIDDEN, DRAGGING
}
interface ActionCallback {
fun onNavToItem()
fun onPrev()
fun onPlayPauseClick()
fun onNext()
}
private lateinit var contentView: View
private val playbackContainerView: FrameLayout
private val playbackBarView: PlaybackBarView
private val playbackPanelView: FrameLayout
private val playbackFragment = PlaybackFragment()
/**
* The drag helper that animates and dispatches drag events to the panels.
*/
private val dragHelper = ViewDragHelper.create(this, DragHelperCallback()).apply {
minVelocity = MIN_FLING_VEL * resources.displayMetrics.density
}
/**
* The current window insets.
* Important since this layout must play a long with Auxio's edge-to-edge functionality.
*/
private var lastInsets: WindowInsets? = null
/** The current panel state. Can be [PanelState.DRAGGING]*/
var panelState = INIT_PANEL_STATE
private set
/** The last panel state before a drag event began. */
private var lastIdlePanelState = INIT_PANEL_STATE
/** The range of pixels that the panel can drag through */
private var panelRange = 0
/**
* The relative offset of this panel as a percentage of [panelRange].
* A value of 1 means a fully expanded panel.
* A value of 0 means a collapsed panel.
* A value below 0 means a hidden panel.
*/
private var panelOffset = 0f
private var initMotionX = 0f
private var initMotionY = 0f
private val tRect = Rect()
init {
setWillNotDraw(false)
// Set up our playback views. Doing this allows us to abstract away the implementation
// of these views from the user of this layout [MainFragment].
playbackContainerView = FrameLayout(context).apply {
id = R.id.playback_container
isClickable = true
isFocusable = false
isFocusableInTouchMode = false
background = MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = ColorStateList.valueOf(R.attr.colorSurface.resolveAttr(context))
elevation = resources.getDimensionPixelSize(R.dimen.elevation_normal).toFloat()
}
}
playbackBarView = PlaybackBarView(context).apply {
id = R.id.playback_bar
playbackContainerView.addView(this)
(layoutParams as FrameLayout.LayoutParams).apply {
width = LayoutParams.MATCH_PARENT
height = LayoutParams.WRAP_CONTENT
gravity = Gravity.TOP
}
// The bar view if clicked will expand into the full panel
setOnClickListener {
if (canSlide && panelState != PanelState.EXPANDED) {
applyState(PanelState.EXPANDED)
}
}
}
playbackPanelView = FrameLayout(context).apply {
playbackContainerView.addView(this)
(layoutParams as FrameLayout.LayoutParams).apply {
width = LayoutParams.MATCH_PARENT
height = LayoutParams.MATCH_PARENT
gravity = Gravity.CENTER
}
id = R.id.playback_panel
// Make sure we add our fragment to this view. This is actually a replace operation
// since we don't want to stack fragments but we can't ensure that this view doesn't
// already have a fragment attached.
(context as AppCompatActivity).supportFragmentManager.beginTransaction()
.replace(R.id.playback_panel, playbackFragment)
.commit()
}
}
// / --- CONTROL METHODS ---
/**
* Update the song that this layout is showing. This will be reflected in the compact view
* at the bottom of the screen.
*/
fun setSong(song: Song?) {
if (song != null) {
playbackBarView.setSong(song)
// Make sure the bar is shown
if (panelState == PanelState.HIDDEN) {
applyState(PanelState.COLLAPSED)
}
} else {
applyState(PanelState.HIDDEN)
}
}
/**
* Update the playing status on this layout. This will be reflected in the compact view
* at the bottom of the screen.
*/
fun setPlaying(isPlaying: Boolean) {
playbackBarView.setPlaying(isPlaying)
}
/**
* Update the playback position on this layout. This will be reflected in the compact view
* at the bottom of the screen.
*/
fun setPosition(position: Long) {
playbackBarView.setPosition(position)
}
/**
* Add a callback for actions from the compact playback view in this layout.
*/
fun setActionCallback(callback: ActionCallback) {
playbackBarView.setCallback(callback)
}
/**
* Collapse the panel if it is currently expanded.
*/
fun collapse() {
if (panelState == PanelState.EXPANDED) {
applyState(PanelState.COLLAPSED)
}
}
private fun applyState(state: PanelState) {
// Dragging events are really complex and we don't want to mess up the state
// while we are in one.
if (!isEnabled || state == panelState || panelState == PanelState.DRAGGING) {
return
}
if (!isLaidOut) {
// Not laid out, just apply the state and let the measure + layout steps apply it for us.
setPanelStateInternal(state)
} else {
// We are laid out. In this case we actually animate to our desired target.
when (state) {
PanelState.COLLAPSED -> smoothSlideTo(0f)
PanelState.EXPANDED -> smoothSlideTo(1.0f)
PanelState.HIDDEN -> smoothSlideTo(computePanelOffset(measuredHeight))
else -> {}
}
}
}
override fun onFinishInflate() {
super.onFinishInflate()
check(childCount == 1) { "There must only be one view in this layout" }
// Grab our content view [asserting that there is nothing else] and then add our panel.
// I would add our panel in our init, but that messes things up for some reason.
contentView = getChildAt(0)
addView(playbackContainerView)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Sanity check. The last thing I want to deal with is this view being WRAP_CONTENT.
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
check(widthMode == MeasureSpec.EXACTLY || heightMode == MeasureSpec.EXACTLY) {
"This view must be MATCH_PARENT"
}
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(widthSize, heightSize)
// First measure our actual container. We need to do this first to determine our
// range and offset values.
val panelWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
val panelHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
playbackContainerView.measure(panelWidthSpec, panelHeightSpec)
panelRange = measuredHeight - playbackBarView.measuredHeight
if (!isLaidOut) {
// This is our first layout, so make sure we know what offset we should work with
// before we measure our content
panelOffset = when (panelState) {
PanelState.EXPANDED -> 1.0f
PanelState.HIDDEN -> computePanelOffset(measuredHeight)
else -> 0f
}
updatePanelTransition()
}
applyContentWindowInsets()
measureContent()
}
private fun measureContent() {
// We need to find out how much the panel should affect the view.
// When the panel is in it's bar form, we shorten the content view. If it's being expanded,
// we keep the same height and just overlay the panel.
val barHeightAdjusted = measuredHeight - computePanelTopPosition(min(panelOffset, 0f))
// Note that these views will always be a fixed MATCH_PARENT. This is intentional,
// as it reduces the logic we have to deal with regarding WRAP_CONTENT views.
val contentWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
val contentHeightSpec = MeasureSpec.makeMeasureSpec(
measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY
)
contentView.measure(contentWidthSpec, contentHeightSpec)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// Figure out where our panel should be and lay it out there.
val panelTop = computePanelTopPosition(panelOffset)
playbackContainerView.layout(
0,
panelTop,
playbackContainerView.measuredWidth,
playbackContainerView.measuredHeight + panelTop
)
layoutContent()
}
private fun layoutContent() {
// We already did our magic while measuring. No need to do anything here.
contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight)
}
override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
val save = canvas.save()
// Drawing views that are under the panel is inefficient, clip the canvas
// so that doesn't occur.
if (child == contentView) {
canvas.getClipBounds(tRect)
tRect.bottom = tRect.bottom.coerceAtMost(playbackContainerView.top)
canvas.clipRect(tRect)
}
return super.drawChild(canvas, child, drawingTime).also {
canvas.restoreToCount(save)
}
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
// One issue with handling a bottom bar with edge-to-edge is that if you want to
// apply window insets to a view, those insets will cause incorrect spacing if the
// bottom navigation is consumed by a bar. To fix this, we modify the bottom insets
// to reflect the presence of the panel [at least in it's collapsed state]
playbackContainerView.dispatchApplyWindowInsets(insets)
lastInsets = insets
applyContentWindowInsets()
return insets
}
/**
* Apply window insets to the content views in this layouts. This is done separately as at
* times we want to re-inset the content views but not re-inset the bar view.
*/
private fun applyContentWindowInsets() {
val insets = lastInsets
if (insets != null) {
contentView.dispatchApplyWindowInsets(adjustInsets(insets))
}
}
/**
* Adjust window insets to line up with the panel
*/
private fun adjustInsets(insets: WindowInsets): WindowInsets {
// We kind to do a reverse-measure to figure out how we should inset this view.
// Find how much space is lost by the panel and then combine that with the
// bottom inset to find how much space we should apply.
val bars = insets.systemBarsCompat
val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
WindowInsets.Builder(insets)
.setInsets(
WindowInsets.Type.systemBars(),
Insets.of(bars.left, bars.top, bars.right, adjustedBottomInset)
)
.build()
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> {
@Suppress("DEPRECATION")
insets.replaceSystemWindowInsets(
bars.left, bars.top, bars.right, adjustedBottomInset
)
}
else -> insets
}
}
override fun onSaveInstanceState(): Parcelable = Bundle().apply {
putParcelable("superState", super.onSaveInstanceState())
putSerializable(
KEY_PANEL_STATE,
if (panelState != PanelState.DRAGGING) {
panelState
} else {
lastIdlePanelState
}
)
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state is Bundle) {
panelState = state.getSerializable(KEY_PANEL_STATE) as? PanelState ?: INIT_PANEL_STATE
super.onRestoreInstanceState(state.getParcelable("superState"))
} else {
super.onRestoreInstanceState(state)
}
}
@Suppress("Redundant")
override fun performClick(): Boolean {
return super.performClick()
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
performClick()
return if (!canSlide) {
super.onTouchEvent(ev)
} else try {
dragHelper.processTouchEvent(ev)
true
} catch (ex: Exception) {
// Ignore the pointer out of range exception
false
}
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (!canSlide) {
dragHelper.abort()
return false
}
val adx = abs(ev.x - initMotionX)
val ady = abs(ev.y - initMotionY)
val dragSlop = dragHelper.touchSlop
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
initMotionX = ev.x
initMotionY = ev.y
if (!playbackContainerView.isUnder(ev.x.toInt(), ev.y.toInt())) {
// Pointer is not on our view, do not intercept this event
dragHelper.cancel()
return false
}
}
MotionEvent.ACTION_MOVE -> {
val pointerUnder = playbackContainerView.isUnder(ev.x.toInt(), ev.y.toInt())
val motionUnder = playbackContainerView.isUnder(initMotionX.toInt(), initMotionY.toInt())
if (!(pointerUnder || motionUnder) || ady > dragSlop && adx > ady) {
// Pointer has moved beyond our control, do not intercept this event
dragHelper.cancel()
return false
}
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP ->
if (dragHelper.isDragging) {
// Stopped pressing while we were dragging, let the drag helper handle it
dragHelper.processTouchEvent(ev)
return true
}
}
return dragHelper.shouldInterceptTouchEvent(ev)
}
override fun computeScroll() {
// I have no idea what this does but it seems important so I keep it around
if (dragHelper.continueSettling(true)) {
if (!isEnabled) {
dragHelper.abort()
return
}
postInvalidateOnAnimation()
}
}
private fun View.isUnder(x: Int, y: Int): Boolean {
val viewLocation = IntArray(2)
getLocationOnScreen(viewLocation)
val parentLocation = IntArray(2)
(parent as View).getLocationOnScreen(parentLocation)
val screenX = parentLocation[0] + x
val screenY = parentLocation[1] + y
val inX = screenX >= viewLocation[0] && screenX < viewLocation[0] + width
val inY = screenY >= viewLocation[1] && screenY < viewLocation[1] + height
return inX && inY
}
private val ViewDragHelper.isDragging: Boolean
get() {
// We can't grab the drag state outside of a callback, but that's stupid and I don't
// want to vendor ViewDragHelper so I just do reflection instead.
val state = try {
this::class.java.getDeclaredField("mDragState").run {
isAccessible = true
get(dragHelper) as Int
}
} catch (e: Exception) {
ViewDragHelper.STATE_IDLE
}
return state == ViewDragHelper.STATE_DRAGGING
}
private fun setPanelStateInternal(state: PanelState) {
if (panelState == state) {
return
}
panelState = state
sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
}
/**
* Update the view transitions done when the panel slides up.
*/
private fun updatePanelTransition() {
contentView.alpha = min(1 - panelOffset, 1f)
// Slowly reduce the elevation as we slide up, eventually resulting in a neutral color
// instead of an elevated one when fully expanded.
(playbackContainerView.background as MaterialShapeDrawable).alpha = (min(1 - panelOffset, 1f) * 255).toInt()
// Fade out our bar view as we slide up
playbackBarView.apply {
alpha = min(1 - panelOffset, 1f)
isInvisible = alpha == 0f
// When edge-to-edge is enabled, the playback bar will not fade out into the
// playback menu's toolbar properly as PlaybackFragment will apply it's window insets.
// Therefore, we slowly increase the bar view's margins so that it fully disappears
// near the toolbar instead of in the system bars, which just looks nicer.
// The reason why we can't pad the bar is that it might result in the padding desyncing
// [reminder that this view also applies the bottom window inset] and we can't
// apply padding to the whole container layout since that would adjust the size
// of the playback view. This seems to be the least obtrusive way to do this.
lastInsets?.systemBarsCompat?.let { bars ->
val params = layoutParams as FrameLayout.LayoutParams
val oldTopMargin = params.topMargin
params.setMargins(
params.leftMargin,
(bars.top * max(panelOffset, 0f)).toInt(),
params.rightMargin,
params.bottomMargin
)
// Poke the layout only when we changed something
if (params.topMargin != oldTopMargin) {
playbackContainerView.requestLayout()
}
}
}
// Fade in our panel as we slide up
playbackPanelView.apply {
alpha = max(panelOffset, 0f)
isInvisible = alpha == 0f
}
}
private fun computePanelTopPosition(panelOffset: Float): Int =
measuredHeight - playbackBarView.measuredHeight - (panelOffset * panelRange).toInt()
private fun computePanelOffset(topPosition: Int): Float =
(computePanelTopPosition(0f) - topPosition).toFloat() / panelRange
private fun smoothSlideTo(offset: Float) {
if (!isEnabled) {
// Disabled, do nothing
return
}
// Find the new top position and animate the panel to that
val panelTop = computePanelTopPosition(offset)
if (dragHelper.smoothSlideViewTo(playbackContainerView, playbackContainerView.left, panelTop)) {
postInvalidateOnAnimation()
}
}
private val canSlide: Boolean
get() = panelState != PanelState.HIDDEN && isEnabled
private inner class DragHelperCallback : ViewDragHelper.Callback() {
override fun tryCaptureView(child: View, pointerId: Int): Boolean {
// Only capture on a fully expanded panel view
return child === playbackContainerView && panelOffset >= 0
}
override fun onViewDragStateChanged(state: Int) {
if (state == ViewDragHelper.STATE_IDLE) {
panelOffset = computePanelOffset(playbackContainerView.top)
when {
panelOffset == 1f -> setPanelStateInternal(PanelState.EXPANDED)
panelOffset == 0f -> setPanelStateInternal(PanelState.COLLAPSED)
panelOffset < 0f -> {
setPanelStateInternal(PanelState.HIDDEN)
playbackContainerView.visibility = INVISIBLE
}
else -> setPanelStateInternal(PanelState.EXPANDED)
}
}
}
override fun onViewCaptured(capturedChild: View, activePointerId: Int) {}
override fun onViewPositionChanged(
changedView: View,
left: Int,
top: Int,
dx: Int,
dy: Int
) {
// We're dragging, so we need to update our state accordingly
if (panelState != PanelState.DRAGGING) {
lastIdlePanelState = panelState
}
setPanelStateInternal(PanelState.DRAGGING)
// Update our panel offset using the new top value
panelOffset = computePanelOffset(top)
if (panelOffset < 0) {
// If we are hiding the panel, make sure we relayout our content too.
applyContentWindowInsets()
measureContent()
layoutContent()
}
updatePanelTransition()
invalidate()
}
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
val newOffset = when {
// Swipe Up -> Expand to top
yvel < 0 -> 1f
// Swipe down -> Collapse to bottom
yvel > 0 -> 0f
// No velocity, far enough from middle to expand to top
panelOffset >= 0.5f -> 1f
// Collapse to bottom
else -> 0f
}
dragHelper.settleCapturedViewAt(releasedChild.left, computePanelTopPosition(newOffset))
invalidate()
}
override fun getViewVerticalDragRange(child: View): Int {
return panelRange
}
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
val collapsedTop = computePanelTopPosition(0f)
val expandedTop = computePanelTopPosition(1.0f)
return top.coerceAtLeast(expandedTop).coerceAtMost(collapsedTop)
}
}
companion object {
private val INIT_PANEL_STATE = PanelState.HIDDEN
private const val MIN_FLING_VEL = 400
private const val KEY_PANEL_STATE = BuildConfig.APPLICATION_ID + ".key.panel_state"
}
}

View file

@ -24,7 +24,6 @@
android:id="@+id/playback_layout" android:id="@+id/playback_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"> android:clipToPadding="false">
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
@ -32,8 +31,7 @@
style="@style/Widget.Auxio.Toolbar.Icon.Down" style="@style/Widget.Auxio.Toolbar.Icon.Down"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_playback" app:menu="@menu/menu_playback" />
app:title="@string/lbl_playback" />
<ImageView <ImageView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
@ -146,7 +144,7 @@
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/playback_play_pause" android:id="@+id/playback_play_pause"
style="@style/Widget.Auxio.FloatingActionButton.MidLarge" style="@style/Widget.Auxio.FloatingActionButton.PlayPause"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:contentDescription="@string/desc_play_pause" android:contentDescription="@string/desc_play_pause"

View file

@ -24,7 +24,6 @@
android:id="@+id/playback_layout" android:id="@+id/playback_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"> android:clipToPadding="false">
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
@ -32,8 +31,7 @@
style="@style/Widget.Auxio.Toolbar.Icon.Down" style="@style/Widget.Auxio.Toolbar.Icon.Down"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_playback" app:menu="@menu/menu_playback" />
app:title="@string/lbl_playback" />
<ImageView <ImageView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
@ -145,7 +143,7 @@
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/playback_play_pause" android:id="@+id/playback_play_pause"
style="@style/Widget.Auxio.FloatingActionButton.MidLarge" style="@style/Widget.Auxio.FloatingActionButton.PlayPause"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:contentDescription="@string/desc_play_pause" android:contentDescription="@string/desc_play_pause"

View file

@ -24,7 +24,6 @@
android:id="@+id/playback_layout" android:id="@+id/playback_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"> android:clipToPadding="false">
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
@ -32,8 +31,7 @@
style="@style/Widget.Auxio.Toolbar.Icon.Down" style="@style/Widget.Auxio.Toolbar.Icon.Down"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_playback" app:menu="@menu/menu_playback" />
app:title="@string/lbl_playback" />
<ImageView <ImageView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
@ -129,7 +127,7 @@
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/playback_play_pause" android:id="@+id/playback_play_pause"
style="@style/Widget.Auxio.FloatingActionButton.MidLarge" style="@style/Widget.Auxio.FloatingActionButton.PlayPause"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_large" android:layout_marginBottom="@dimen/spacing_large"

View file

@ -12,7 +12,7 @@
<variable <variable
name="callback" name="callback"
type="org.oxycblt.auxio.playback.PlaybackBarLayout.ActionCallback" /> type="org.oxycblt.auxio.playback.PlaybackLayout.ActionCallback" />
</data> </data>

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:context=".MainFragment"> tools:context=".MainFragment">
<org.oxycblt.auxio.playback.PlaybackBarLayout <org.oxycblt.auxio.playback.PlaybackLayout
android:id="@+id/main_bar_layout" android:id="@+id/main_bar_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -19,5 +19,5 @@
app:navGraph="@navigation/nav_explore" app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />
</org.oxycblt.auxio.playback.PlaybackBarLayout> </org.oxycblt.auxio.playback.PlaybackLayout>
</layout> </layout>

View file

@ -23,7 +23,6 @@
android:id="@+id/playback_layout" android:id="@+id/playback_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"> android:clipToPadding="false">
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
@ -31,8 +30,7 @@
style="@style/Widget.Auxio.Toolbar.Icon.Down" style="@style/Widget.Auxio.Toolbar.Icon.Down"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_playback" app:menu="@menu/menu_playback" />
app:title="@string/lbl_playback" />
<ImageView <ImageView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
@ -127,7 +125,7 @@
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/playback_play_pause" android:id="@+id/playback_play_pause"
style="@style/Widget.Auxio.FloatingActionButton.MidLarge" style="@style/Widget.Auxio.FloatingActionButton.PlayPause"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_medium" android:layout_marginBottom="@dimen/spacing_medium"

View file

@ -12,7 +12,7 @@
<variable <variable
name="callback" name="callback"
type="org.oxycblt.auxio.playback.PlaybackBarLayout.ActionCallback" /> type="org.oxycblt.auxio.playback.PlaybackLayout.ActionCallback" />
</data> </data>

View file

@ -9,8 +9,8 @@
android:label="MainFragment" android:label="MainFragment"
tools:layout="@layout/fragment_main"> tools:layout="@layout/fragment_main">
<action <action
android:id="@+id/action_go_to_playback" android:id="@+id/action_show_queue"
app:destination="@id/playback_fragment" app:destination="@id/queue_fragment"
app:enterAnim="@anim/anim_nav_slide_up" app:enterAnim="@anim/anim_nav_slide_up"
app:exitAnim="@anim/anim_stationary" app:exitAnim="@anim/anim_stationary"
app:popEnterAnim="@anim/anim_stationary" app:popEnterAnim="@anim/anim_stationary"
@ -30,19 +30,7 @@
app:popEnterAnim="@anim/anim_stationary" app:popEnterAnim="@anim/anim_stationary"
app:popExitAnim="@anim/anim_nav_slide_down" /> app:popExitAnim="@anim/anim_nav_slide_down" />
</fragment> </fragment>
<fragment
android:id="@+id/playback_fragment"
android:name="org.oxycblt.auxio.playback.PlaybackFragment"
android:label="PlaybackFragment"
tools:layout="@layout/fragment_playback">
<action
android:id="@+id/action_show_queue"
app:destination="@id/queue_fragment"
app:enterAnim="@anim/anim_nav_slide_up"
app:exitAnim="@anim/anim_stationary"
app:popEnterAnim="@anim/anim_stationary"
app:popExitAnim="@anim/anim_nav_slide_down" />
</fragment>
<fragment <fragment
android:id="@+id/queue_fragment" android:id="@+id/queue_fragment"
android:name="org.oxycblt.auxio.playback.queue.QueueFragment" android:name="org.oxycblt.auxio.playback.queue.QueueFragment"

View file

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- This is for PlaybackBarLayout --> <!-- This is for PlaybackBarLayout -->
<item name="playback_container" type="id" />
<item name="playback_bar" type="id" /> <item name="playback_bar" type="id" />
<item name="playback_panel" type="id" />
<!-- This is for HomeFragment's AppBarLayout. Explanations for these can be found there. --> <!-- This is for HomeFragment's AppBarLayout. Explanations for these can be found there. -->
<item name="home_song_list" type="id" /> <item name="home_song_list" type="id" />

View file

@ -161,36 +161,26 @@
<item name="android:textAppearance">@style/TextAppearance.Auxio.LabelLarger</item> <item name="android:textAppearance">@style/TextAppearance.Auxio.LabelLarger</item>
</style> </style>
<style name="Widget.Auxio.FloatingActionButton.MidLarge" parent="Widget.Material3.FloatingActionButton.Primary"> <style name="Widget.Auxio.FloatingActionButton.PlayPause" parent="Widget.Material3.FloatingActionButton.Primary">
<item name="maxImageSize">@dimen/size_play_fab_icon</item> <item name="maxImageSize">@dimen/size_play_fab_icon</item>
<item name="fabCustomSize">@dimen/size_btn_large</item> <item name="fabCustomSize">@dimen/size_btn_large</item>
</style>
<style name="Widget.MaterialComponents.Button.IconOnly"> <!--
<item name="iconPadding">0dp</item> Abuse this floating action button to act more like an old-school auxio button.
<item name="android:insetTop">0dp</item> This is only done because -->
<item name="android:insetBottom">0dp</item> <item name="android:elevation">0dp</item>
<item name="android:paddingLeft">12dp</item>
<item name="android:paddingRight">12dp</item>
<item name="android:minWidth">@dimen/size_btn_small</item>
<item name="android:minHeight">@dimen/size_btn_small</item>
<item name="iconGravity">textStart</item>
</style>
<style name="Widget.MaterialComponents.Button.UnelevatedButton.IconOnly" parent="Widget.MaterialComponents.Button.IconOnly">
<item name="android:stateListAnimator">@animator/mtrl_btn_unelevated_state_list_anim</item>
<item name="elevation">0dp</item> <item name="elevation">0dp</item>
<item name="materialThemeOverlay">@style/ThemeOverlay.Auxio.FloatingActionButton.PlayPause</item>
<item name="shapeAppearanceOverlay">@style/ShapeAppearance.Auxio.FloatingActionButton.PlayPause</item>
</style> </style>
<style name="Widget.MaterialComponents.Button.TextButton.IconOnly" parent="Widget.MaterialComponents.Button.UnelevatedButton.IconOnly"> <style name="ThemeOverlay.Auxio.FloatingActionButton.PlayPause" parent="">
<item name="android:textColor">@color/mtrl_text_btn_text_color_selector</item> <item name="colorContainer">?attr/colorSecondary</item>
<item name="iconTint">?attr/colorControlNormal</item> <item name="colorOnContainer">?attr/colorOnSecondary</item>
<item name="backgroundTint">@color/mtrl_btn_text_btn_bg_color_selector</item>
<item name="rippleColor">?attr/colorControlHighlight</item>
</style> </style>
<style name="Widget.MaterialComponents.Button.OutlinedButton.IconOnly" parent="Widget.MaterialComponents.Button.TextButton.IconOnly"> <style name="ShapeAppearance.Auxio.FloatingActionButton.PlayPause" parent="">
<item name="strokeColor">@color/mtrl_btn_stroke_color_selector</item> <item name="cornerSize">50%</item>
<item name="strokeWidth">@dimen/mtrl_btn_stroke_size</item>
</style> </style>
</resources> </resources>