diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 8fbf4fb2..87a848c9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -1,11 +1,11 @@ package com.subtracks.ui import android.net.Uri -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -74,7 +73,6 @@ import com.subtracks.ui.settings.SettingsRoute import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.rememberArtworkColors import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -94,10 +92,8 @@ class RootViewModel( private const val NAVIGATION_DURATION_MS = 260 private const val OVERLAY_DURATION_MS = 200 -private const val EXPAND_FADE = 0.1f private const val FLING_VELOCITY = 1000f private const val MINI_PLAYER_ANIM_MS = 200 -private const val SCRIM_FADE_START = 0.85f private object Routes { const val LIBRARY = "library" @@ -106,6 +102,7 @@ private object Routes { const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}" const val PLAYLIST_DETAIL = "playlist/{playlistId}" + const val NOW_PLAYING = "now-playing" fun album( id: String, @@ -143,63 +140,16 @@ private fun MainNavigation() { val playbackController = koinInject() val playback by playbackController.state.collectAsStateWithLifecycle() var showingQueue by rememberSaveable { mutableStateOf(false) } - var nowPlayingOpen by rememberSaveable { mutableStateOf(false) } - var nowPlayingProgress by remember { mutableFloatStateOf(0f) } - var miniPlayerTopPx by remember { mutableFloatStateOf(0f) } - var settleJob by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - fun settleNowPlaying(open: Boolean) { - if (open) nowPlayingOpen = true - settleJob?.cancel() - settleJob = - scope.launch { - animate(nowPlayingProgress, if (open) 1f else 0f, animationSpec = tween(OVERLAY_DURATION_MS)) { value, _ -> - nowPlayingProgress = value - } - if (!open) nowPlayingOpen = false - } - } - // The NavHost registers its back callback from a LaunchedEffect, so a BackHandler composed - // before it can lose the back to the NavHost (which would pop the route under the overlay). - // Register ours from a LaunchedEffect after the NavHost so it lands later and wins. - val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - val overlayBack = - remember { - object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - when { - showingQueue -> showingQueue = false - nowPlayingOpen -> settleNowPlaying(false) - } - } - } - } - SideEffect { overlayBack.isEnabled = showingQueue || nowPlayingOpen } - LaunchedEffect(backDispatcher, overlayBack) { - backDispatcher?.addCallback(overlayBack) - try { - awaitCancellation() - } finally { - overlayBack.remove() - } - } LaunchedEffect(Unit) { playbackController.connect() } - LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f } val playerVisible = playback.item != null - LaunchedEffect(playerVisible) { - if (!playerVisible) { - settleJob?.cancel() - nowPlayingOpen = false - nowPlayingProgress = 0f - } - } + val onNowPlaying = currentRoute == Routes.NOW_PLAYING + val scrimAlpha by animateFloatAsState(if (onNowPlaying) 1f else 0f, tween(NAVIGATION_DURATION_MS), label = "scrim") val density = LocalDensity.current val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val statusBarInset = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0 - val showMiniPlayer = playerVisible && !keyboardUp + val showMiniPlayer = playerVisible && !keyboardUp && !onNowPlaying val bottomInset by animateDpAsState( targetValue = if (showMiniPlayer) 0.dp else navBarInset, animationSpec = tween(MINI_PLAYER_ANIM_MS), @@ -313,6 +263,38 @@ private fun MainNavigation() { onBack = { navController.popBackStack() }, ) } + composable( + route = Routes.NOW_PLAYING, + enterTransition = { + slideInVertically(initialOffsetY = { it }, animationSpec = tween(NAVIGATION_DURATION_MS)) + }, + exitTransition = { + slideOutVertically(targetOffsetY = { it }, animationSpec = tween(NAVIGATION_DURATION_MS)) + }, + popEnterTransition = { + slideInVertically(initialOffsetY = { it }, animationSpec = tween(NAVIGATION_DURATION_MS)) + }, + popExitTransition = { + slideOutVertically(targetOffsetY = { it }, animationSpec = tween(NAVIGATION_DURATION_MS)) + }, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { }, + onDragStopped = { velocity -> if (velocity > FLING_VELOCITY) navController.popBackStack() }, + ), + ) { + NowPlayingRoute( + onBack = { navController.popBackStack() }, + onQueue = { showingQueue = true }, + modifier = Modifier.fillMaxSize(), + ) + } + } } AnimatedVisibility( @@ -325,65 +307,28 @@ private fun MainNavigation() { state = playback, coverArt = miniArt, artwork = artwork, - onExpand = { settleNowPlaying(true) }, + onExpand = { navController.navigate(Routes.NOW_PLAYING) }, onPlayPause = playbackController::togglePlayPause, onNext = playbackController::next, progressInset = tabBarVisible, - onExpandDrag = { dragUpPx -> - nowPlayingOpen = true - settleJob?.cancel() - if (miniPlayerTopPx > 0f) { - nowPlayingProgress = (dragUpPx / miniPlayerTopPx).coerceIn(0f, 1f) - } - }, - onExpandRelease = { velocity -> settleNowPlaying(nowPlayingProgress > 0.05f || velocity < -FLING_VELOCITY) }, - modifier = Modifier.onGloballyPositioned { miniPlayerTopPx = it.positionInRoot().y }, + onExpandRelease = { velocity -> if (velocity < -FLING_VELOCITY) navController.navigate(Routes.NOW_PLAYING) }, ) } } - if (nowPlayingOpen) { - Box( - modifier = - Modifier - .fillMaxSize() - .graphicsLayer { - translationY = (1f - nowPlayingProgress) * miniPlayerTopPx - alpha = (nowPlayingProgress / EXPAND_FADE).coerceAtMost(1f) - }.draggable( - orientation = Orientation.Vertical, - state = - rememberDraggableState { delta -> - settleJob?.cancel() - if (miniPlayerTopPx > 0f) { - nowPlayingProgress = (nowPlayingProgress - delta / miniPlayerTopPx).coerceIn(0f, 1f) - } - }, - onDragStopped = { velocity -> settleNowPlaying(nowPlayingProgress > 0.9f && velocity < FLING_VELOCITY) }, + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarInset + 8.dp) + .graphicsLayer { alpha = scrimAlpha } + .background( + Brush.verticalGradient( + listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), ), - ) { - NowPlayingRoute( - onBack = { settleNowPlaying(false) }, - onQueue = { showingQueue = true }, - modifier = Modifier.fillMaxSize(), - ) - } - - Box( - modifier = - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarInset + 8.dp) - .graphicsLayer { - alpha = ((nowPlayingProgress - SCRIM_FADE_START) / (1f - SCRIM_FADE_START)).coerceIn(0f, 1f) - }.background( - Brush.verticalGradient( - listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), - ), - ), - ) - } + ), + ) AnimatedVisibility( visible = showingQueue, @@ -397,4 +342,6 @@ private fun MainNavigation() { } } } + + BackHandler(enabled = showingQueue) { showingQueue = false } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 2333224a..9f026d31 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -128,7 +128,8 @@ fun AlbumDetailScreen( val disc = song.disc ?: 1L val discLabel = discLabels[disc] if (disc != lastDisc && (multiDisc || discLabel != null)) { - item(key = "disc:$disc") { DiscHeader(discLabel ?: "Disc $disc", first = lastDisc == null) } + val isFirst = lastDisc == null + item(key = "disc:$disc") { DiscHeader(discLabel ?: "Disc $disc", first = isFirst) } } lastDisc = disc item(key = song.id) { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 7f00f9e5..862311e2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -486,7 +486,7 @@ fun LibraryScreen( ListOptionsGlyph( modifier = Modifier - .size(26.dp) + .size(24.dp) .semantics { contentDescription = "List options" }, ) if (filtersActive) { diff --git a/app/src/test/kotlin/com/subtracks/ui/NowPlayingBackTest.kt b/app/src/test/kotlin/com/subtracks/ui/NowPlayingBackTest.kt deleted file mode 100644 index 21a1d7dd..00000000 --- a/app/src/test/kotlin/com/subtracks/ui/NowPlayingBackTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.subtracks.ui - -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers -import kotlinx.coroutines.awaitCancellation -import org.junit.Assert.assertFalse -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(AndroidJUnit4::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) -class NowPlayingBackTest { - @get:Rule - val composeRule = createAndroidComposeRule() - - @Test - fun backClosesTheOverlayBeforePoppingTheDestination() { - var overlayOpen by mutableStateOf(false) - var navController: NavHostController? = null - composeRule.setContent { - val nav = rememberNavController() - navController = nav - val dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher - val overlayBackCallback = - remember { - object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - overlayOpen = false - } - } - } - SideEffect { overlayBackCallback.isEnabled = overlayOpen } - Box(Modifier.fillMaxSize()) { - NavHost( - navController = nav, - startDestination = "a", - enterTransition = { - slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(260)) + - fadeIn(tween(260)) - }, - exitTransition = { - slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(260)) + - fadeOut(tween(260)) - }, - popEnterTransition = { - slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(260)) + - fadeIn(tween(260)) - }, - popExitTransition = { - slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(260)) + - fadeOut(tween(260)) - }, - ) { - composable("a") { Text("A") } - composable("b") { Text("B") } - } - if (overlayOpen) { - Text("OVERLAY", modifier = Modifier.fillMaxSize()) - } - } - // The NavHost registers its back callback from a LaunchedEffect, so the overlay must - // register its own from a LaunchedEffect after it to land later in the dispatcher. - LaunchedEffect(dispatcher, overlayBackCallback) { - dispatcher.addCallback(overlayBackCallback) - try { - awaitCancellation() - } finally { - overlayBackCallback.remove() - } - } - } - - composeRule.runOnIdle { navController!!.navigate("b") } - composeRule.onNodeWithText("B").assertExists() - composeRule.runOnIdle { overlayOpen = true } - composeRule.onNodeWithText("OVERLAY").assertExists() - - composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } - composeRule.waitForIdle() - - assertFalse("the overlay should have closed", overlayOpen) - composeRule.onNodeWithText("B").assertExists() - } -}