diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4760c1de9..870a7aa6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt
index e69342276..2d0429d1c 100644
--- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt
+++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt
@@ -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")
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index 7ff7e5f7a..be7c6799a 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -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 ==
diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
index e73d168e0..a9f63fc9a 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
@@ -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
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
new file mode 100644
index 000000000..f63a237b1
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
@@ -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 .
+ */
+
+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
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
deleted file mode 100644
index 10059b68c..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
+++ /dev/null
@@ -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 .
- */
-
-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()
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
similarity index 92%
rename from app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
index 889fee6f4..b19cff6ac 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
@@ -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()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
index c6c92c35c..2644d90b2 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
@@ -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 &&
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt
similarity index 72%
rename from app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt
rename to app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt
index 8cb2d2f1d..8473aeeb1 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-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)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt b/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt
index dccde380f..3317373f2 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/DiffCallback.kt
@@ -34,6 +34,7 @@ class DiffCallback : DiffUtil.ItemCallback() {
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
+ // FIXME: Not correct, use item displays
return oldItem.hashCode() == newItem.hashCode()
}
}
diff --git a/app/src/main/res/layout-land/fragment_playback.xml b/app/src/main/res/layout-land/fragment_playback_panel.xml
similarity index 99%
rename from app/src/main/res/layout-land/fragment_playback.xml
rename to app/src/main/res/layout-land/fragment_playback_panel.xml
index 9f646b1cc..9459c2287 100644
--- a/app/src/main/res/layout-land/fragment_playback.xml
+++ b/app/src/main/res/layout-land/fragment_playback_panel.xml
@@ -2,7 +2,7 @@
+ tools:context=".playback.PlaybackPanelFragment">
diff --git a/app/src/main/res/layout-sw600dp-land/fragment_playback.xml b/app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml
similarity index 99%
rename from app/src/main/res/layout-sw600dp-land/fragment_playback.xml
rename to app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml
index 5d543e24c..c5af42495 100644
--- a/app/src/main/res/layout-sw600dp-land/fragment_playback.xml
+++ b/app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml
@@ -2,7 +2,7 @@
+ tools:context=".playback.PlaybackPanelFragment">
diff --git a/app/src/main/res/layout-sw600dp/fragment_playback.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml
similarity index 99%
rename from app/src/main/res/layout-sw600dp/fragment_playback.xml
rename to app/src/main/res/layout-sw600dp/fragment_playback_panel.xml
index f4d7aff03..ae93d2408 100644
--- a/app/src/main/res/layout-sw600dp/fragment_playback.xml
+++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml
@@ -2,7 +2,7 @@
+ tools:context=".playback.PlaybackPanelFragment">
diff --git a/app/src/main/res/layout-sw640dp/view_playback_bar.xml b/app/src/main/res/layout-sw640dp/fragment_playback_bar.xml
similarity index 97%
rename from app/src/main/res/layout-sw640dp/view_playback_bar.xml
rename to app/src/main/res/layout-sw640dp/fragment_playback_bar.xml
index 794ad3054..3c215d85f 100644
--- a/app/src/main/res/layout-sw640dp/view_playback_bar.xml
+++ b/app/src/main/res/layout-sw640dp/fragment_playback_bar.xml
@@ -12,7 +12,7 @@
-
@@ -108,5 +108,5 @@
app:trackColor="?attr/colorPrimary"
tools:progress="70" />
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout-w600dp-land/fragment_playback.xml b/app/src/main/res/layout-w600dp-land/fragment_playback_panel.xml
similarity index 99%
rename from app/src/main/res/layout-w600dp-land/fragment_playback.xml
rename to app/src/main/res/layout-w600dp-land/fragment_playback_panel.xml
index cdb2c3f24..8331a238f 100644
--- a/app/src/main/res/layout-w600dp-land/fragment_playback.xml
+++ b/app/src/main/res/layout-w600dp-land/fragment_playback_panel.xml
@@ -2,7 +2,7 @@
+ tools:context=".playback.PlaybackPanelFragment">
diff --git a/app/src/main/res/layout-w600dp/view_playback_bar.xml b/app/src/main/res/layout-w600dp/fragment_playback_bar.xml
similarity index 97%
rename from app/src/main/res/layout-w600dp/view_playback_bar.xml
rename to app/src/main/res/layout-w600dp/fragment_playback_bar.xml
index 9eb19e546..911ec3ff4 100644
--- a/app/src/main/res/layout-w600dp/view_playback_bar.xml
+++ b/app/src/main/res/layout-w600dp/fragment_playback_bar.xml
@@ -12,7 +12,7 @@
-
@@ -106,5 +106,5 @@
app:trackColor="?attr/colorPrimary"
tools:progress="70" />
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
index 0c0abdbf9..25ff81e59 100644
--- a/app/src/main/res/layout/fragment_main.xml
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -8,8 +8,8 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
-
@@ -21,7 +21,19 @@
app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" />
-
+
+
+
+
+
-
@@ -80,5 +80,5 @@
app:layout_constraintStart_toStartOf="parent"
tools:progress="70" />
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_playback.xml b/app/src/main/res/layout/fragment_playback_panel.xml
similarity index 99%
rename from app/src/main/res/layout/fragment_playback.xml
rename to app/src/main/res/layout/fragment_playback_panel.xml
index a3cdfc8b1..78225dcdb 100644
--- a/app/src/main/res/layout/fragment_playback.xml
+++ b/app/src/main/res/layout/fragment_playback_panel.xml
@@ -2,7 +2,7 @@
+ tools:context=".playback.PlaybackPanelFragment">
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
index c688d448a..edd273a2d 100644
--- a/app/src/main/res/values/ids.xml
+++ b/app/src/main/res/values/ids.xml
@@ -1,9 +1,7 @@
-
-
-
-
+
+
diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml
index 3038da668..c8205bc9a 100644
--- a/app/src/main/res/values/integers.xml
+++ b/app/src/main/res/values/integers.xml
@@ -2,7 +2,7 @@
150
-
+
- @string/set_theme_auto