mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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 app.seb3thehacker.gearslip.car.theme.*
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -162,48 +165,88 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// 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().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
|
||||
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())
|
||||
val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
|
||||
MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, tabModifier) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, tabModifier) { tab = Tab.QUEUE }
|
||||
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
|
||||
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
|
||||
}
|
||||
val searchButton: @Composable () -> Unit = {
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
stringResource(R.string.media_search, app.label),
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
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 }
|
||||
MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
stringResource(R.string.media_search, app.label),
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp)) {
|
||||
// A small screen, or a big one at Extra large size, has no room for two columns: the
|
||||
// app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
|
||||
// the controls, so nothing is lost.
|
||||
if (maxWidth < NARROW_WIDTH || maxHeight < NARROW_HEIGHT) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
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()) {
|
||||
// Each tab takes a share of the row, but a long word (a translation, a big
|
||||
// text size) can still scroll the row rather than be cut off.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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,
|
||||
maxLines = 1,
|
||||
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 -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The now-playing view on its own, for places with no tabs around it (an app's own
|
||||
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
|
||||
* The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
|
||||
* 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
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Row(
|
||||
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
|
||||
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
|
||||
BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
|
||||
val art = rememberArt(now)
|
||||
if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
|
||||
val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
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) {
|
||||
BoxWithConstraints(modifier) {
|
||||
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(
|
||||
// At least as tall as the space, so the controls sit at its bottom; scrolls if they don't fit.
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = maxHeight).padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
|
||||
Modifier.fillMaxSize().padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(gap, 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(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(minFontSize = 22.sp, maxFontSize = MaterialTheme.typography.displaySmall.fontSize, stepSize = 2.sp),
|
||||
)
|
||||
Text(
|
||||
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) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TransportRow(now, media, skip = 66.dp, modifier = Modifier.weight(1f))
|
||||
PlayModeRow(now, media, size = 54.dp, modifier = Modifier.weight(1f), maxOther = 2)
|
||||
TransportRow(now, media, skip = if (compact) skip else 66.dp, modifier = Modifier.weight(1f))
|
||||
PlayModeRow(now, media, size = mode, modifier = Modifier.weight(1f), maxOther = 2)
|
||||
}
|
||||
} else {
|
||||
TransportRow(now, media, skip = 64.dp, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = 54.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
TransportRow(now, media, skip = skip, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = mode, modifier = Modifier.fillMaxWidth(), maxOther = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,35 +738,44 @@ private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier)
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
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) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val compact = maxHeight < 360.dp || maxWidth < 420.dp
|
||||
val showTimes = maxHeight >= 260.dp
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
// 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(
|
||||
stringResource(R.string.media_press_play),
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||
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
|
||||
* leave room for, with the title and artist beside it rather than under it, so the cover gets the
|
||||
|
||||
Reference in new issue
Block a user