/* * 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 import android.os.Bundle import android.view.LayoutInflater import android.view.ViewTreeObserver import android.view.WindowInsets import androidx.activity.OnBackPressedCallback import androidx.core.view.ViewCompat import androidx.core.view.isInvisible import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.NeoBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.transition.MaterialFadeThrough import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueSheetBehavior import org.oxycblt.auxio.playback.PlaybackSheetBehavior import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.fragment.ViewBindingFragment import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.unlikelyToBeNull import kotlin.math.max import kotlin.math.min /** * A wrapper around the home fragment that shows the playback fragment and controls the more * high-level navigation features. * @author OxygenCobalt */ class MainFragment : ViewBindingFragment(), ViewTreeObserver.OnPreDrawListener { private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val navModel: NavigationViewModel by activityViewModels() private val callback = DynamicBackPressedCallback() private var lastInsets: WindowInsets? = null private val elevationNormal: Float by lifecycleObject { binding -> binding.context.getDimen(R.dimen.elevation_normal) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialFadeThrough() exitTransition = MaterialFadeThrough() } override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { // --- UI SETUP --- val context = requireActivity() context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) binding.root.setOnApplyWindowInsetsListener { _, insets -> lastInsets = insets insets } // Send meaningful accessibility events for bottom sheets ViewCompat.setAccessibilityPaneTitle( binding.playbackSheet, context.getString(R.string.lbl_playback) ) ViewCompat.setAccessibilityPaneTitle( binding.queueSheet, context.getString(R.string.lbl_queue) ) val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? if (queueSheetBehavior != null) { val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior unlikelyToBeNull(binding.handleWrapper).setOnClickListener { if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED && queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED ) { queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED } } } else { // Dual-pane mode, color/pad the queue sheet manually. binding.queueSheet.apply { background = MaterialShapeDrawable.createWithElevationOverlay(context).apply { fillColor = context.getAttrColorCompat(R.attr.colorSurface) elevation = context.getDimen(R.dimen.elevation_normal) } setOnApplyWindowInsetsListener { v, insets -> v.updatePadding(top = insets.systemBarInsetsCompat.top) insets } } } // --- VIEWMODEL SETUP --- collect(navModel.mainNavigationAction, ::handleMainNavigation) collect(navModel.exploreNavigationItem, ::handleExploreNavigation) collectImmediately(playbackModel.song, ::updateSong) } override fun onStart() { super.onStart() // Callback could still reasonably fire even if we clear the binding, attach/detach // our pre-draw listener our listener in onStart/onStop respectively. requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this) } override fun onStop() { super.onStop() requireBinding().playbackSheet.viewTreeObserver.removeOnPreDrawListener(this) } override fun onPreDraw(): Boolean { // CoordinatorLayout is insane and thus makes bottom sheet callbacks insane. Do our // checks before every draw, which is not ideal in the slightest but also has minimal // performance impact since we are only mutating attributes used during drawing. val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f) val outPlaybackRatio = 1 - playbackRatio val halfOutRatio = min(playbackRatio * 2, 1f) val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2 val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? if (queueSheetBehavior != null) { // Queue sheet, take queue into account so the playback bar is shown and the playback // panel is hidden when the queue sheet is expanded. val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f) val halfOutQueueRatio = min(queueRatio * 2, 1f) val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2 binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio) binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio) binding.queueFragment.alpha = queueRatio if (playbackModel.song.value != null) { // Hack around the playback sheet intercepting swipe events on the queue bar playbackSheetBehavior.isDraggable = queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED } } else { // No queue sheet, fade normally based on the playback sheet binding.playbackBarFragment.alpha = 1 - halfOutRatio binding.playbackPanelFragment.alpha = halfInPlaybackRatio } binding.exploreNavHost.apply { alpha = outPlaybackRatio isInvisible = alpha == 0f } binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt() binding.playbackBarFragment.apply { isInvisible = alpha == 0f lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio } } binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f binding.queueSheet.apply { alpha = halfInPlaybackRatio binding.queueSheet.isInvisible = alpha == 0f } binding.queueFragment.isInvisible = binding.queueFragment.alpha == 0f if (playbackModel.song.value == null) { // Sometimes lingering drags can un-hide the playback sheet even when we intend to // hide it, make sure we keep it hidden. tryHideAll() } // Since the callback is also reliant on the bottom sheets, we must also update it // every frame. callback.updateEnabledState() return true } private fun updateSong(song: Song?) { if (song != null) { tryUnhideAll() } else { tryHideAll() } } private fun handleMainNavigation(action: MainNavigationAction?) { if (action == null) return when (action) { is MainNavigationAction.Expand -> tryExpandAll() is MainNavigationAction.Collapse -> tryCollapseAll() is MainNavigationAction.Directions -> findNavController().navigate(action.directions) } navModel.finishMainNavigation() } private fun handleExploreNavigation(item: Music?) { if (item != null) { tryCollapseAll() } } private fun tryExpandAll() { val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { // State is collapsed and non-hidden, expand playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED } } private fun tryCollapseAll() { val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { // Make sure the queue is also collapsed here. val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED } } private fun tryUnhideAll() { val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? // Queue sheet behavior is either collapsed or expanded, no hiding needed queueSheetBehavior?.isDraggable = true playbackSheetBehavior.apply { // Make sure the view is draggable, at least until the draw checks kick in. isDraggable = true state = NeoBottomSheetBehavior.STATE_COLLAPSED } } } private fun tryHideAll() { val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? // Make these views non-draggable so the user can't halt the hiding event. queueSheetBehavior?.apply { isDraggable = false state = NeoBottomSheetBehavior.STATE_COLLAPSED } playbackSheetBehavior.apply { isDraggable = false state = NeoBottomSheetBehavior.STATE_HIDDEN } } } /** * A back press callback that handles how to respond to backwards navigation in the detail * fragments and the playback panel. */ private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) { override fun handleOnBackPressed() { val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? if (queueSheetBehavior != null && queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED ) { // Collapse the queue first if it is expanded. queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED return } if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN ) { // Then collapse the playback sheet. playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED return } binding.exploreNavHost.findNavController().navigateUp() } fun updateEnabledState() { val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? val exploreNavController = binding.exploreNavHost.findNavController() isEnabled = playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED || queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED || exploreNavController.currentDestination?.id != exploreNavController.graph.startDestinationId } } }