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