From b849bd5c6f41d9233065fe98254c14e0a8e200ef Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:22:35 +0000 Subject: [PATCH] revert the now playing destination; drop the NavHost while the overlay is up - restore the now playing overlay (the destination had too much moving during the transition); while the overlay is open, omit the NavHost so it has no back callback to steal the back from the overlay's BackHandler - first disc header gets a little top padding (16dp), the rest keep 40dp - keep the fab glyph container 28dp so the filter dot aligns as before --- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 331 ++++++++++-------- .../subtracks/ui/library/AlbumDetailScreen.kt | 2 +- .../com/subtracks/ui/library/LibraryScreen.kt | 2 +- 3 files changed, 186 insertions(+), 149 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 87a848c9..bed4424c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -5,7 +5,6 @@ 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 @@ -92,8 +91,10 @@ 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" @@ -102,7 +103,6 @@ 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, @@ -140,16 +140,39 @@ 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 + } + } LaunchedEffect(Unit) { playbackController.connect() } + LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f } val playerVisible = playback.item != null - val onNowPlaying = currentRoute == Routes.NOW_PLAYING - val scrimAlpha by animateFloatAsState(if (onNowPlaying) 1f else 0f, tween(NAVIGATION_DURATION_MS), label = "scrim") + LaunchedEffect(playerVisible) { + if (!playerVisible) { + settleJob?.cancel() + nowPlayingOpen = false + nowPlayingProgress = 0f + } + } 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 && !onNowPlaying + val showMiniPlayer = playerVisible && !keyboardUp val bottomInset by animateDpAsState( targetValue = if (showMiniPlayer) 0.dp else navBarInset, animationSpec = tween(MINI_PLAYER_ANIM_MS), @@ -160,139 +183,113 @@ private fun MainNavigation() { ArtworkTheme(artwork) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().imePadding()) { - NavHost( - navController = navController, - modifier = - Modifier - .weight(1f) - .then( - if (playerVisible) { - Modifier.consumeWindowInsets(WindowInsets.navigationBars) - } else { - Modifier - }, - ), - startDestination = Routes.LIBRARY, - enterTransition = { - slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeIn(tween(NAVIGATION_DURATION_MS)) - }, - exitTransition = { - slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeOut(tween(NAVIGATION_DURATION_MS)) - }, - popEnterTransition = { - slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeIn(tween(NAVIGATION_DURATION_MS)) - }, - popExitTransition = { - slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + - fadeOut(tween(NAVIGATION_DURATION_MS)) - }, - ) { - composable(Routes.LIBRARY) { - LibraryRoute( - onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, - onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, - onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, - onOpenSettings = { navController.navigate(Routes.SETTINGS) }, - bottomInset = bottomInset, - ) - } - composable(Routes.SETTINGS) { - SettingsRoute( - onAddServer = { navController.navigate(Routes.ADD_SERVER) }, - onBack = { navController.popBackStack() }, - ) - } - composable( - route = Routes.ALBUM_DETAIL, - arguments = - listOf( - navArgument("albumId") { type = NavType.StringType }, - navArgument("coverArt") { - type = NavType.StringType - defaultValue = "" - }, - ), - ) { entry -> - AlbumDetailRoute( - albumId = entry.arguments?.getString("albumId").orEmpty(), - coverArtId = - entry.arguments - ?.getString("coverArt") - .orEmpty() - .ifEmpty { null }, - onBack = { navController.popBackStack() }, - ) - } - composable( - route = Routes.ARTIST_DETAIL, - arguments = - listOf( - navArgument("artistId") { type = NavType.StringType }, - navArgument("coverArt") { - type = NavType.StringType - defaultValue = "" - }, - ), - ) { entry -> - ArtistDetailRoute( - artistId = entry.arguments?.getString("artistId").orEmpty(), - coverArtId = - entry.arguments - ?.getString("coverArt") - .orEmpty() - .ifEmpty { null }, - onBack = { navController.popBackStack() }, - onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, - ) - } - composable( - route = Routes.PLAYLIST_DETAIL, - arguments = listOf(navArgument("playlistId") { type = NavType.StringType }), - ) { entry -> - PlaylistDetailRoute( - playlistId = entry.arguments?.getString("playlistId").orEmpty(), - onBack = { navController.popBackStack() }, - ) - } - composable(Routes.ADD_SERVER) { - AddSourceRoute( - onSaved = { navController.popBackStack() }, - 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( + Box(Modifier.weight(1f)) { + // The now playing overlay covers this, and while it is up we drop the NavHost so + // it cannot steal the back from the overlay's BackHandler. + if (!nowPlayingOpen) { + NavHost( + navController = navController, modifier = Modifier .fillMaxSize() - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { }, - onDragStopped = { velocity -> if (velocity > FLING_VELOCITY) navController.popBackStack() }, + .then( + if (playerVisible) { + Modifier.consumeWindowInsets(WindowInsets.navigationBars) + } else { + Modifier + }, ), + startDestination = Routes.LIBRARY, + enterTransition = { + slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeIn(tween(NAVIGATION_DURATION_MS)) + }, + exitTransition = { + slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeOut(tween(NAVIGATION_DURATION_MS)) + }, + popEnterTransition = { + slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeIn(tween(NAVIGATION_DURATION_MS)) + }, + popExitTransition = { + slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) + + fadeOut(tween(NAVIGATION_DURATION_MS)) + }, ) { - NowPlayingRoute( - onBack = { navController.popBackStack() }, - onQueue = { showingQueue = true }, - modifier = Modifier.fillMaxSize(), - ) + composable(Routes.LIBRARY) { + LibraryRoute( + onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, + onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, + onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, + onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + bottomInset = bottomInset, + ) + } + composable(Routes.SETTINGS) { + SettingsRoute( + onAddServer = { navController.navigate(Routes.ADD_SERVER) }, + onBack = { navController.popBackStack() }, + ) + } + composable( + route = Routes.ALBUM_DETAIL, + arguments = + listOf( + navArgument("albumId") { type = NavType.StringType }, + navArgument("coverArt") { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { entry -> + AlbumDetailRoute( + albumId = entry.arguments?.getString("albumId").orEmpty(), + coverArtId = + entry.arguments + ?.getString("coverArt") + .orEmpty() + .ifEmpty { null }, + onBack = { navController.popBackStack() }, + ) + } + composable( + route = Routes.ARTIST_DETAIL, + arguments = + listOf( + navArgument("artistId") { type = NavType.StringType }, + navArgument("coverArt") { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { entry -> + ArtistDetailRoute( + artistId = entry.arguments?.getString("artistId").orEmpty(), + coverArtId = + entry.arguments + ?.getString("coverArt") + .orEmpty() + .ifEmpty { null }, + onBack = { navController.popBackStack() }, + onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, + ) + } + composable( + route = Routes.PLAYLIST_DETAIL, + arguments = listOf(navArgument("playlistId") { type = NavType.StringType }), + ) { entry -> + PlaylistDetailRoute( + playlistId = entry.arguments?.getString("playlistId").orEmpty(), + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.ADD_SERVER) { + AddSourceRoute( + onSaved = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } } } } @@ -307,28 +304,65 @@ private fun MainNavigation() { state = playback, coverArt = miniArt, artwork = artwork, - onExpand = { navController.navigate(Routes.NOW_PLAYING) }, + onExpand = { settleNowPlaying(true) }, onPlayPause = playbackController::togglePlayPause, onNext = playbackController::next, progressInset = tabBarVisible, - onExpandRelease = { velocity -> if (velocity < -FLING_VELOCITY) navController.navigate(Routes.NOW_PLAYING) }, + 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 }, ) } } - 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), + 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) }, ), - ), - ) + ) { + 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, @@ -344,4 +378,7 @@ private fun MainNavigation() { } BackHandler(enabled = showingQueue) { showingQueue = false } + BackHandler(enabled = nowPlayingOpen && !showingQueue) { + settleNowPlaying(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 9f026d31..bb44498c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -153,7 +153,7 @@ private fun DiscHeader( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (first) 0.dp else 40.dp, bottom = 4.dp), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (first) 16.dp else 40.dp, bottom = 4.dp), ) { Icon( imageVector = Icons.Outlined.FiberSmartRecord, 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 862311e2..d6271b88 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -482,7 +482,7 @@ fun LibraryScreen( elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp), ) { - Box { + Box(Modifier.size(28.dp), contentAlignment = Alignment.Center) { ListOptionsGlyph( modifier = Modifier