diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index ed1b47c7a..742018420 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -76,6 +76,7 @@ class MainFragment : private var sheetBackCallback: SheetBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null + private var speedDialBackCallback: SpeedDialBackPressedCallback? = null private var selectionNavigationListener: DialogAwareNavigationListener? = null private var lastInsets: WindowInsets? = null private var elevationNormal = 0f @@ -109,6 +110,8 @@ class MainFragment : DetailBackPressedCallback(detailModel).also { detailBackCallback = it } val selectionBackCallback = SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } + val speedDialBackCallback = + SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it } selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) @@ -158,6 +161,7 @@ class MainFragment : collect(detailModel.toShow.flow, ::handleShow) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) + collectImmediately(homeModel.speedDialOpen, speedDialBackCallback::invalidateEnabled) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.openPanel.flow, ::handlePanel) @@ -181,6 +185,7 @@ class MainFragment : // navigation, navigation out of detail views, etc. We have to do this here in // onResume or otherwise the FragmentManager will have precedence. requireActivity().onBackPressedDispatcher.apply { + addCallback(viewLifecycleOwner, requireNotNull(speedDialBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback)) @@ -197,6 +202,7 @@ class MainFragment : override fun onDestroyBinding(binding: FragmentMainBinding) { super.onDestroyBinding(binding) + speedDialBackCallback = null sheetBackCallback = null detailBackCallback = null selectionBackCallback = null @@ -218,6 +224,13 @@ class MainFragment : binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f) + if (playbackRatio > 0f && homeModel.speedDialOpen.value) { + // Stupid hack to prevent you from sliding the sheet up without closing the speed + // dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed + // dial, which is super finicky behavior. + homeModel.setSpeedDialOpen(false) + } + val outPlaybackRatio = 1 - playbackRatio val halfOutRatio = min(playbackRatio * 2, 1f) val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2 @@ -493,4 +506,17 @@ class MainFragment : isEnabled = selection.isNotEmpty() } } + + private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) : + OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (homeModel.speedDialOpen.value) { + homeModel.setSpeedDialOpen(false) + } + } + + fun invalidateEnabled(open: Boolean) { + isEnabled = open + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index fca2bf924..aacf82749 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -149,14 +149,20 @@ class HomeFragment : binding.root.rootView.apply { // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate // it ourselves. + findViewById(R.id.main_scrim).setOnClickListener { + homeModel.setSpeedDialOpen(false) + } + findViewById(R.id.main_scrim).setOnTouchListener { _, event -> handleSpeedDialBoundaryTouch(event) - false + } + + findViewById(R.id.sheet_scrim).setOnClickListener { + homeModel.setSpeedDialOpen(false) } findViewById(R.id.sheet_scrim).setOnTouchListener { _, event -> handleSpeedDialBoundaryTouch(event) - false } } @@ -212,6 +218,7 @@ class HomeFragment : binding.homeNewPlaylistFab.apply { inflate(R.menu.new_playlist_actions) setOnActionSelectedListener(this@HomeFragment) + setChangeListener(homeModel::setSpeedDialOpen) } hideAllFabs() @@ -224,6 +231,7 @@ class HomeFragment : collect(homeModel.recreateTabs.flow, ::handleRecreate) collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab) + collect(homeModel.speedDialOpen, ::updateSpeedDial) collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) @@ -246,6 +254,7 @@ class HomeFragment : storagePermissionLauncher = null binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeNormalToolbar.setOnMenuItemClickListener(null) + binding.homeNewPlaylistFab.setChangeListener(null) binding.homeNewPlaylistFab.setOnActionSelectedListener(null) } @@ -577,8 +586,6 @@ class HomeFragment : return } - logD(binding.homeShuffleFab.isOrWillBeShown) - if (binding.homeShuffleFab.isOrWillBeShown) { logD("Animating transition") binding.homeShuffleFab.hide( @@ -606,12 +613,51 @@ class HomeFragment : } } - private fun handleSpeedDialBoundaryTouch(event: MotionEvent) { + private fun updateSpeedDial(open: Boolean) { val binding = requireBinding() - if (binding.homeNewPlaylistFab.isOpen && - !binding.homeNewPlaylistFab.isUnder(event.x, event.y)) { - binding.homeNewPlaylistFab.close() + + binding.root.rootView.apply { + // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate + // it ourselves. + findViewById(R.id.main_scrim).isClickable = open + findViewById(R.id.sheet_scrim).isClickable = open } + + if (open) { + binding.homeNewPlaylistFab.open(true) + } else { + binding.homeNewPlaylistFab.close(true) + } + } + + private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean { + val binding = binding ?: return false + + if (binding.homeNewPlaylistFab.isUnder(event.x, event.y)) { + // Convert absolute coordinates to relative coordinates + val offsetX = event.x - binding.homeNewPlaylistFab.x + val offsetY = event.y - binding.homeNewPlaylistFab.y + + // Create a new MotionEvent with relative coordinates + val relativeEvent = + MotionEvent.obtain( + event.downTime, + event.eventTime, + event.action, + offsetX, + offsetY, + event.metaState) + + // Dispatch the relative MotionEvent to the target child view + val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent) + + // Recycle the relative MotionEvent + relativeEvent.recycle() + + return handled + } + + return false } private fun handleShow(show: Show?) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index bb9311c84..51bc04976 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -156,6 +156,10 @@ constructor( /** A marker for whether the user is fast-scrolling in the home view or not. */ val isFastScrolling: StateFlow = _isFastScrolling + private val _speedDialOpen = MutableStateFlow(false) + /** A marker for whether the speed dial is open or not. */ + val speedDialOpen: StateFlow = _speedDialOpen + private val _showOuter = MutableEvent() val showOuter: Event get() = _showOuter @@ -293,6 +297,16 @@ constructor( _isFastScrolling.value = isFastScrolling } + /** + * Update whether the speed dial is open or not. + * + * @param speedDialOpen true if the speed dial is open, false otherwise. + */ + fun setSpeedDialOpen(speedDialOpen: Boolean) { + logD("Updating speed dial state: $speedDialOpen") + _speedDialOpen.value = speedDialOpen + } + fun showSettings() { _showOuter.put(Outer.Settings) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt index ab978eed4..1a9b5174d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -66,6 +66,7 @@ import org.oxycblt.auxio.util.getDimenPixels class ThemedSpeedDialView : SpeedDialView { private var mainFabAnimator: Animator? = null private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small) + private var innerChangeListener: ((Boolean) -> Unit)? = null constructor(context: Context) : super(context) @@ -126,6 +127,7 @@ class ThemedSpeedDialView : SpeedDialView { }) start() } + innerChangeListener?.invoke(isOpen) } }) } @@ -178,8 +180,6 @@ class ThemedSpeedDialView : SpeedDialView { val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary) val labelBackgroundColor = context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface) - val labelStroke = - context.getAttrColorCompat(com.google.android.material.R.attr.colorOutline) val labelElevation = context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation) val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium) @@ -237,6 +237,10 @@ class ThemedSpeedDialView : SpeedDialView { } } + fun setChangeListener(listener: ((Boolean) -> Unit)?) { + innerChangeListener = listener + } + companion object { private val VIEW_PROPERTY_BACKGROUND_TINT = object : Property(Int::class.java, "backgroundTint") { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 97a8fd8f0..6140261a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -224,9 +224,9 @@ constructor( * @param playlist The [Playlist] to rename, * @param name The new name of the [Playlist]. If null, the user will be prompted for a name. * @param applySongs The songs to apply to the playlist after renaming. If empty, no songs will - * be applied. This argument is internal and does not need to be specified in normal use. + * be applied. This argument is internal and does not need to be specified in normal use. * @param reason The reason why the playlist is being renamed. This argument is internal and - * does not need to be specified in normal use. + * does not need to be specified in normal use. */ fun renamePlaylist( playlist: Playlist,