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 db9796bf29
commit f5ca6d2bdc

View File

@ -1,5 +1,6 @@
package cz.bugsy.karemote.ui.screens.main
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -45,6 +46,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -226,6 +228,39 @@ private fun PlayerContent(
onNextStation: () -> Unit,
onPreviousStation: () -> 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 radioBrowserInfo = uiState.radioBrowserInfo
@ -236,45 +271,149 @@ private fun PlayerContent(
) {
Spacer(modifier = Modifier.height(16.dp))
// Station artwork
Card(
modifier = Modifier
.size(200.dp)
.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
)
}
}
}
}
StationArtwork(
favicon = radioBrowserInfo?.favicon,
size = 200.dp,
onShowStationPicker = onShowStationPicker
)
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
Text(
text = status.stationName.ifBlank { "No Station" },
@ -341,103 +480,108 @@ private fun PlayerContent(
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Playback controls
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
FilledIconButton(
onClick = onPreviousStation,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Icon(
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
@Composable
private fun PlaybackControls(
isPlaying: Boolean,
onTogglePlayPause: () -> Unit,
onNextStation: () -> Unit,
onPreviousStation: () -> Unit
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
FilledIconButton(
onClick = onPreviousStation,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
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 = "${(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()
)
}
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = "Previous station",
modifier = Modifier.size(32.dp)
)
}
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()
)
}
}
}