diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt index a3a55c9..62cd077 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt @@ -123,6 +123,13 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.launch +/** + * Width of the side navigation rail shown on tablets (Material3 `NavigationRail` container width). + * The expanding Now Playing overlay is inset by this on portrait tablets so it never draws over the + * rail (see the `startInset` passed to [ExpandingNowPlayer]). + */ +private val NavigationRailWidth = 80.dp + enum class AppDestinations( val label: String, val icon: ImageVector, @@ -296,10 +303,17 @@ class MainActivity : ComponentActivity() { val isWide = (windowSizeClass.minWidthDp != 0 && windowSizeClass.minHeightDp != 0) + // Whether the Now Playing side pane can actually dock beside the content (two horizontal + // partitions — expanded width, i.e. a landscape tablet). A portrait tablet is `isWide` but + // has only ONE partition, so it can't split: it falls through to the expanding overlay player + // like a phone. Derived from the directive (fresh window metrics), NOT the flaky navigator + // pane value. This — not `isWide` — is what gates which Now Playing surface is used. + val splitViewActive = customDirective.maxHorizontalPartitions > 1 + // Lift the FAB above the mini player when it's showing. The mini bar is now an overlay // (not in the Scaffold's bottomBar), so the Scaffold no longer reserves space for it. val fabMiniInset by animateDpAsState( - targetValue = if (!isWide && playerState?.currentMediaItem?.mediaId != null) 84.dp else 0.dp, + targetValue = if (!splitViewActive && playerState?.currentMediaItem?.mediaId != null) 84.dp else 0.dp, label = "Fab Mini Inset" ) @@ -567,7 +581,7 @@ class MainActivity : ComponentActivity() { // fold/unfold and would let the mini and the pane both show at once. // On phones the mini is the expanding overlay and this flag only drives the // nav-bar fuse, so it's simply "is something playing". - val miniPlayerVisible = if (isWide) { + val miniPlayerVisible = if (splitViewActive) { playerState?.currentMediaItem?.mediaId != null && (discoveryViewingStation.value || hidingDiscoverDetail) } else { @@ -606,8 +620,9 @@ class MainActivity : ComponentActivity() { ) } - if (isWide) { - // Tablets keep the stock NavigationRail; the mini player sits in the bar. + if (splitViewActive) { + // Landscape tablet: the Now Playing side pane docks, so keep the stock + // NavigationRail and let this bottom mini bar re-dock the pane. AnimatedVisibility( visible = miniPlayerVisible && !hideMiniForDock, enter = slideInVertically( @@ -647,7 +662,7 @@ class MainActivity : ComponentActivity() { } } } - } else { + } else if (!isWide) { // Phones: only the floating nav bar lives in the bar. The mini player is // now the collapsed state of the expanding Now Playing surface, rendered // as an overlay above everything (see ExpandingNowPlayer below), so it can @@ -674,6 +689,8 @@ class MainActivity : ComponentActivity() { ) } } + // else: portrait tablet — the rail owns navigation and the expanding overlay + // owns the player, so the bottom bar stays empty. }) { innerPadding -> // The mini / expanding player is an OVERLAY (not in the Scaffold's bottomBar), // so the Scaffold reserves no space for it. Add its footprint to the scroll @@ -685,7 +702,7 @@ class MainActivity : ComponentActivity() { top = innerPadding.calculateTopPadding(), end = innerPadding.calculateEndPadding(layoutDirection), bottom = innerPadding.calculateBottomPadding() + - (if (!isWide && playerState?.currentMediaItem?.mediaId != null) 84.dp else 0.dp) + (if (!splitViewActive && playerState?.currentMediaItem?.mediaId != null) 84.dp else 0.dp) ) AnimatedVisibility( radioListMode != null, @@ -810,11 +827,13 @@ class MainActivity : ComponentActivity() { ) } - // Phone Now Playing lives here — a SECOND child of the drawer content, stacked over the - // scaffold, so the settings drawer sheet draws OVER it. It used to be a sibling of the - // whole drawer and painted on top of the opened settings sheet. Re-wrapped in - // ReverseLayoutDirection because the entire drawer is flipped to sit on the right edge. - if (!isWide) { + // The expanding Now Playing surface lives here — a SECOND child of the drawer content, + // stacked over the scaffold, so the settings drawer sheet draws OVER it. It used to be a + // sibling of the whole drawer and painted on top of the opened settings sheet. Re-wrapped + // in ReverseLayoutDirection because the entire drawer is flipped to sit on the right edge. + // Shown whenever the side pane can't dock (phones AND portrait tablets) — the docking side + // pane replaces it only when the horizontal split is actually available. + if (!splitViewActive) { // When playback ends, snap back to the mini bar so the next song opens collapsed. LaunchedEffect(playerState?.currentMediaItem?.mediaId) { if (playerState?.currentMediaItem?.mediaId == null) { @@ -838,7 +857,12 @@ class MainActivity : ComponentActivity() { play = { mediaController?.play() }, pause = { mediaController?.pause() }, stop = { mainActivityViewModel?.sharedMediaController?.mediaSession?.value?.player?.stop() }, - onDismissProgress = { miniDismissProgress = it } + onDismissProgress = { miniDismissProgress = it }, + // Portrait tablet keeps the side rail (no floating bottom nav bar), so the mini bar + // rests just above the system nav inset instead of reserving the nav bar footprint, + // and is inset past the rail so it never draws over it. Phones: neither applies. + reserveNavBarArea = !isWide, + startInset = if (isWide) NavigationRailWidth else 0.dp ) } } diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt index f22d351..a68299c 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt @@ -97,16 +97,25 @@ fun ExpandingNowPlayer( pause: () -> Unit, stop: () -> Unit, onDismissProgress: (Float) -> Unit, + reserveNavBarArea: Boolean = true, + startInset: Dp = 0.dp, modifier: Modifier = Modifier ) { BoxWithConstraints(modifier = modifier.fillMaxSize()) { val density = LocalDensity.current - val fullHeightPx = with(density) { maxHeight.toPx() } + // Bind the scope to a `this` local: its constraints give the container's full height (px). This + // also satisfies the "unused BoxWithConstraints scope" lint — the scope is otherwise only read + // inside nested lambdas (with/remember/layout), which the check doesn't count as a use. + val boxScope = this + val fullHeightPx = boxScope.constraints.maxHeight.toFloat() val miniBarPx = with(density) { MiniBarHeight.toPx() } val sideMarginPx = with(density) { CollapsedSideMargin.toPx() } + val startInsetPx = with(density) { startInset.toPx() } val shadowPx = with(density) { CollapsedShadow.toPx() } + // Phones float above the bottom nav bar (reserve its 82dp footprint); tablets use a side rail and + // have no bottom bar, so the mini bar rests just above the system nav inset. val navAreaPx = WindowInsets.navigationBars.getBottom(density) + - with(density) { NavBarArea.toPx() } + if (reserveNavBarArea) with(density) { NavBarArea.toPx() } else 0f val collapsedY = (fullHeightPx - miniBarPx - navAreaPx).coerceAtLeast(0f) val thresholdPx = with(density) { 5.dp.toPx() } @@ -191,12 +200,15 @@ fun ExpandingNowPlayer( .layout { measurable, constraints -> val f = state.expansion() val hInset = lerp(sideMarginPx, 0f, f) - val cardW = (constraints.maxWidth - 2f * hInset).roundToInt() + // Left inset that clears the side nav rail while collapsed and closes to 0 as the card fills + // the screen — so the collapsed mini bar sits beside the rail but the full player covers it. + val railInset = lerp(startInsetPx, 0f, f) + val cardW = (constraints.maxWidth - railInset - 2f * hInset).roundToInt() .coerceIn(0, constraints.maxWidth) val cardH = lerp(miniBarPx, fullHeightPx, f).roundToInt() .coerceIn(0, constraints.maxHeight) val placeable = measurable.measure(Constraints.fixed(cardW, cardH)) - layout(constraints.maxWidth, cardH) { placeable.place(hInset.roundToInt(), 0) } + layout(constraints.maxWidth, cardH) { placeable.place((railInset + hInset).roundToInt(), 0) } } // One shape + shadow for the whole card; both flatten as it opens. translationX slides the // whole card when the collapsed mini bar is swiped away to dismiss. @@ -206,7 +218,11 @@ fun ExpandingNowPlayer( // radius as the mini bar is swiped away, then flatten with the top corners as it opens. val dismiss = (abs(dismissOffset.value) / (screenWidthPx * 0.4f)).coerceIn(0f, 1f) val topC = lerp(CollapsedCorner, 0.dp, f) - val bottomC = lerp(lerp(CollapsedBottomCorner, CollapsedCorner, dismiss), 0.dp, f) + // Phones nest the bottom against the nav bar (tight 14dp); tablets float free with no bar + // below, so the bottom is fully rounded like the top. Both round back to [CollapsedCorner] + // on swipe-away and flatten to 0 as the card fills the screen. + val restingBottomCorner = if (reserveNavBarArea) CollapsedBottomCorner else CollapsedCorner + val bottomC = lerp(lerp(restingBottomCorner, CollapsedCorner, dismiss), 0.dp, f) shape = RoundedCornerShape( topStart = topC, topEnd = topC, diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt index 5741e6b..024f6c1 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -186,7 +185,7 @@ fun NowPlayingContent( .padding(bottom = innerPadding.calculateBottomPadding()) .fillMaxHeight() .fillMaxWidth() - .padding(bottom = 32.dp) +// .padding(bottom = 32.dp) ) { NowPlayingAlbumArt( modifier = Modifier.fillMaxHeight(.75f), diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingBottomBar.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingBottomBar.kt index f57f7bb..fa235a8 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingBottomBar.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingBottomBar.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.Composable @@ -20,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.palette.graphics.Palette @@ -91,6 +89,6 @@ fun NowPlayingBottomBar( isSleeping = isSleeping, palette = palette ) - Spacer(Modifier.height(48.dp)) + Spacer(Modifier.weight(0.05f)) } }