media: small screens and big text sizes get one column with the tabs in a bar, and the controls always stay on screen

The responsive layout ideas come from M4TH1EU's pull request #46, carried onto the current
two-column design.
This commit is contained in:
Seb3thehacker committed 2026-10-26 15:06:53 -07:00
1 parent 7bfb8c8669
commit dbeaa9cf5e
1 file changed
+147 -77
@@ -6,6 +6,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -162,48 +165,88 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
return@Surface return@Surface
} }
// Two columns, split the same way on every tab so nothing jumps when switching: the app val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
// and the art (or, beside a list, the compact player) on the left; the tabs across the MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, tabModifier) { tab = Tab.NOW_PLAYING }
// top of the right column, over whatever the tab shows. MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, tabModifier) { tab = Tab.QUEUE }
Row( MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp), MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
horizontalArrangement = Arrangement.spacedBy(24.dp), }
) { val searchButton: @Composable () -> Unit = {
Column(Modifier.weight(0.8f).fillMaxHeight()) { if (now.canSearch || media.canSearchLibrary) {
AppIdentity(app, onExit) GsIconButton(
Spacer(Modifier.height(12.dp)) Icons.Filled.Search,
if (tab == Tab.NOW_PLAYING) { stringResource(R.string.media_search, app.label),
CoverArt(art, Modifier.weight(1f).fillMaxWidth()) { searching = true },
} else { colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
CompactPlayer(now, media, art, Modifier.weight(1f).fillMaxWidth()) size = 44.dp,
shape = MaterialTheme.shapes.large,
)
}
}
val tabContent: @Composable (Modifier) -> Unit = { contentModifier ->
Box(contentModifier) {
when (tab) {
Tab.NOW_PLAYING -> StageControls(now, media, Modifier.fillMaxSize())
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
} }
} }
Column(Modifier.weight(1.2f).fillMaxHeight()) { }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp)) {
MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } // A small screen, or a big one at Extra large size, has no room for two columns: the
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } // app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } // the controls, so nothing is lost.
if (now.canSearch || media.canSearchLibrary) { if (maxWidth < NARROW_WIDTH || maxHeight < NARROW_HEIGHT) {
GsIconButton( Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icons.Filled.Search, Row(
stringResource(R.string.media_search, app.label), Modifier.fillMaxWidth(),
{ searching = true }, horizontalArrangement = Arrangement.spacedBy(12.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), verticalAlignment = Alignment.CenterVertically,
size = 44.dp, ) {
shape = MaterialTheme.shapes.large, AppIdentity(app, onExit, Modifier.widthIn(max = 180.dp))
) Row(
Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) { tabs(Modifier) }
searchButton()
}
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
if (tab == Tab.NOW_PLAYING) NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth(), padded = false)
else tabContent(Modifier.weight(1f).fillMaxWidth())
}
return@BoxWithConstraints
}
// Two columns, split the same way on every tab so nothing jumps when switching: the app
// and the art (or, beside a list, the compact player) on the left; the tabs across the
// top of the right column, over whatever the tab shows.
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Column(Modifier.weight(0.8f).fillMaxHeight()) {
AppIdentity(app, onExit)
Spacer(Modifier.height(12.dp))
if (tab == Tab.NOW_PLAYING) {
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
} else {
CompactPlayer(now, media, art, Modifier.weight(1f).fillMaxWidth())
} }
} }
search?.let { SearchStatus(it, app.label, media::dismissSearch) } Column(Modifier.weight(1.2f).fillMaxHeight()) {
Spacer(Modifier.height(12.dp)) // Each tab takes a share of the row, but a long word (a translation, a big
Box(Modifier.weight(1f).fillMaxWidth()) { // text size) can still scroll the row rather than be cut off.
when (tab) { Row(
Tab.NOW_PLAYING -> StageControls(now, media, Modifier.fillMaxSize()) Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) horizontalArrangement = Arrangement.spacedBy(8.dp),
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize()) ) {
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) Row(Modifier.weight(1f, fill = false), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
tabs(Modifier.widthIn(min = 96.dp))
}
searchButton()
} }
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
Spacer(Modifier.height(12.dp))
tabContent(Modifier.weight(1f).fillMaxWidth())
} }
} }
} }
@@ -382,6 +425,7 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 12.dp),
) )
} }
} }
@@ -587,17 +631,27 @@ internal fun Notice(title: String, detail: String?, action: Pair<String, () -> U
// --- now playing ----------------------------------------------------------------------------- // --- now playing -----------------------------------------------------------------------------
/** /**
* The now-playing view on its own, for places with no tabs around it (an app's own * The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
* now-playing template): the cover on the left, [NowPlayingControls] on the right. * small screen. The cover on the left and [NowPlayingControls] on the right where there's room;
* narrower, the cover sits above the controls, and only when they still fit under it.
*/ */
@Composable @Composable
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) { internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
Row( BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
modifier.padding(horizontal = 24.dp, vertical = 8.dp), val art = rememberArt(now)
horizontalArrangement = Arrangement.spacedBy(24.dp), if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
) { val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight()) Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight()) CoverArt(art, Modifier.width(artWidth).fillMaxHeight())
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxHeight())
}
} else {
val artHeight = if (maxHeight >= 400.dp) minOf(maxHeight * 0.25f, 160.dp) else 0.dp
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (artHeight > 0.dp) CoverArt(art, Modifier.fillMaxWidth().height(artHeight))
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxWidth())
}
}
} }
} }
@@ -632,16 +686,21 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier, alignmen
private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) { private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
val oneRow = maxWidth >= 820.dp val oneRow = maxWidth >= 820.dp
// Short of height (a small screen, or Extra large size), everything steps down a size.
val compact = maxHeight < 360.dp
val showTimes = maxHeight >= 260.dp
val gap = if (compact) 8.dp else 16.dp
Column( Column(
// At least as tall as the space, so the controls sit at its bottom; scrolls if they don't fit. Modifier.fillMaxSize().padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = maxHeight).padding(start = 4.dp, end = 4.dp, bottom = 4.dp), verticalArrangement = Arrangement.spacedBy(gap, Alignment.Bottom),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
) { ) {
Column { // The title gives up its height first and scrolls, so the controls never leave the screen.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
Text( Text(
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) }, now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis, maxLines = 2, overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(minFontSize = 22.sp, maxFontSize = MaterialTheme.typography.displaySmall.fontSize, stepSize = 2.sp),
) )
Text( Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" · "), listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" · "),
@@ -657,15 +716,17 @@ private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier)
) )
} }
} }
PlayerProgress(now, media, Modifier.fillMaxWidth()) PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
val skip = if (compact) 48.dp else 64.dp
val mode = if (compact) 46.dp else 54.dp
if (oneRow) { if (oneRow) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TransportRow(now, media, skip = 66.dp, modifier = Modifier.weight(1f)) TransportRow(now, media, skip = if (compact) skip else 66.dp, modifier = Modifier.weight(1f))
PlayModeRow(now, media, size = 54.dp, modifier = Modifier.weight(1f), maxOther = 2) PlayModeRow(now, media, size = mode, modifier = Modifier.weight(1f), maxOther = 2)
} }
} else { } else {
TransportRow(now, media, skip = 64.dp, modifier = Modifier.fillMaxWidth()) TransportRow(now, media, skip = skip, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 54.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2) PlayModeRow(now, media, size = mode, modifier = Modifier.fillMaxWidth(), maxOther = 2)
} }
} }
} }
@@ -677,35 +738,44 @@ private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier)
*/ */
@Composable @Composable
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) { private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column( BoxWithConstraints(modifier) {
modifier.verticalScroll(rememberScrollState()), val compact = maxHeight < 360.dp || maxWidth < 420.dp
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically), val showTimes = maxHeight >= 260.dp
) { Column(
Column { Modifier.fillMaxSize(),
Text( verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) }, ) {
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, // The title gives up its height first and scrolls, so the controls never leave the screen.
maxLines = 2, overflow = TextOverflow.Ellipsis, Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text( Text(
stringResource(R.string.media_press_play), now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
) )
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text(
stringResource(R.string.media_press_play),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
TransportRow(now, media, skip = if (compact) 48.dp else 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = if (compact) 46.dp else 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
} }
PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
} }
} }
/** Below these, the media app drops to one column: see [MediaScreen]. */
private val NARROW_WIDTH = 600.dp
private val NARROW_HEIGHT = 300.dp
/** /**
* The player beside the browse list, the queue and the lyrics: the cover as big as the controls * The player beside the browse list, the queue and the lyrics: the cover as big as the controls
* leave room for, with the title and artist beside it rather than under it, so the cover gets the * leave room for, with the title and artist beside it rather than under it, so the cover gets the