fix(android): let list content reach the MiniPlayer — #2681
android / Build + lint + test (push) Successful in 4m28s

The shell is a Column (content weight(1f), then the bar), so the
content viewport already ends at the MiniPlayer's top edge. But
nothing owned the bottom navigation-bar inset under edge-to-edge:
each in-shell screen's own Scaffold claimed it via the default
contentWindowInsets and padded its content up by the nav-bar height
a second time. That padding is the dead strip the operator sees
between the last list row and the bar — and the bar's own bottom
was drawing under the gesture pill.

ShellScaffold now owns the inset end to end: the content region
consumes it, and a Spacer below the MiniPlayer re-holds the space
for the system bar (unconditional — MiniPlayer renders nothing when
no track is loaded). Modifier.consumeWindowInsets alone can't fix
it: ScaffoldLayout reads contentWindowInsets.asPaddingValues()
directly, outside the modifier consumption chain, so every in-shell
Scaffold is handed the new zero ShellContentWindowInsets. The
full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the
default — no shell sits above them.

Also drops the hardcoded 140dp bottom contentPadding on Album and
Playlist detail, a Flutter-era value for a player bar that overlaid
its list; here the shell reserves that space in layout already.
This commit is contained in:
2026-08-16 10:40:05 -04:00
parent 8e1d25a772
commit 20bd7bfaf8
16 changed files with 75 additions and 5 deletions
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -112,6 +113,7 @@ fun AdminLandingScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -42,6 +43,7 @@ fun AdminQuarantineScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -41,6 +42,7 @@ fun AdminRequestsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -35,6 +35,7 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -49,6 +50,7 @@ fun AdminTagSourcesScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@@ -127,6 +128,7 @@ private fun AdminUsersScaffold(
onRevokeInvite: (String) -> Unit,
) {
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -42,6 +42,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -61,6 +62,7 @@ fun DiscoverScreen(
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -91,6 +91,7 @@ import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
@@ -564,6 +565,7 @@ fun HomeScreen(
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -52,6 +51,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -70,6 +70,7 @@ fun AlbumDetailScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -165,7 +166,6 @@ private fun AlbumBody(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item {
AlbumHeader(
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -79,6 +80,7 @@ fun ArtistDetailScreen(
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -43,6 +43,7 @@ import com.fabledsword.minstrel.nav.Library
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -77,6 +78,7 @@ fun LibraryScreen(
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
Column {
@@ -7,7 +7,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -75,6 +74,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -339,6 +339,7 @@ fun PlaylistDetailScreen(
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
@@ -430,7 +431,6 @@ private fun PlaylistDetailBody(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) }
item { HorizontalDivider() }
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -149,6 +150,7 @@ fun PlaylistsListScreen(
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -45,6 +45,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -58,6 +59,7 @@ fun RequestsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -55,6 +55,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -76,6 +77,7 @@ fun SearchScreen(
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -55,6 +55,7 @@ import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -81,6 +82,7 @@ fun SettingsScreen(
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -2,9 +2,14 @@ package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
@@ -20,6 +25,26 @@ import com.fabledsword.minstrel.update.ui.UpdateBanner
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
/**
* `contentWindowInsets` for a screen's own Scaffold when that screen
* renders inside [ShellScaffold].
*
* The shell owns the window insets for in-shell routes: it consumes the
* status bar at the top and holds the navigation-bar space at the
* bottom, below the MiniPlayer. A screen Scaffold's default
* `contentWindowInsets` is the raw system bars, and Scaffold reads that
* value directly (`contentWindowInsets.asPaddingValues()`) rather than
* through the consumption-aware modifier chain — so the shell consuming
* the inset does NOT reach it. Left at the default, every in-shell
* screen pads its content up by the nav-bar height a second time: the
* dead strip between the last list row and the MiniPlayer.
*
* Full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the
* default — no shell sits above them, so their Scaffold is the only
* thing that can inset them.
*/
val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
/**
* Outer shell wrapper for "in-app" routes — banners on top
* (conditional, none implemented yet), routed screen filling the
@@ -82,12 +107,31 @@ fun ShellScaffold(
VersionTooOldBanner()
UpdateBanner()
ConnectionErrorBanner()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
// The window is edge-to-edge, so something has to own the
// bottom navigation-bar inset, and the shell claims it once
// here: the content region consumes it so nothing inside a
// screen pads for it a second time (see
// [ShellContentWindowInsets] for the Scaffold half of that —
// Scaffold reads its insets outside the consumption chain), and
// the Spacer below re-holds the space for the system bar.
// Un-owned, the inset re-appears inside each screen as a dead
// strip between the last list row and the MiniPlayer.
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.consumeWindowInsets(WindowInsets.navigationBars),
) {
content()
}
SnackbarHost(hostState = snackbarHostState)
MiniPlayer(
onExpandClick = onExpandPlayer,
)
// Unconditional: the MiniPlayer renders nothing when no track
// is loaded, and the content region has already given the inset
// up, so this is what keeps a fresh install's last row off the
// gesture pill.
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}