Add landscape layout support for main player screen

Refactors PlayerContent into reusable components and adds orientation
detection. In landscape mode, artwork appears on the left with station
info and controls on the right for better horizontal space usage.
This commit is contained in:
Pavel Baksy 2025-11-23 00:00:15 +01:00
parent f9adf3d3db
commit 44c84ab57f

View File

@ -1,5 +1,6 @@
package cz.bugsy.karemote.ui.screens.main package cz.bugsy.karemote.ui.screens.main
import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -45,6 +46,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -226,6 +228,39 @@ private fun PlayerContent(
onNextStation: () -> Unit, onNextStation: () -> Unit,
onPreviousStation: () -> Unit, onPreviousStation: () -> Unit,
onShowStationPicker: () -> Unit onShowStationPicker: () -> Unit
) {
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) {
LandscapePlayerContent(
uiState = uiState,
onTogglePlayPause = onTogglePlayPause,
onVolumeChange = onVolumeChange,
onNextStation = onNextStation,
onPreviousStation = onPreviousStation,
onShowStationPicker = onShowStationPicker
)
} else {
PortraitPlayerContent(
uiState = uiState,
onTogglePlayPause = onTogglePlayPause,
onVolumeChange = onVolumeChange,
onNextStation = onNextStation,
onPreviousStation = onPreviousStation,
onShowStationPicker = onShowStationPicker
)
}
}
@Composable
private fun PortraitPlayerContent(
uiState: MainUiState,
onTogglePlayPause: () -> Unit,
onVolumeChange: (Int) -> Unit,
onNextStation: () -> Unit,
onPreviousStation: () -> Unit,
onShowStationPicker: () -> Unit
) { ) {
val status = uiState.karadioStatus val status = uiState.karadioStatus
val radioBrowserInfo = uiState.radioBrowserInfo val radioBrowserInfo = uiState.radioBrowserInfo
@ -236,45 +271,149 @@ private fun PlayerContent(
) { ) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Station artwork StationArtwork(
Card( favicon = radioBrowserInfo?.favicon,
modifier = Modifier size = 200.dp,
.size(200.dp) onShowStationPicker = onShowStationPicker
.clickable { onShowStationPicker() }, )
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (!radioBrowserInfo?.favicon.isNullOrBlank()) {
AsyncImage(
model = radioBrowserInfo?.favicon,
contentDescription = "Station logo",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Radio,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
StationInfo(
status = status,
radioBrowserInfo = radioBrowserInfo,
onShowStationPicker = onShowStationPicker
)
Spacer(modifier = Modifier.weight(1f))
PlaybackControls(
isPlaying = status.isPlaying,
onTogglePlayPause = onTogglePlayPause,
onNextStation = onNextStation,
onPreviousStation = onPreviousStation
)
Spacer(modifier = Modifier.height(32.dp))
VolumeControl(
volume = status.volume,
onVolumeChange = onVolumeChange
)
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun LandscapePlayerContent(
uiState: MainUiState,
onTogglePlayPause: () -> Unit,
onVolumeChange: (Int) -> Unit,
onNextStation: () -> Unit,
onPreviousStation: () -> Unit,
onShowStationPicker: () -> Unit
) {
val status = uiState.karadioStatus
val radioBrowserInfo = uiState.radioBrowserInfo
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Left side: Artwork
StationArtwork(
favicon = radioBrowserInfo?.favicon,
size = 180.dp,
onShowStationPicker = onShowStationPicker
)
Spacer(modifier = Modifier.width(24.dp))
// Right side: Info and controls
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
StationInfo(
status = status,
radioBrowserInfo = radioBrowserInfo,
onShowStationPicker = onShowStationPicker
)
Spacer(modifier = Modifier.height(16.dp))
PlaybackControls(
isPlaying = status.isPlaying,
onTogglePlayPause = onTogglePlayPause,
onNextStation = onNextStation,
onPreviousStation = onPreviousStation
)
Spacer(modifier = Modifier.height(16.dp))
VolumeControl(
volume = status.volume,
onVolumeChange = onVolumeChange
)
}
}
}
@Composable
private fun StationArtwork(
favicon: String?,
size: androidx.compose.ui.unit.Dp,
onShowStationPicker: () -> Unit
) {
Card(
modifier = Modifier
.size(size)
.clickable { onShowStationPicker() },
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (!favicon.isNullOrBlank()) {
AsyncImage(
model = favicon,
contentDescription = "Station logo",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Radio,
contentDescription = null,
modifier = Modifier.size(size / 2.5f),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
@Composable
private fun StationInfo(
status: cz.bugsy.karemote.data.model.KaradioStatus,
radioBrowserInfo: cz.bugsy.karemote.data.model.RadioBrowserStation?,
onShowStationPicker: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// Station name // Station name
Text( Text(
text = status.stationName.ifBlank { "No Station" }, text = status.stationName.ifBlank { "No Station" },
@ -341,103 +480,108 @@ private fun PlayerContent(
} }
} }
} }
}
}
Spacer(modifier = Modifier.weight(1f)) @Composable
private fun PlaybackControls(
// Playback controls isPlaying: Boolean,
Row( onTogglePlayPause: () -> Unit,
horizontalArrangement = Arrangement.Center, onNextStation: () -> Unit,
verticalAlignment = Alignment.CenterVertically, onPreviousStation: () -> Unit
modifier = Modifier.fillMaxWidth() ) {
) { Row(
FilledIconButton( horizontalArrangement = Arrangement.Center,
onClick = onPreviousStation, verticalAlignment = Alignment.CenterVertically
modifier = Modifier.size(56.dp), ) {
colors = IconButtonDefaults.filledIconButtonColors( FilledIconButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer onClick = onPreviousStation,
) modifier = Modifier.size(56.dp),
) { colors = IconButtonDefaults.filledIconButtonColors(
Icon( containerColor = MaterialTheme.colorScheme.secondaryContainer
imageVector = Icons.Default.SkipPrevious,
contentDescription = "Previous station",
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = onTogglePlayPause,
modifier = Modifier.size(72.dp)
) {
Icon(
imageVector = if (status.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (status.isPlaying) "Pause" else "Play",
modifier = Modifier.size(40.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = onNextStation,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = "Next station",
modifier = Modifier.size(32.dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Volume control
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
) )
) { ) {
Column( Icon(
modifier = Modifier.padding(16.dp) imageVector = Icons.Default.SkipPrevious,
) { contentDescription = "Previous station",
Row( modifier = Modifier.size(32.dp)
verticalAlignment = Alignment.CenterVertically, )
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Volume",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${(status.volume * 100 / 254)}%",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = status.volume.toFloat(),
onValueChange = { onVolumeChange(it.toInt()) },
valueRange = 0f..254f,
modifier = Modifier.fillMaxWidth()
)
}
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = onTogglePlayPause,
modifier = Modifier.size(72.dp)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(40.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = onNextStation,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = "Next station",
modifier = Modifier.size(32.dp)
)
}
}
}
@Composable
private fun VolumeControl(
volume: Int,
onVolumeChange: (Int) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Volume",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${(volume * 100 / 254)}%",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = volume.toFloat(),
onValueChange = { onVolumeChange(it.toInt()) },
valueRange = 0f..254f,
modifier = Modifier.fillMaxWidth()
)
}
} }
} }