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:
co-authored by
Claude Sonnet 5
parent
cf52bb854e
commit
f79da367dc
@@ -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,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<Route.Login> {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Route.Main) {
|
||||
popUpTo<Route.Login> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<Route.Main> {
|
||||
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<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() })
|
||||
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(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Route.Main) {
|
||||
popUpTo<Route.Login> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<Route.Main> {
|
||||
MainScreen(
|
||||
onOpenSettings = { navController.navigate(Route.Settings) },
|
||||
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
|
||||
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
|
||||
)
|
||||
}
|
||||
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 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 = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user