make now playing a NavHost destination

The overlay fought the NavHost over the back: OnBackPressedDispatcher hands the back
to the last-registered enabled callback, and the NavHost registers its own as part of
its composition, so it intermittently popped the route underneath. A destination lets
the back stack own it.

- add Routes.NOW_PLAYING with a vertical slide, opened by tapping the mini player or
  flicking it up; back or a downward fling pops it
- hide the mini player while on the now-playing route so it fills the screen
- the static scrim now follows the route instead of a drag progress
- drop the nowPlayingOpen/progress overlay state and its test
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent fd37c3ad86
commit e8db675470
4 changed files with 57 additions and 221 deletions
+44 -97
View File
@@ -1,11 +1,11 @@
package com.subtracks.ui package com.subtracks.ui
import android.net.Uri import android.net.Uri
import androidx.activity.OnBackPressedCallback import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf 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.ArtworkTheme
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -94,10 +92,8 @@ class RootViewModel(
private const val NAVIGATION_DURATION_MS = 260 private const val NAVIGATION_DURATION_MS = 260
private const val OVERLAY_DURATION_MS = 200 private const val OVERLAY_DURATION_MS = 200
private const val EXPAND_FADE = 0.1f
private const val FLING_VELOCITY = 1000f private const val FLING_VELOCITY = 1000f
private const val MINI_PLAYER_ANIM_MS = 200 private const val MINI_PLAYER_ANIM_MS = 200
private const val SCRIM_FADE_START = 0.85f
private object Routes { private object Routes {
const val LIBRARY = "library" const val LIBRARY = "library"
@@ -106,6 +102,7 @@ private object Routes {
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}" const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
const val PLAYLIST_DETAIL = "playlist/{playlistId}" const val PLAYLIST_DETAIL = "playlist/{playlistId}"
const val NOW_PLAYING = "now-playing"
fun album( fun album(
id: String, id: String,
@@ -143,63 +140,16 @@ private fun MainNavigation() {
val playbackController = koinInject<PlaybackController>() val playbackController = koinInject<PlaybackController>()
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
var showingQueue by rememberSaveable { mutableStateOf(false) } 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<Job?>(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) { playbackController.connect() }
LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f }
val playerVisible = playback.item != null val playerVisible = playback.item != null
LaunchedEffect(playerVisible) { val onNowPlaying = currentRoute == Routes.NOW_PLAYING
if (!playerVisible) { val scrimAlpha by animateFloatAsState(if (onNowPlaying) 1f else 0f, tween(NAVIGATION_DURATION_MS), label = "scrim")
settleJob?.cancel()
nowPlayingOpen = false
nowPlayingProgress = 0f
}
}
val density = LocalDensity.current val density = LocalDensity.current
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val statusBarInset = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val statusBarInset = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0 val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0
val showMiniPlayer = playerVisible && !keyboardUp val showMiniPlayer = playerVisible && !keyboardUp && !onNowPlaying
val bottomInset by animateDpAsState( val bottomInset by animateDpAsState(
targetValue = if (showMiniPlayer) 0.dp else navBarInset, targetValue = if (showMiniPlayer) 0.dp else navBarInset,
animationSpec = tween(MINI_PLAYER_ANIM_MS), animationSpec = tween(MINI_PLAYER_ANIM_MS),
@@ -313,6 +263,38 @@ private fun MainNavigation() {
onBack = { 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(
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( AnimatedVisibility(
@@ -325,65 +307,28 @@ private fun MainNavigation() {
state = playback, state = playback,
coverArt = miniArt, coverArt = miniArt,
artwork = artwork, artwork = artwork,
onExpand = { settleNowPlaying(true) }, onExpand = { navController.navigate(Routes.NOW_PLAYING) },
onPlayPause = playbackController::togglePlayPause, onPlayPause = playbackController::togglePlayPause,
onNext = playbackController::next, onNext = playbackController::next,
progressInset = tabBarVisible, progressInset = tabBarVisible,
onExpandDrag = { dragUpPx -> onExpandRelease = { velocity -> if (velocity < -FLING_VELOCITY) navController.navigate(Routes.NOW_PLAYING) },
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 },
) )
} }
} }
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( Box(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.fillMaxWidth() .fillMaxWidth()
.height(statusBarInset + 8.dp) .height(statusBarInset + 8.dp)
.graphicsLayer { .graphicsLayer { alpha = scrimAlpha }
alpha = ((nowPlayingProgress - SCRIM_FADE_START) / (1f - SCRIM_FADE_START)).coerceIn(0f, 1f) .background(
}.background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent),
), ),
), ),
) )
}
AnimatedVisibility( AnimatedVisibility(
visible = showingQueue, visible = showingQueue,
@@ -397,4 +342,6 @@ private fun MainNavigation() {
} }
} }
} }
BackHandler(enabled = showingQueue) { showingQueue = false }
} }
@@ -128,7 +128,8 @@ fun AlbumDetailScreen(
val disc = song.disc ?: 1L val disc = song.disc ?: 1L
val discLabel = discLabels[disc] val discLabel = discLabels[disc]
if (disc != lastDisc && (multiDisc || discLabel != null)) { 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 lastDisc = disc
item(key = song.id) { item(key = song.id) {
@@ -486,7 +486,7 @@ fun LibraryScreen(
ListOptionsGlyph( ListOptionsGlyph(
modifier = modifier =
Modifier Modifier
.size(26.dp) .size(24.dp)
.semantics { contentDescription = "List options" }, .semantics { contentDescription = "List options" },
) )
if (filtersActive) { if (filtersActive) {
@@ -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<ComponentActivity>()
@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()
}
}