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<T>()
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 <noreply@anthropic.com>
This commit is contained in:
christopher
2026-09-17 04:40:36 -04:00
co-authored by Claude Sonnet 5
parent cf52bb854e
commit f79da367dc
4 changed files with 168 additions and 109 deletions
@@ -2,10 +2,12 @@ package com.InfernalAquatics.deepwave.ui.components
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
@@ -38,6 +40,7 @@ fun MiniPlayerBar(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.navigationBarsPadding()
.clickable(enabled = title != null, onClick = onClick) .clickable(enabled = title != null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -53,6 +56,9 @@ fun MiniPlayerBar(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.basicMarquee(),
) )
if (subtitle != null) { if (subtitle != null) {
Text( Text(
@@ -88,3 +94,17 @@ private fun MiniPlayerBarPlayingPreview() {
MiniPlayerBar(title = "Sample Track", subtitle = "Sample Artist", artworkUrl = null, isPlaying = true, onTogglePlayPause = {}) 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 = {},
)
}
}
@@ -2,7 +2,9 @@ package com.InfernalAquatics.deepwave.ui.navigation
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -13,18 +15,28 @@ import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController 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.AlbumDetailScreen
import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
import com.InfernalAquatics.deepwave.ui.login.LoginScreen import com.InfernalAquatics.deepwave.ui.login.LoginScreen
import com.InfernalAquatics.deepwave.ui.player.NowPlayingScreen import com.InfernalAquatics.deepwave.ui.player.NowPlayingScreen
import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen 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 @UnstableApi
@Composable @Composable
fun DeepwaveNavHost( fun DeepwaveNavHost(
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
sessionViewModel: SessionViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel(),
playerViewModel: PlayerViewModel = hiltViewModel(),
) { ) {
val isLoggedIn by sessionViewModel.isLoggedIn.collectAsState() val isLoggedIn by sessionViewModel.isLoggedIn.collectAsState()
@@ -32,48 +44,71 @@ fun DeepwaveNavHost(
null -> Box(modifier = Modifier.fillMaxSize()) { null -> Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} }
else -> NavHost( else -> {
navController = navController, val playbackUiState by playerViewModel.uiState.collectAsState()
startDestination = if (isLoggedIn == true) Route.Main else Route.Login, val currentDestination by navController.currentBackStackEntryAsState()
) { val showMiniPlayer = currentDestination?.destination?.route?.let { route ->
composable<Route.Login> { route != Route.Login::class.qualifiedName && route != Route.NowPlaying::class.qualifiedName
LoginScreen( } ?: false
onLoginSuccess = {
navController.navigate(Route.Main) { Scaffold(
popUpTo<Route.Login> { inclusive = true } bottomBar = {
} if (showMiniPlayer) {
}, MiniPlayerBar(
) title = playbackUiState.nowPlaying?.title,
} subtitle = playbackUiState.nowPlaying?.artist,
composable<Route.Main> { artworkUrl = playbackUiState.nowPlaying?.artworkUri,
MainScreen( isPlaying = playbackUiState.isPlaying,
onOpenSettings = { navController.navigate(Route.Settings) }, onTogglePlayPause = playerViewModel::togglePlayPause,
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) }, onClick = { navController.navigate(Route.NowPlaying) },
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, )
onOpenNowPlaying = { navController.navigate(Route.NowPlaying) }, }
) },
} ) { innerPadding ->
composable<Route.Settings> { NavHost(
SettingsScreen( navController = navController,
onBack = { navController.popBackStack() }, startDestination = if (isLoggedIn == true) Route.Main else Route.Login,
onLoggedOut = { modifier = Modifier.padding(innerPadding),
navController.navigate(Route.Login) { ) {
popUpTo<Route.Main> { inclusive = true } composable<Route.Login> {
} LoginScreen(
}, onLoginSuccess = {
) navController.navigate(Route.Main) {
} popUpTo<Route.Login> { inclusive = true }
composable<Route.ArtistDetail> { }
ArtistDetailScreen( },
onBack = { navController.popBackStack() }, )
onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, }
) composable<Route.Main> {
} MainScreen(
composable<Route.AlbumDetail> { onOpenSettings = { navController.navigate(Route.Settings) },
AlbumDetailScreen(onBack = { navController.popBackStack() }) onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
} onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
composable<Route.NowPlaying> { )
NowPlayingScreen(onBack = { navController.popBackStack() }) }
composable<Route.Settings> {
SettingsScreen(
onBack = { navController.popBackStack() },
onLoggedOut = {
navController.navigate(Route.Login) {
popUpTo<Route.Main> { inclusive = true }
}
},
)
}
composable<Route.ArtistDetail> {
ArtistDetailScreen(
onBack = { navController.popBackStack() },
onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
)
}
composable<Route.AlbumDetail> {
AlbumDetailScreen(onBack = { navController.popBackStack() })
}
composable<Route.NowPlaying> {
NowPlayingScreen(onBack = { navController.popBackStack() })
}
}
} }
} }
} }
@@ -2,7 +2,6 @@ package com.InfernalAquatics.deepwave.ui.navigation
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -14,7 +13,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -22,50 +20,26 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview 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.R
import com.InfernalAquatics.deepwave.media.PlaybackUiState
import com.InfernalAquatics.deepwave.ui.components.BottomNavBar import com.InfernalAquatics.deepwave.ui.components.BottomNavBar
import com.InfernalAquatics.deepwave.ui.components.BottomTab 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.ArtistsScreen
import com.InfernalAquatics.deepwave.ui.library.HomeScreen import com.InfernalAquatics.deepwave.ui.library.HomeScreen
import com.InfernalAquatics.deepwave.ui.library.SearchScreen import com.InfernalAquatics.deepwave.ui.library.SearchScreen
import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme 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 @Composable
fun MainScreen( fun MainScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenArtist: (String) -> Unit, onOpenArtist: (String) -> Unit,
onOpenAlbum: (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) } var selectedTab by remember { mutableStateOf(BottomTab.Home) }
@@ -81,17 +55,7 @@ private fun MainContent(
) )
}, },
bottomBar = { bottomBar = {
Column { BottomNavBar(selectedTab = selectedTab, onTabSelected = { selectedTab = it })
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 })
}
}, },
) { innerPadding -> ) { innerPadding ->
Box( Box(
@@ -111,15 +75,8 @@ private fun MainContent(
/** Interactive in Android Studio's preview pane — try tapping the tabs and the settings icon. */ /** 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) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable @Composable
private fun MainContentPreview() { private fun MainScreenPreview() {
DeepwaveTheme { DeepwaveTheme {
MainContent( MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {})
playbackUiState = PlaybackUiState(),
onOpenSettings = {},
onOpenArtist = {},
onOpenAlbum = {},
onOpenNowPlaying = {},
onTogglePlayPause = {},
)
} }
} }
@@ -1,6 +1,7 @@
package com.InfernalAquatics.deepwave.ui.player package com.InfernalAquatics.deepwave.ui.player
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -86,29 +87,49 @@ private fun NowPlayingContent(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
.padding(24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Artwork(imageUrl = uiState.nowPlaying?.artworkUri, size = 280.dp, modifier = Modifier.padding(top = 24.dp)) // Artwork/title/artist float in whatever space is left above the controls, so a
Spacer(modifier = Modifier.height(32.dp)) // wrapped or marqueed title never pushes the transport controls around - those
Text( // stay anchored to a fixed distance from the bottom regardless of title length.
text = uiState.nowPlaying?.title ?: "Nothing playing", Column(
style = MaterialTheme.typography.headlineSmall, modifier = Modifier
textAlign = TextAlign.Center, .weight(1f)
) .fillMaxWidth(),
uiState.nowPlaying?.artist?.let { horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Artwork(imageUrl = uiState.nowPlaying?.artworkUri, size = 280.dp)
Spacer(modifier = Modifier.height(32.dp))
Text( Text(
text = it, text = uiState.nowPlaying?.title ?: "Nothing playing",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, 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) SeekBar(positionMs = uiState.positionMs, durationMs = uiState.durationMs, onSeek = onSeek)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, 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 = {},
)
}
}