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 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 = {},
)
}
}
@@ -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,9 +44,31 @@ fun DeepwaveNavHost(
null -> Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
else -> NavHost(
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<Route.Login> {
LoginScreen(
@@ -50,7 +84,6 @@ fun DeepwaveNavHost(
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<Route.Settings> {
@@ -77,4 +110,6 @@ fun DeepwaveNavHost(
}
}
}
}
}
}
@@ -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 })
}
},
) { 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 = {})
}
}
@@ -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,15 +87,29 @@ 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))
// 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 = uiState.nowPlaying?.title ?: "Nothing playing",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.basicMarquee(),
)
uiState.nowPlaying?.artist?.let {
Text(
@@ -102,13 +117,19 @@ private fun NowPlayingContent(
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 = {},
)
}
}