playback: rework playback slide up implementation

Rework the playback slide up implementation to be more straightfoward.

This is really composed of stylistic improvements, very little in
actual behavior changes. This does re-introduce a regression when
nothing is playing where the scroll position will become off when
rotating, but that desynchronization happens often so unless I were
to completely migrate both the panel and the bar to a view, I don't
really see it as an issue.
This commit is contained in:
OxygenCobalt 2022-03-20 12:27:52 -06:00
parent 608112a7ac
commit e4d4266e35
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
21 changed files with 317 additions and 329 deletions

View file

@ -6,7 +6,10 @@
- Fixed incorrect ellipsizing on song items
#### Dev/Meta
- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese ]
- Switched to spotless and ktfmt instead of ktlint
- Migrated constants to centralized table
- A bunch of internal view implementation improvements
## v2.2.2
#### What's New

View file

@ -34,7 +34,6 @@ import org.oxycblt.auxio.settings.SettingsManager
* - Refactor fragment class
* - Remove databinding and dedup layouts
* - Rework RecyclerView management and item dragging
* - Rework sealed classes to minimize whens and maximize overrides
* ```
*/
@Suppress("UNUSED")

View file

@ -30,7 +30,6 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
@ -46,7 +45,6 @@ import org.oxycblt.auxio.util.logW
*/
class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private var callback: Callback? = null
@ -87,10 +85,6 @@ class MainFragment : Fragment() {
// --- VIEWMODEL SETUP ---
// We have to control the bar view from here since using a Fragment in PlaybackLayout
// would result in annoying UI issues.
binding.playbackLayout.setup(playbackModel, detailModel, viewLifecycleOwner)
// Initialize music loading. Do it here so that it shows on every fragment that this
// one contains.
musicModel.loadMusic(requireContext())
@ -135,6 +129,14 @@ class MainFragment : Fragment() {
}
}
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song != null) {
binding.bottomSheetLayout.show()
} else {
binding.bottomSheetLayout.hide()
}
}
logD("Fragment Created")
return binding.root
@ -156,7 +158,7 @@ class MainFragment : Fragment() {
*/
inner class Callback(private val binding: FragmentMainBinding) : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (!binding.playbackLayout.collapse()) {
if (!binding.bottomSheetLayout.collapse()) {
val navController = binding.exploreNavHost.findNavController()
if (navController.currentDestination?.id ==

View file

@ -79,7 +79,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
View(context).apply {
alpha = 0f
background = context.getDrawableSafe(R.drawable.ui_scroll_thumb)
this@FastScrollRecyclerView.overlay.add(this)
}
private val thumbWidth = thumbView.background.intrinsicWidth
@ -94,8 +93,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
private val scrollPositionChildRect = Rect()
// Popup
private val popupView =
FastScrollPopupView(context).apply {
@ -106,8 +103,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenOffsetSafe(R.dimen.spacing_small)
}
this@FastScrollRecyclerView.overlay.add(this)
}
private var showingPopup = false
@ -149,6 +144,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
onDragListener?.invoke(value)
}
private val tRect = Rect()
/** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: ((Int) -> String)? = null
@ -299,8 +296,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Combine the previous item dimensions with the current item top to find our scroll
// position
getDecoratedBoundsWithMargins(getChildAt(0), scrollPositionChildRect)
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - scrollPositionChildRect.top
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
// Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
@ -493,8 +490,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
val itemView = getChildAt(0)
getDecoratedBoundsWithMargins(itemView, scrollPositionChildRect)
return scrollPositionChildRect.height()
getDecoratedBoundsWithMargins(itemView, tRect)
return tRect.height()
}
private val itemCount: Int

View file

@ -0,0 +1,125 @@
/*
* 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
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.color.MaterialColors
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.ui.BottomSheetLayout
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackBarFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentPlaybackBarBinding.inflate(inflater)
// -- UI SETUP ---
binding.root.apply {
setOnClickListener {
// 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 BottomSheetLayout).expand()
}
setOnLongClickListener {
playbackModel.song.value?.let { song -> detailModel.navToItem(song) }
true
}
setOnApplyWindowInsetsListener { view, insets ->
// Since we swipe up this view, we need to make sure it does not collide with
// any gesture events. So, apply the system gesture insets if present and then
// only default to the system bar insets when there are no other options.
val gesturePadding =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
insets.getInsets(WindowInsets.Type.systemGestures()).bottom
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
@Suppress("DEPRECATION") insets.systemGestureInsets.bottom
}
else -> 0
}
view.updatePadding(
bottom =
if (gesturePadding != 0) gesturePadding
else insets.systemBarInsetsCompat.bottom)
insets
}
}
binding.playbackSkipPrev?.setOnClickListener { playbackModel.skipPrev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlayingStatus() }
binding.playbackSkipNext?.setOnClickListener { playbackModel.skipNext() }
// Deliberately override the progress bar color [in a Lollipop-friendly way] so that
// we use colorSecondary instead of colorSurfaceVariant. This is because
// colorSurfaceVariant is used with the assumption that the view that is using it is
// not elevated and is therefore not colored. This view is elevated.
binding.playbackProgressBar.trackColor =
MaterialColors.compositeARGBWithAlpha(
requireContext().getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt())
// -- VIEWMODEL SETUP ---
binding.song = playbackModel.song.value
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song != null) {
binding.song = song
binding.executePendingBindings()
}
}
binding.playbackPlayPause.isActivated = playbackModel.isPlaying.value!!
playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying ->
binding.playbackPlayPause.isActivated = isPlaying
binding.executePendingBindings()
}
binding.playbackProgressBar.progress = playbackModel.position.value!!.toInt()
playbackModel.position.observe(viewLifecycleOwner) { position ->
binding.playbackProgressBar.progress = position.toInt()
}
binding.executePendingBindings()
return binding.root
}
}

View file

@ -1,113 +0,0 @@
/*
* Copyright (c) 2021 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
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.WindowInsets
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.updatePadding
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.color.MaterialColors
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ViewPlaybackBarBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A view displaying the playback state in a compact manner. This is only meant to be used by
* [PlaybackLayout].
*/
class PlaybackBarView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewPlaybackBarBinding.inflate(context.inflater, this, true)
init {
id = R.id.playback_bar
// Deliberately override the progress bar color [in a Lollipop-friendly way] so that
// we use colorSecondary instead of colorSurfaceVariant. This is because
// colorSurfaceVariant is used with the assumption that the view that is using it is
// not elevated and is therefore not colored. This view is elevated.
binding.playbackProgressBar.trackColor =
MaterialColors.compositeARGBWithAlpha(
context.getAttrColorSafe(R.attr.colorSecondary), (255 * 0.2).toInt())
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Since we swipe up this view, we need to make sure it does not collide with
// any gesture events. So, apply the system gesture insets if present and then
// only default to the system bar insets when there are no other options.
val gesturePadding =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
insets.getInsets(WindowInsets.Type.systemGestures()).bottom
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
@Suppress("DEPRECATION") insets.systemGestureInsets.bottom
}
else -> 0
}
updatePadding(
bottom =
if (gesturePadding != 0) gesturePadding else insets.systemBarInsetsCompat.bottom)
return insets
}
fun setup(
playbackModel: PlaybackViewModel,
detailModel: DetailViewModel,
viewLifecycleOwner: LifecycleOwner
) {
setOnLongClickListener {
playbackModel.song.value?.let { song -> detailModel.navToItem(song) }
true
}
binding.playbackSkipPrev?.setOnClickListener { playbackModel.skipPrev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlayingStatus() }
binding.playbackSkipNext?.setOnClickListener { playbackModel.skipNext() }
binding.playbackPlayPause.isActivated = playbackModel.isPlaying.value!!
playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying ->
binding.playbackPlayPause.isActivated = isPlaying
}
binding.playbackProgressBar.progress = playbackModel.position.value!!.toInt()
playbackModel.position.observe(viewLifecycleOwner) { position ->
binding.playbackProgressBar.progress = position.toInt()
}
}
fun setSong(song: Song) {
binding.song = song
binding.executePendingBindings()
}
}

View file

@ -28,9 +28,10 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBinding
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.ui.BottomSheetLayout
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -41,17 +42,17 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
*
* TODO: Handle RTL correctly in the playback buttons
*/
class PlaybackFragment : Fragment() {
class PlaybackPanelFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private var lastBinding: FragmentPlaybackBinding? = null
private var lastBinding: FragmentPlaybackPanelBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentPlaybackBinding.inflate(layoutInflater)
val binding = FragmentPlaybackPanelBinding.inflate(layoutInflater)
val queueItem: MenuItem
// See onDestroyView for why we do this
@ -98,9 +99,6 @@ class PlaybackFragment : Fragment() {
logD("Updating song display to ${song.rawName}")
binding.song = song
binding.playbackSeekBar.setDuration(song.seconds)
} else {
logD("No song is being played, leaving")
findNavController().navigateUp()
}
}
@ -164,6 +162,6 @@ class PlaybackFragment : Fragment() {
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()
(requireView().parent.parent.parent as BottomSheetLayout).collapse()
}
}

View file

@ -138,10 +138,7 @@ class PlaybackService :
// --- SYSTEM SETUP ---
widgets = WidgetController(this)
// Set up the media button callbacks
mediaSession = MediaSessionCompat(this, packageName).apply { isActive = true }
connector = PlaybackSessionConnector(this, player, mediaSession)
// Then the notification/headset callbacks
@ -201,6 +198,7 @@ class PlaybackService :
playbackManager.setPlaying(false)
// The service coroutines last job is to save the state to the DB, before terminating itself
// FIXME: This is a terrible idea, move this to when the user closes the notification
serviceScope.launch {
playbackManager.saveStateToDatabase(this@PlaybackService)
serviceJob.cancel()
@ -438,19 +436,17 @@ class PlaybackService :
when (intent.action) {
// --- SYSTEM EVENTS ---
// Technically the MediaSession seems to handle bluetooth events on their
// own, but keep this around as a fallback in the case that the former fails
// for whatever reason.
// TODO: Remove this since the headset hook KeyEvent should be fine enough.
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) {
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> pauseFromPlug()
AudioManager.SCO_AUDIO_STATE_CONNECTED -> maybeResumeFromPlug()
}
}
// MediaSession does not handle wired headsets for some reason, so also include
// this. Gotta love Android having two actions for more or less the same thing.
// Android has four different ways of handling audio plug events for some reason:
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets
// 2. ACTION_SCO_AUDIO_STATE_UPDATED, which only works with pausing from a plug
// event and I'm not even sure if it's needed
// 3. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// a non-starter since both require me to display a permission prompt
// 4. Some weird internal framework thing that also handles bluetooth headsets???
//
// They should have just stopped at ACTION_HEADSET_PLUG. Just use 1 and 2 so that
// *something* fills in the role.
AudioManager.ACTION_HEADSET_PLUG -> {
when (intent.getIntExtra("state", -1)) {
0 -> pauseFromPlug()
@ -459,8 +455,12 @@ class PlaybackService :
initialHeadsetPlugEventHandled = true
}
// I have never seen this happen but it might be useful
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) {
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> pauseFromPlug()
AudioManager.SCO_AUDIO_STATE_CONNECTED -> maybeResumeFromPlug()
}
}
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
// --- AUXIO EVENTS ---
@ -485,7 +485,7 @@ class PlaybackService :
* that friendly
* 2. There is a bug where playback will always start when this service starts, mostly due
* to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear
* that it may not work on OEM skins that for whatever reason don't make this action fire.\
* that it may not work on OEM skins that for whatever reason don't make this action fire.
*/
private fun maybeResumeFromPlug() {
if (playbackManager.song != null &&

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback
package org.oxycblt.auxio.ui
import android.content.Context
import android.graphics.Canvas
@ -31,18 +31,14 @@ 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 androidx.lifecycle.LifecycleOwner
import com.google.android.material.shape.MaterialShapeDrawable
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenSafe
@ -55,10 +51,21 @@ import org.oxycblt.auxio.util.stateList
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* 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 and androidx tools.
* A layout that *properly* handles bottom sheet functionality.
*
* BottomSheetBehavior has a multitude of shortcomings based that make it a non-starter for Auxio,
* such as:
* - No edge-to-edge support
* - Extreme jank
* - Terrible APIs that you have to use just to make the UX tolerable
* - Reliance on CoordinatorLayout, which is just a terrible component in general and everyone
* responsible for creating it should be publicly shamed
*
* So, I decided to make my own implementation. With blackjack, and referential humor.
*
* The actual internals of this view are based off of 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 and androidx tools.
*
* **Note:** If you want to adapt this layout into your own app. Good luck. This layout has been
* reduced to Auxio's use case in particular and is really hard to understand since it has a ton of
@ -66,10 +73,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* extendable. You have been warned.
*
* @author OxygenCobalt (With help from Umano and Hai Zhang)
*
* TODO: Find a better way to handle PlaybackFragment in general (navigation, creation)
*/
class PlaybackLayout
class BottomSheetLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
ViewGroup(context, attrs, defStyle) {
@ -80,13 +85,39 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
DRAGGING
}
// Core views [obtained when layout is inflated]
private lateinit var contentView: View
private val playbackContainerView: FrameLayout
private val playbackBarView: PlaybackBarView
private val playbackPanelView: FrameLayout
private lateinit var barView: View
private lateinit var panelView: View
private val playbackContainerBg: MaterialShapeDrawable
private val playbackFragment = PlaybackFragment()
// We have to define the background before the container declaration as otherwise it wont work
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
private val containerBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
elevation = context.pxOfDp(elevationNormal).toFloat()
}
private val containerView =
FrameLayout(context).apply {
id = R.id.bottom_sheet_layout_container
isClickable = true
isFocusable = false
isFocusableInTouchMode = false
// The way we fade out the elevation overlay is not by actually reducing the
// elevation but by fading out the background drawable itself. To be safe,
// we apply this background drawable to a layer list with another colorSurface
// shape drawable, just in case weird things happen if background drawable is
// completely transparent.
background =
(context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply {
setDrawableByLayerId(R.id.panel_overlay, containerBackgroundDrawable)
}
disableDropShadowCompat()
}
/** The drag helper that animates and dispatches drag events to the panels. */
private val dragHelper =
@ -115,128 +146,39 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
*/
private var panelOffset = 0f
// Miscellaneous view things
// Miscellaneous touch things
private var initMotionX = 0f
private var initMotionY = 0f
private val tRect = Rect()
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
/** See [isDragging] */
private val dragStateField =
ViewDragHelper::class.java.getDeclaredField("mDragState").apply { isAccessible = true }
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
playbackContainerBg =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
elevation = context.pxOfDp(elevationNormal).toFloat()
}
// The way we fade out the elevation overlay is not by actually reducing the
// elevation
// but by fading out the background drawable itself. To be safe, we apply this
// background drawable to a layer list with another colorSurface shape drawable,
// just
// in case weird things happen if background drawable is completely transparent.
background =
(context.getDrawableSafe(R.drawable.ui_panel_bg) as LayerDrawable).apply {
setDrawableByLayerId(R.id.panel_overlay, playbackContainerBg)
}
disableDropShadowCompat()
}
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.
try {
(context as AppCompatActivity)
.supportFragmentManager
.beginTransaction()
.replace(R.id.playback_panel, playbackFragment)
.commit()
} catch (e: Exception) {
// Band-aid to stop the app crashing if we have to swap out the content view
// without warning (which we have to do sometimes because android is the worst
// thing ever)
}
}
}
// / --- 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 setup(
playbackModel: PlaybackViewModel,
detailModel: DetailViewModel,
viewLifecycleOwner: LifecycleOwner
) {
setSong(playbackModel.song.value)
fun show(): Boolean {
if (panelState == PanelState.HIDDEN) {
applyState(PanelState.COLLAPSED)
return true
}
playbackModel.song.observe(viewLifecycleOwner) { song -> setSong(song) }
playbackBarView.setup(playbackModel, detailModel, viewLifecycleOwner)
return false
}
private 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)
fun expand(): Boolean {
if (panelState == PanelState.COLLAPSED) {
applyState(PanelState.EXPANDED)
return true
}
return false
}
/**
@ -252,6 +194,17 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
return false
}
/**
*/
fun hide(): Boolean {
if (panelState != PanelState.HIDDEN) {
applyState(PanelState.HIDDEN)
return true
}
return false
}
private fun applyState(state: PanelState) {
logD("Applying panel state $state")
@ -284,12 +237,28 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
override fun onFinishInflate() {
super.onFinishInflate()
check(childCount == 1) { "There must only be one view in this layout" }
contentView = getChildAt(0) // Child 1 is assumed to be the content
barView = getChildAt(1) // Child 2 is assumed to be the bar used when collapsed
panelView = getChildAt(2) // Child 3 is assumed to be the panel used when expanded
// 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)
removeView(barView)
removeView(panelView)
// We actually move the bar and panel views into a container so that they have consistent
// behavior when be manipulate layouts later.
containerView.apply {
addView(
barView,
FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
.apply { gravity = Gravity.TOP })
addView(
panelView,
FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
.apply { gravity = Gravity.CENTER })
}
addView(containerView)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@ -311,16 +280,16 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// range and offset values.
val panelWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
val panelHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
playbackContainerView.measure(panelWidthSpec, panelHeightSpec)
containerView.measure(panelWidthSpec, panelHeightSpec)
panelRange = measuredHeight - playbackBarView.measuredHeight
panelRange = measuredHeight - barView.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.EXPANDED -> 1f
PanelState.HIDDEN -> computePanelOffset(measuredHeight)
else -> 0f
}
@ -351,11 +320,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// 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)
containerView.layout(
0, panelTop, containerView.measuredWidth, containerView.measuredHeight + panelTop)
layoutContent()
}
@ -372,7 +338,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// so that doesn't occur.
if (child == contentView) {
canvas.getClipBounds(tRect)
tRect.bottom = tRect.bottom.coerceAtMost(playbackContainerView.top)
tRect.bottom = tRect.bottom.coerceAtMost(containerView.top)
canvas.clipRect(tRect)
}
@ -384,7 +350,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// 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)
containerView.dispatchApplyWindowInsets(insets)
lastInsets = insets
applyContentWindowInsets()
return insets
@ -403,7 +369,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
/** 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.
// We kind of 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.systemBarInsetsCompat
@ -464,7 +430,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
initMotionX = ev.x
initMotionY = ev.y
if (!playbackContainerView.isUnder(ev.x, ev.y)) {
if (!containerView.isUnder(ev.x, ev.y)) {
// Pointer is not on our view, do not intercept this event
dragHelper.cancel()
return false
@ -474,8 +440,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
val adx = abs(ev.x - initMotionX)
val ady = abs(ev.y - initMotionY)
val pointerUnder = playbackContainerView.isUnder(ev.x, ev.y)
val motionUnder = playbackContainerView.isUnder(initMotionX, initMotionY)
val pointerUnder = containerView.isUnder(ev.x, ev.y)
val motionUnder = containerView.isUnder(initMotionX, initMotionY)
if (!(pointerUnder || motionUnder) || ady > dragHelper.touchSlop && adx > ady) {
// Pointer has moved beyond our control, do not intercept this event
@ -526,7 +492,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
}
/**
* Do the nice view animations that occur whenever we slide up the playback panel. The way I
* Do the nice view animations that occur whenever we slide up the bottom sheet. The way I
* transition is largely inspired by Android 12's notification panel, with the compact view
* fading out completely before the panel view fades in.
*/
@ -544,24 +510,24 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// Slowly reduce the elevation of the container as we slide up, eventually resulting in a
// neutral color instead of an elevated one when fully expanded.
playbackContainerBg.alpha = (outRatio * 255).toInt()
playbackContainerView.translationZ = elevationNormal * outRatio
containerBackgroundDrawable.alpha = (outRatio * 255).toInt()
containerView.translationZ = elevationNormal * outRatio
// Fade out our bar view as we slide up
playbackBarView.apply {
barView.apply {
alpha = min(1 - halfOutRatio, 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.
// When edge-to-edge is enabled, the bar will not fade out into the
// top of the panel 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
// desynchronizing [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.
// the size of the panel view. This seems to be the least obtrusive way to do this.
lastInsets?.systemBarInsetsCompat?.let { bars ->
val params = layoutParams as FrameLayout.LayoutParams
val params = layoutParams as MarginLayoutParams
val oldTopMargin = params.topMargin
params.setMargins(
@ -572,20 +538,20 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// Poke the layout only when we changed something
if (params.topMargin != oldTopMargin) {
playbackContainerView.requestLayout()
containerView.requestLayout()
}
}
}
// Fade in our panel as we slide up
playbackPanelView.apply {
panelView.apply {
alpha = halfInRatio
isInvisible = alpha == 0f
}
}
private fun computePanelTopPosition(panelOffset: Float): Int =
measuredHeight - playbackBarView.measuredHeight - (panelOffset * panelRange).toInt()
measuredHeight - barView.measuredHeight - (panelOffset * panelRange).toInt()
private fun computePanelOffset(topPosition: Int): Float =
(computePanelTopPosition(0f) - topPosition).toFloat() / panelRange
@ -595,7 +561,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
val okay =
dragHelper.smoothSlideViewTo(
playbackContainerView, playbackContainerView.left, computePanelTopPosition(offset))
containerView, containerView.left, computePanelTopPosition(offset))
if (okay) {
postInvalidateOnAnimation()
@ -608,19 +574,19 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
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
return child === containerView && panelOffset >= 0
}
override fun onViewDragStateChanged(state: Int) {
if (state == ViewDragHelper.STATE_IDLE) {
panelOffset = computePanelOffset(playbackContainerView.top)
panelOffset = computePanelOffset(containerView.top)
when {
panelOffset == 1f -> setPanelStateInternal(PanelState.EXPANDED)
panelOffset == 0f -> setPanelStateInternal(PanelState.COLLAPSED)
panelOffset < 0f -> {
setPanelStateInternal(PanelState.HIDDEN)
playbackContainerView.visibility = INVISIBLE
containerView.visibility = INVISIBLE
}
else -> setPanelStateInternal(PanelState.EXPANDED)
}

View file

@ -34,6 +34,7 @@ class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
// FIXME: Not correct, use item displays
return oldItem.hashCode() == newItem.hashCode()
}
}

View file

@ -2,7 +2,7 @@
<layout 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"
tools:context=".playback.PlaybackFragment">
tools:context=".playback.PlaybackPanelFragment">
<data>

View file

@ -2,7 +2,7 @@
<layout 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"
tools:context=".playback.PlaybackFragment">
tools:context=".playback.PlaybackPanelFragment">
<data>

View file

@ -2,7 +2,7 @@
<layout 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"
tools:context=".playback.PlaybackFragment">
tools:context=".playback.PlaybackPanelFragment">
<data>

View file

@ -12,7 +12,7 @@
</data>
<merge
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
@ -108,5 +108,5 @@
app:trackColor="?attr/colorPrimary"
tools:progress="70" />
</merge>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -2,7 +2,7 @@
<layout 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"
tools:context=".playback.PlaybackFragment">
tools:context=".playback.PlaybackPanelFragment">
<data>

View file

@ -12,7 +12,7 @@
</data>
<merge
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
@ -106,5 +106,5 @@
app:trackColor="?attr/colorPrimary"
tools:progress="70" />
</merge>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -8,8 +8,8 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.oxycblt.auxio.playback.PlaybackLayout
android:id="@+id/playback_layout"
<org.oxycblt.auxio.ui.BottomSheetLayout
android:id="@+id/bottom_sheet_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -21,7 +21,19 @@
app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" />
</org.oxycblt.auxio.playback.PlaybackLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/playback_bar_fragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:name="org.oxycblt.auxio.playback.PlaybackBarFragment" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/playback_panel_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment" />
</org.oxycblt.auxio.ui.BottomSheetLayout>
<FrameLayout
android:id="@+id/layout_too_small"

View file

@ -12,7 +12,7 @@
</data>
<merge
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
@ -80,5 +80,5 @@
app:layout_constraintStart_toStartOf="parent"
tools:progress="70" />
</merge>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -2,7 +2,7 @@
<layout 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"
tools:context=".playback.PlaybackFragment">
tools:context=".playback.PlaybackPanelFragment">
<data>

View file

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

View file

@ -2,7 +2,7 @@
<resources>
<integer name="detail_app_bar_title_anim_duration">150</integer>
<!-- FIXME: This is really stupid, figure out how we can unify it with the C object-->
<!-- FIXME: This is really stupid, figure out how we can unify it with the IntegerTable object-->
<!-- Preference values -->
<string-array name="entries_theme">
<item>@string/set_theme_auto</item>