From 44c84ab57f3a1216ca6c8356eade612ab1a1deac Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Sun, 23 Nov 2025 00:00:15 +0100 Subject: [PATCH] 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. --- .../karemote/ui/screens/main/MainScreen.kt | 400 ++++++++++++------ 1 file changed, 272 insertions(+), 128 deletions(-) diff --git a/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt b/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt index 8caa2aa..290ef77 100644 --- a/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt @@ -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() + ) + } } }