From f79da367dc4e73eabef9d0f98343f8948f266393 Mon Sep 17 00:00:00 2001 From: christopher Date: Thu, 17 Sep 2026 04:40:36 -0400 Subject: [PATCH] Fix mini-player disappearing on detail screens; polish Now Playing UI The user noticed the mini-player ribbon vanished whenever navigating into an artist/album/settings screen, and asked for the full-player transport controls to stay anchored to a fixed spot and for long titles to marquee like Spotify instead of wrapping. - Mini-player was owned by MainScreen's own Scaffold, so it unmounted entirely on any sibling nav destination (ArtistDetail/AlbumDetail/ Settings). Hoisted it to DeepwaveNavHost, wrapping the whole authenticated NavHost in an outer Scaffold whose bottomBar shows the mini-player on every route except Login and NowPlaying itself. MainScreen no longer needs PlayerViewModel at all as a result - reverted it to a single previewable composable. - That hoist exposed a real inset bug: MiniPlayerBar is a plain Row, not a Material3 NavigationBar/BottomAppBar, so it doesn't handle system-bar insets on its own - it only ever looked right because it sat above the inset-aware BottomNavBar in the same Column. Standing alone, it rendered underneath the gesture nav bar. Fixed with an explicit navigationBarsPadding(). - NowPlayingScreen: the artwork/title/artist block now gets weight(1f) in the Column, so the seek bar and transport controls always land at a fixed distance from the bottom regardless of title length - previously a long title wrapped to 2-3 lines and pushed the controls down inconsistently. - Long titles now use maxLines = 1 + Modifier.basicMarquee() instead of wrapping, on both NowPlayingScreen and MiniPlayerBar's title. Fixed navController.currentBackStackEntryAsState()-based route comparison to use route-string equality rather than the hasRoute() generic (that API isn't available in navigation-compose 2.9.8, this project's pinned version - only the KClass-less String overload exists here). Verified on-device: mini-player now persists correctly through Home -> Album Detail -> Now Playing and back, sits above the system nav bar, and the transport controls stay in the same position across tracks with short and long titles. Co-Authored-By: Claude Sonnet 5 --- .../deepwave/ui/components/MiniPlayerBar.kt | 20 +++ .../deepwave/ui/navigation/DeepwaveNavHost.kt | 119 +++++++++++------- .../deepwave/ui/navigation/MainScreen.kt | 63 ++-------- .../deepwave/ui/player/NowPlayingScreen.kt | 75 ++++++++--- 4 files changed, 168 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt index 320800d..c8f318d 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt @@ -2,10 +2,12 @@ package com.InfernalAquatics.deepwave.ui.components import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause @@ -38,6 +40,7 @@ fun MiniPlayerBar( modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant) + .navigationBarsPadding() .clickable(enabled = title != null, onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -53,6 +56,9 @@ fun MiniPlayerBar( style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .basicMarquee(), ) if (subtitle != null) { Text( @@ -88,3 +94,17 @@ private fun MiniPlayerBarPlayingPreview() { MiniPlayerBar(title = "Sample Track", subtitle = "Sample Artist", artworkUrl = null, isPlaying = true, onTogglePlayPause = {}) } } + +@Preview(name = "Long title", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MiniPlayerBarLongTitlePreview() { + DeepwaveTheme { + MiniPlayerBar( + title = "Riddim March [Forthcoming Blacklight Audio Extended Remix Edit]", + subtitle = "TOOG", + artworkUrl = null, + isPlaying = true, + onTogglePlayPause = {}, + ) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt index 0bf6516..44d976a 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -2,7 +2,9 @@ package com.InfernalAquatics.deepwave.ui.navigation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -13,18 +15,28 @@ import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen import com.InfernalAquatics.deepwave.ui.login.LoginScreen import com.InfernalAquatics.deepwave.ui.player.NowPlayingScreen +import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen +/** + * Hosts the mini-player bar itself, above the NavHost content rather than inside any one + * screen - it needs to stay visible across Main/Settings/ArtistDetail/AlbumDetail (everywhere + * except the full-screen NowPlaying player and the pre-login state), which none of those + * screens' own Scaffolds could give it on their own since they're sibling nav destinations. + */ @UnstableApi @Composable fun DeepwaveNavHost( navController: NavHostController = rememberNavController(), sessionViewModel: SessionViewModel = hiltViewModel(), + playerViewModel: PlayerViewModel = hiltViewModel(), ) { val isLoggedIn by sessionViewModel.isLoggedIn.collectAsState() @@ -32,48 +44,71 @@ fun DeepwaveNavHost( null -> Box(modifier = Modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } - else -> NavHost( - navController = navController, - startDestination = if (isLoggedIn == true) Route.Main else Route.Login, - ) { - composable { - LoginScreen( - onLoginSuccess = { - navController.navigate(Route.Main) { - popUpTo { inclusive = true } - } - }, - ) - } - composable { - MainScreen( - onOpenSettings = { navController.navigate(Route.Settings) }, - onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) }, - onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, - onOpenNowPlaying = { navController.navigate(Route.NowPlaying) }, - ) - } - composable { - SettingsScreen( - onBack = { navController.popBackStack() }, - onLoggedOut = { - navController.navigate(Route.Login) { - popUpTo { inclusive = true } - } - }, - ) - } - composable { - ArtistDetailScreen( - onBack = { navController.popBackStack() }, - onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, - ) - } - composable { - AlbumDetailScreen(onBack = { navController.popBackStack() }) - } - composable { - NowPlayingScreen(onBack = { navController.popBackStack() }) + else -> { + val playbackUiState by playerViewModel.uiState.collectAsState() + val currentDestination by navController.currentBackStackEntryAsState() + val showMiniPlayer = currentDestination?.destination?.route?.let { route -> + route != Route.Login::class.qualifiedName && route != Route.NowPlaying::class.qualifiedName + } ?: false + + Scaffold( + bottomBar = { + if (showMiniPlayer) { + MiniPlayerBar( + title = playbackUiState.nowPlaying?.title, + subtitle = playbackUiState.nowPlaying?.artist, + artworkUrl = playbackUiState.nowPlaying?.artworkUri, + isPlaying = playbackUiState.isPlaying, + onTogglePlayPause = playerViewModel::togglePlayPause, + onClick = { navController.navigate(Route.NowPlaying) }, + ) + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = if (isLoggedIn == true) Route.Main else Route.Login, + modifier = Modifier.padding(innerPadding), + ) { + composable { + LoginScreen( + onLoginSuccess = { + navController.navigate(Route.Main) { + popUpTo { inclusive = true } + } + }, + ) + } + composable { + MainScreen( + onOpenSettings = { navController.navigate(Route.Settings) }, + onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) }, + onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, + ) + } + composable { + SettingsScreen( + onBack = { navController.popBackStack() }, + onLoggedOut = { + navController.navigate(Route.Login) { + popUpTo { inclusive = true } + } + }, + ) + } + composable { + ArtistDetailScreen( + onBack = { navController.popBackStack() }, + onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, + ) + } + composable { + AlbumDetailScreen(onBack = { navController.popBackStack() }) + } + composable { + NowPlayingScreen(onBack = { navController.popBackStack() }) + } + } } } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt index a325a2e..39f2318 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt @@ -2,7 +2,6 @@ package com.InfernalAquatics.deepwave.ui.navigation import android.content.res.Configuration import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -14,7 +13,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,50 +20,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.media3.common.util.UnstableApi import com.InfernalAquatics.deepwave.R -import com.InfernalAquatics.deepwave.media.PlaybackUiState import com.InfernalAquatics.deepwave.ui.components.BottomNavBar import com.InfernalAquatics.deepwave.ui.components.BottomTab -import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen import com.InfernalAquatics.deepwave.ui.library.HomeScreen import com.InfernalAquatics.deepwave.ui.library.SearchScreen -import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme -/** Resolves the real player state, then hands off to the previewable [MainContent]. */ -@UnstableApi +/** + * Hosts the bottom-tab shell (Home / Search / Library) and settings entry point. The + * mini-player bar is NOT owned here - it's hoisted to [DeepwaveNavHost] so it stays visible + * when navigating to sibling routes like ArtistDetail/AlbumDetail/Settings, which this + * screen's own Scaffold could never give it. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( onOpenSettings: () -> Unit, onOpenArtist: (String) -> Unit, onOpenAlbum: (String) -> Unit, - onOpenNowPlaying: () -> Unit, - playerViewModel: PlayerViewModel = hiltViewModel(), -) { - val playbackUiState by playerViewModel.uiState.collectAsState() - MainContent( - playbackUiState = playbackUiState, - onOpenSettings = onOpenSettings, - onOpenArtist = onOpenArtist, - onOpenAlbum = onOpenAlbum, - onOpenNowPlaying = onOpenNowPlaying, - onTogglePlayPause = playerViewModel::togglePlayPause, - ) -} - -/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MainContent( - playbackUiState: PlaybackUiState, - onOpenSettings: () -> Unit, - onOpenArtist: (String) -> Unit, - onOpenAlbum: (String) -> Unit, - onOpenNowPlaying: () -> Unit, - onTogglePlayPause: () -> Unit, ) { var selectedTab by remember { mutableStateOf(BottomTab.Home) } @@ -81,17 +55,7 @@ private fun MainContent( ) }, bottomBar = { - Column { - MiniPlayerBar( - title = playbackUiState.nowPlaying?.title, - subtitle = playbackUiState.nowPlaying?.artist, - artworkUrl = playbackUiState.nowPlaying?.artworkUri, - isPlaying = playbackUiState.isPlaying, - onTogglePlayPause = onTogglePlayPause, - onClick = onOpenNowPlaying, - ) - BottomNavBar(selectedTab = selectedTab, onTabSelected = { selectedTab = it }) - } + BottomNavBar(selectedTab = selectedTab, onTabSelected = { selectedTab = it }) }, ) { innerPadding -> Box( @@ -111,15 +75,8 @@ private fun MainContent( /** Interactive in Android Studio's preview pane — try tapping the tabs and the settings icon. */ @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891) @Composable -private fun MainContentPreview() { +private fun MainScreenPreview() { DeepwaveTheme { - MainContent( - playbackUiState = PlaybackUiState(), - onOpenSettings = {}, - onOpenArtist = {}, - onOpenAlbum = {}, - onOpenNowPlaying = {}, - onTogglePlayPause = {}, - ) + MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt index 9971b55..f3a1243 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt @@ -1,6 +1,7 @@ package com.InfernalAquatics.deepwave.ui.player import android.content.res.Configuration +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -86,29 +87,49 @@ private fun NowPlayingContent( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(24.dp), + .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Artwork(imageUrl = uiState.nowPlaying?.artworkUri, size = 280.dp, modifier = Modifier.padding(top = 24.dp)) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = uiState.nowPlaying?.title ?: "Nothing playing", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - uiState.nowPlaying?.artist?.let { + // Artwork/title/artist float in whatever space is left above the controls, so a + // wrapped or marqueed title never pushes the transport controls around - those + // stay anchored to a fixed distance from the bottom regardless of title length. + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Artwork(imageUrl = uiState.nowPlaying?.artworkUri, size = 280.dp) + Spacer(modifier = Modifier.height(32.dp)) Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = uiState.nowPlaying?.title ?: "Nothing playing", + style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .basicMarquee(), ) + uiState.nowPlaying?.artist?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .basicMarquee(), + ) + } } - Spacer(modifier = Modifier.height(24.dp)) SeekBar(positionMs = uiState.positionMs, durationMs = uiState.durationMs, onSeek = onSeek) Spacer(modifier = Modifier.height(16.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -179,3 +200,29 @@ private fun NowPlayingContentPreview() { ) } } + +/** Demonstrates that a long title marquees on one line instead of wrapping, and that the controls stay put regardless. */ +@Preview(name = "Long title", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891) +@Composable +private fun NowPlayingContentLongTitlePreview() { + DeepwaveTheme { + NowPlayingContent( + uiState = PlaybackUiState( + nowPlaying = NowPlaying( + mediaId = "subsonic:song:2", + title = "Riddim March [Forthcoming Blacklight Audio Extended Remix Edit]", + artist = "TOOG", + artworkUri = null, + ), + isPlaying = true, + positionMs = 15_000, + durationMs = 109_000, + ), + onBack = {}, + onTogglePlayPause = {}, + onSeek = {}, + onSkipNext = {}, + onSkipPrevious = {}, + ) + } +}