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 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
|
||||||
|
|||||||
Reference in new issue
Block a user