From 777549c76b198d796f15031606dfe9fdda841bf2 Mon Sep 17 00:00:00 2001 From: str0ng4le <83457499+str0ng4le@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:02:21 +0100 Subject: [PATCH 1/4] Landscape layout for car tablets: navigation rail and side player When the window is wider than it is tall and at least 560dp wide (measured after the app's UI scale), MainScreen switches to a wide layout: - a NavigationRail with labelled items replaces the bottom navigation bar; - station lists and grids use the full height, without the mini-player overlay; - a new SidePanelPlayer column on the right stays visible while a station is selected, with centred artwork and text, playback controls and the recent-tracks list when there is enough height (hidden below 480dp so the controls stay fully visible). Portrait and narrow windows keep the existing layout. --- .../com/ounben/amaradio/ui/MainScreen.kt | 447 ++++++++++-------- .../com/ounben/amaradio/ui/PlayerScreens.kt | 172 +++++++ 2 files changed, 429 insertions(+), 190 deletions(-) diff --git a/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt b/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt index 308f6941..1066463a 100644 --- a/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt +++ b/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.core.content.edit import com.ounben.amaradio.Utils import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavDestination.Companion.hierarchy @@ -123,6 +124,30 @@ fun MainScreen( val isSettingsSubScreen = currentRoute == Screen.About.route || currentRoute == Screen.Statistics.route val isSettingsActive = currentRoute == Screen.Settings.route || isSettingsSubScreen + // Shared by the bottom navigation bar (portrait) and the navigation rail (landscape) + val navItems = listOf(Screen.Stations, Screen.Favourites, Screen.History, Screen.Settings) + + fun isNavItemSelected(screen: Screen): Boolean = + if (screen == Screen.Settings) isSettingsActive + else currentDestination?.hierarchy?.any { it.route == screen.route } == true + + fun onNavItemClick(screen: Screen) { + if (screen == Screen.Settings && isSettingsSubScreen) { + // Jump back to main settings if on sub-screen + navController.popBackStack(Screen.Settings.route, false) + } else { + isPlayerExpanded = false + if (screen == Screen.Stations) mainViewModel.setStationsInitialTab(0) + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + BackHandler(enabled = isPlayerExpanded) { isPlayerExpanded = false } BackHandler(enabled = mainUiState.isSearching) { @@ -130,7 +155,20 @@ fun MainScreen( searchViewModel.clearResults() } - Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + // Landscape screens (car tablets, tablets, phones held sideways) use a wide layout: + // navigation rail on the left and a permanent player column on the right, instead of + // bottom bar + mini player, which together take almost half of a landscape screen's height. + // maxWidth/maxHeight already include the app's own UI scale (see Theme.kt). + val useWideLayout = maxWidth > maxHeight && maxWidth >= 560.dp + val sidePanelWidth = (maxWidth * 0.36f).coerceIn(300.dp, 440.dp) + val showSidePlayer = useWideLayout && playerUiState.currentStation != null + + // The expandable full-screen player is not used in the wide layout + LaunchedEffect(useWideLayout) { + if (useWideLayout) isPlayerExpanded = false + } + Scaffold( topBar = { MainTopBar( @@ -167,7 +205,7 @@ fun MainScreen( ) }, bottomBar = { - if (!mainUiState.isSearching) { + if (!useWideLayout && !mainUiState.isSearching) { NavigationBar( modifier = Modifier .navigationBarsPadding() @@ -177,31 +215,11 @@ fun MainScreen( tonalElevation = 0.dp, windowInsets = WindowInsets(0, 0, 0, 0) ) { - val items = listOf(Screen.Stations, Screen.Favourites, Screen.History, Screen.Settings) - - items.forEach { screen -> - val isSelected = if (screen == Screen.Settings) isSettingsActive - else currentDestination?.hierarchy?.any { it.route == screen.route } == true - + navItems.forEach { screen -> NavigationBarItem( icon = { Icon(screen.icon, contentDescription = stringResource(screen.titleRes)) }, - selected = isSelected, - onClick = { - if (screen == Screen.Settings && isSettingsSubScreen) { - // Jump back to main settings if on sub-screen - navController.popBackStack(Screen.Settings.route, false) - } else { - isPlayerExpanded = false - if (screen == Screen.Stations) mainViewModel.setStationsInitialTab(0) - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - }, + selected = isNavItemSelected(screen), + onClick = { onNavItemClick(screen) }, colors = NavigationBarItemDefaults.colors( selectedIconColor = AmaradioAmber, selectedTextColor = AmaradioAmber, @@ -214,186 +232,235 @@ fun MainScreen( } } ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - // Fixed bottom padding to avoid measurement loops during scrolling - val contentBottomPadding = 110.dp - - if (mainUiState.isSearching) { - Surface( - modifier = Modifier.fillMaxSize().padding(bottom = if (playerUiState.currentStation != null) contentBottomPadding else 0.dp), - color = MaterialTheme.colorScheme.background + Row(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + if (useWideLayout) { + // Landscape: vertical navigation with big, labelled targets, easy to hit while parked + NavigationRail( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + windowInsets = WindowInsets(0, 0, 0, 0) ) { - if (searchUiState.isSearching && searchUiState.results.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = AmaradioAmber) - } - } else if (searchUiState.results.isEmpty() && mainUiState.searchQuery.isNotEmpty()) { - Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.TopCenter) { - Text(stringResource(R.string.searchpreference_no_results), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - StationListTemplate( - stations = searchUiState.results, - isGrid = mainUiState.isGridView, - isLoading = searchUiState.isSearching, - error = searchUiState.error, - emptyMessage = stringResource(R.string.searchpreference_no_results), - onStationClick = { station -> - if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station - else playerViewModel.play(station) - mainViewModel.setSearchActive(false) - searchViewModel.clearResults() - }, - onFavoriteClick = { station -> - if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) - else app.favouriteManager.add(station) + Spacer(modifier = Modifier.weight(1f)) + navItems.forEach { screen -> + NavigationRailItem( + icon = { Icon(screen.icon, contentDescription = null, modifier = Modifier.size(32.dp)) }, + label = { Text(stringResource(screen.titleRes), maxLines = 1) }, + selected = isNavItemSelected(screen), + onClick = { + // The rail stays visible while searching: leave search before navigating + if (mainUiState.isSearching) { + mainViewModel.setSearchActive(false) + searchViewModel.clearResults() + } + onNavItemClick(screen) }, - isFavorite = { uuid -> searchUiState.favoriteIds.contains(uuid) } + modifier = Modifier.padding(vertical = 8.dp), + colors = NavigationRailItemDefaults.colors( + selectedIconColor = AmaradioAmber, + selectedTextColor = AmaradioAmber, + indicatorColor = Color.Transparent, + unselectedIconColor = MaterialTheme.colorScheme.onSurface, + unselectedTextColor = MaterialTheme.colorScheme.onSurface + ) ) } + Spacer(modifier = Modifier.weight(1f)) } - } else { - NavHost( - navController = navController, - startDestination = startRoute, - modifier = Modifier.fillMaxSize().padding(bottom = contentBottomPadding) - ) { - composable(Screen.Stations.route) { - TabsScreen( - initialTab = mainUiState.stationsInitialTab, - onStationClick = { station -> - if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station - else playerViewModel.play(station) - }, - onCategoryClick = { } - ) + VerticalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outline) + } + + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Fixed bottom padding to avoid measurement loops during scrolling. + // In the wide layout the player has its own column, so nothing overlaps the content. + val contentBottomPadding = if (useWideLayout) 0.dp else 110.dp + + if (mainUiState.isSearching) { + Surface( + modifier = Modifier.fillMaxSize().padding(bottom = if (playerUiState.currentStation != null) contentBottomPadding else 0.dp), + color = MaterialTheme.colorScheme.background + ) { + if (searchUiState.isSearching && searchUiState.results.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = AmaradioAmber) + } + } else if (searchUiState.results.isEmpty() && mainUiState.searchQuery.isNotEmpty()) { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.TopCenter) { + Text(stringResource(R.string.searchpreference_no_results), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + StationListTemplate( + stations = searchUiState.results, + isGrid = mainUiState.isGridView, + isLoading = searchUiState.isSearching, + error = searchUiState.error, + emptyMessage = stringResource(R.string.searchpreference_no_results), + onStationClick = { station -> + if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station + else playerViewModel.play(station) + mainViewModel.setSearchActive(false) + searchViewModel.clearResults() + }, + onFavoriteClick = { station -> + if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) + else app.favouriteManager.add(station) + }, + isFavorite = { uuid -> searchUiState.favoriteIds.contains(uuid) } + ) + } } - composable(Screen.Favourites.route) { - val viewModel: LocalStationsViewModel = viewModel(key = "starred", factory = LocalStationsViewModelFactory(app, LocalManagerType.FAVOURITES)) - StarredScreen( - viewModel = viewModel, - onStationClick = { station -> - if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station - else playerViewModel.play(station) - }, - onFavoriteClick = { station -> - if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) - else app.favouriteManager.add(station) - }, - isFavorite = { uuid -> mainUiState.favoriteIds.contains(uuid) } - ) + } else { + NavHost( + navController = navController, + startDestination = startRoute, + modifier = Modifier.fillMaxSize().padding(bottom = contentBottomPadding) + ) { + composable(Screen.Stations.route) { + TabsScreen( + initialTab = mainUiState.stationsInitialTab, + onStationClick = { station -> + if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station + else playerViewModel.play(station) + }, + onCategoryClick = { } + ) + } + composable(Screen.Favourites.route) { + val viewModel: LocalStationsViewModel = viewModel(key = "starred", factory = LocalStationsViewModelFactory(app, LocalManagerType.FAVOURITES)) + StarredScreen( + viewModel = viewModel, + onStationClick = { station -> + if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station + else playerViewModel.play(station) + }, + onFavoriteClick = { station -> + if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) + else app.favouriteManager.add(station) + }, + isFavorite = { uuid -> mainUiState.favoriteIds.contains(uuid) } + ) + } + composable(Screen.History.route) { + val localViewModel: LocalStationsViewModel = viewModel(key = "history", factory = LocalStationsViewModelFactory(app, LocalManagerType.HISTORY)) + HistoryScreen( + localStationsViewModel = localViewModel, + trackHistoryViewModel = trackHistoryViewModel, + onStationClick = { station -> + if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station + else playerViewModel.play(station) + }, + onFavoriteClick = { station -> + if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) + else app.favouriteManager.add(station) + }, + onTrackClick = { }, + isFavorite = { uuid -> mainUiState.favoriteIds.contains(uuid) } + ) + } + composable(Screen.Settings.route) { + val viewModel: SettingsViewModel = viewModel() + SettingsScreen( + viewModel = viewModel, + serverInfoViewModel = serverInfoViewModel, + onOpenProxy = { showProxyDialog = true }, + onOpenAbout = { + isPlayerExpanded = false + navController.navigate(Screen.About.route) + }, + onOpenStatistics = { + isPlayerExpanded = false + navController.navigate(Screen.Statistics.route) + }, + onOpenEqualizer = { + val intent = Intent("android.media.action.DISPLAY_AUDIO_EFFECT_CONTROL_PANEL") + intent.putExtra("android.media.extra.PACKAGE_NAME", context.packageName) + intent.putExtra("android.media.extra.AUDIO_SESSION", 0) + try { context.startActivity(intent) } catch (_: Exception) {} + }, + onBatteryOptimize = { + Utils.openBatteryOrAppSettings(context) + }, + onRateApp = { + (context as? android.app.Activity)?.let { + app.reviewManager.launchReviewFlow(it) + } + }, + onExportBackup = onExportBackup, + onImportBackup = onImportBackup + ) + } + composable(Screen.About.route) { AboutScreen { navController.popBackStack() } } + composable(Screen.Statistics.route) { + ServerInfoScreen( + viewModel = serverInfoViewModel, + currentPlayingUuid = playerUiState.currentStation?.StationUuid, + isPlaying = playerUiState.playState == PlayState.Playing, + onStationClick = { station -> + if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station + else playerViewModel.play(station) + }, + onFavoriteClick = { station -> + if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) + else app.favouriteManager.add(station) + }, + onBack = { navController.popBackStack() } + ) + } } - composable(Screen.History.route) { - val localViewModel: LocalStationsViewModel = viewModel(key = "history", factory = LocalStationsViewModelFactory(app, LocalManagerType.HISTORY)) - HistoryScreen( - localStationsViewModel = localViewModel, - trackHistoryViewModel = trackHistoryViewModel, - onStationClick = { station -> - if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station - else playerViewModel.play(station) - }, - onFavoriteClick = { station -> - if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) - else app.favouriteManager.add(station) - }, - onTrackClick = { }, - isFavorite = { uuid -> mainUiState.favoriteIds.contains(uuid) } + } + + // MiniPlayer / FullPlayer Container (portrait and narrow screens only) + if (!useWideLayout) Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (isPlayerExpanded && playerUiState.currentStation != null) + Modifier.fillMaxHeight() + else + Modifier.wrapContentHeight() ) - } - composable(Screen.Settings.route) { - val viewModel: SettingsViewModel = viewModel() - SettingsScreen( - viewModel = viewModel, - serverInfoViewModel = serverInfoViewModel, - onOpenProxy = { showProxyDialog = true }, - onOpenAbout = { - isPlayerExpanded = false - navController.navigate(Screen.About.route) - }, - onOpenStatistics = { - isPlayerExpanded = false - navController.navigate(Screen.Statistics.route) - }, - onOpenEqualizer = { - val intent = Intent("android.media.action.DISPLAY_AUDIO_EFFECT_CONTROL_PANEL") - intent.putExtra("android.media.extra.PACKAGE_NAME", context.packageName) - intent.putExtra("android.media.extra.AUDIO_SESSION", 0) - try { context.startActivity(intent) } catch (_: Exception) {} - }, - onBatteryOptimize = { - Utils.openBatteryOrAppSettings(context) - }, - onRateApp = { - (context as? android.app.Activity)?.let { - app.reviewManager.launchReviewFlow(it) - } - }, - onExportBackup = onExportBackup, - onImportBackup = onImportBackup + .align(Alignment.BottomCenter) + .background(MaterialTheme.colorScheme.surfaceVariant) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { /* ignore delta */ }, + onDragStopped = { velocity -> + if (velocity < -500f && playerUiState.currentStation != null) isPlayerExpanded = true + if (velocity > 500f) isPlayerExpanded = false + } ) - } - composable(Screen.About.route) { AboutScreen { navController.popBackStack() } } - composable(Screen.Statistics.route) { - ServerInfoScreen( - viewModel = serverInfoViewModel, - currentPlayingUuid = playerUiState.currentStation?.StationUuid, - isPlaying = playerUiState.playState == PlayState.Playing, - onStationClick = { station -> - if (sharedPrefHasExternalPlayer(context)) showPlayerSelectorDialog = station - else playerViewModel.play(station) - }, - onFavoriteClick = { station -> - if (app.favouriteManager.has(station.StationUuid)) app.favouriteManager.remove(station.StationUuid) - else app.favouriteManager.add(station) + ) { + if (isPlayerExpanded && playerUiState.currentStation != null) { + Column { + Box(modifier = Modifier.fillMaxWidth().height(32.dp).clickable { isPlayerExpanded = false }, contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(40.dp, 4.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp))) + } + FullPlayer( + playerViewModel = playerViewModel, + trackHistoryViewModel = trackHistoryViewModel, + onTrackClick = { } + ) + } + } else { + MiniPlayer( + viewModel = playerViewModel, + isHeaderRole = false, + onToggleBottomSheet = { + if (playerUiState.currentStation != null) isPlayerExpanded = true }, - onBack = { navController.popBackStack() } + onMoreClick = { } ) } } } - // MiniPlayer / FullPlayer Container - Box( - modifier = Modifier - .fillMaxWidth() - .then( - if (isPlayerExpanded && playerUiState.currentStation != null) - Modifier.fillMaxHeight() - else - Modifier.wrapContentHeight() - ) - .align(Alignment.BottomCenter) - .background(MaterialTheme.colorScheme.surfaceVariant) - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { /* ignore delta */ }, - onDragStopped = { velocity -> - if (velocity < -500f && playerUiState.currentStation != null) isPlayerExpanded = true - if (velocity > 500f) isPlayerExpanded = false - } - ) - ) { - if (isPlayerExpanded && playerUiState.currentStation != null) { - Column { - Box(modifier = Modifier.fillMaxWidth().height(32.dp).clickable { isPlayerExpanded = false }, contentAlignment = Alignment.Center) { - Box(modifier = Modifier.size(40.dp, 4.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp))) - } - FullPlayer( - playerViewModel = playerViewModel, - trackHistoryViewModel = trackHistoryViewModel, - onTrackClick = { } - ) - } - } else { - MiniPlayer( - viewModel = playerViewModel, - isHeaderRole = false, - onToggleBottomSheet = { - if (playerUiState.currentStation != null) isPlayerExpanded = true - }, - onMoreClick = { } - ) - } + // Wide layout: the player is always visible in its own column on the right + if (showSidePlayer) { + VerticalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outline) + SidePanelPlayer( + playerViewModel = playerViewModel, + trackHistoryViewModel = trackHistoryViewModel, + modifier = Modifier.width(sidePanelWidth).fillMaxHeight() + ) } } } diff --git a/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt b/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt index 1e6fd2dd..a081968d 100644 --- a/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt +++ b/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.paging.compose.collectAsLazyPagingItems @@ -443,6 +444,177 @@ fun FullPlayer( } } +/** + * Permanent player column for the wide (landscape) layout, e.g. car tablets. + * Large centred artwork and text that can be read at a glance, and big touch targets. + * On short screens (small head units) the recent-tracks list is dropped so the + * controls always stay fully visible. + */ +@Composable +fun SidePanelPlayer( + playerViewModel: PlayerViewModel, + trackHistoryViewModel: TrackHistoryViewModel, + modifier: Modifier = Modifier +) { + val uiState by playerViewModel.uiState.collectAsState() + val historyFlow = remember(trackHistoryViewModel) { trackHistoryViewModel.allHistoryPaged } + val tracks = historyFlow.collectAsLazyPagingItems() + var trackWithOptions by remember { mutableStateOf(null) } + + val station = uiState.currentStation + val liveInfo = uiState.liveInfo + val songTitle = if (liveInfo.track.isNotEmpty()) liveInfo.track else if (liveInfo.title.isNotEmpty()) liveInfo.title else "" + val artistName = if (liveInfo.track.isNotEmpty()) liveInfo.artist else "" + val isPlaying = uiState.playState == PlayState.Playing || uiState.playState == PlayState.PrePlaying + + BoxWithConstraints(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) { + val showTrackList = maxHeight >= 480.dp + val iconSize = (maxHeight * 0.26f).coerceIn(64.dp, 140.dp) + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = if (showTrackList) Arrangement.Top else Arrangement.Center + ) { + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + StationIcon( + stationName = station?.Name ?: "", + stationUuid = station?.StationUuid ?: "", + iconUrl = station?.IconUrl, + modifier = Modifier.fillMaxSize() + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Fixed number of lines (" " placeholders) so the layout does not jump with metadata + Text( + text = (station?.Name ?: "").ifEmpty { " " }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = AmaradioAmber, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = songTitle.ifEmpty { " " }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + minLines = if (showTrackList) 2 else 1, + maxLines = if (showTrackList) 2 else 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = artistName.ifEmpty { " " }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Box(modifier = Modifier.padding(top = 4.dp), contentAlignment = Alignment.Center) { + PlayerStatusRow( + playState = uiState.playState, + bandwidthFlow = playerViewModel.bandwidth, + showText = true + ) + } + + if (showTrackList) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.tab_player_history), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + TrackList( + tracks = tracks, + onTrackClick = { }, + onTrackLongClick = { trackWithOptions = it }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outline) + + // Big controls: sized to fit the narrowest panel (300dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { playerViewModel.skipToPrevious() }, + modifier = Modifier.size(64.dp) + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = null, + modifier = Modifier.size(52.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + IconButton( + onClick = { playerViewModel.togglePlayPause() }, + modifier = Modifier.size(96.dp) + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.PauseCircle else Icons.Default.PlayCircle, + contentDescription = null, + modifier = Modifier.size(88.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + IconButton( + onClick = { playerViewModel.skipToNext() }, + modifier = Modifier.size(64.dp) + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = null, + modifier = Modifier.size(52.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + IconButton( + onClick = { playerViewModel.toggleFavorite() }, + modifier = Modifier.size(56.dp) + ) { + Icon( + imageVector = if (uiState.isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = if (uiState.isFavorite) AmaradioAmber else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + trackWithOptions?.let { track -> + TrackOptionsDialog( + track = track, + onDismiss = { trackWithOptions = null } + ) + } +} + @Composable fun PlayerStatusRow( From 318c4126b78aa569e29e2c2be9c45fffcfb64cb6 Mon Sep 17 00:00:00 2001 From: str0ng4le <83457499+str0ng4le@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:02:21 +0100 Subject: [PATCH 2/4] Landscape layout: reduce rail and side player control sizes - NavigationRail items use the standard Material sizes (24dp icons, no extra vertical padding). - SidePanelPlayer controls: play 96 -> 72dp, previous/next 64 -> 48dp, favourite 56 -> 44dp, with icons scaled accordingly. --- .../com/ounben/amaradio/ui/MainScreen.kt | 5 ++--- .../com/ounben/amaradio/ui/PlayerScreens.kt | 22 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt b/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt index 1066463a..38796e22 100644 --- a/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt +++ b/app/src/main/kotlin/com/ounben/amaradio/ui/MainScreen.kt @@ -234,7 +234,7 @@ fun MainScreen( ) { innerPadding -> Row(modifier = Modifier.fillMaxSize().padding(innerPadding)) { if (useWideLayout) { - // Landscape: vertical navigation with big, labelled targets, easy to hit while parked + // Landscape: vertical navigation with labelled targets (standard Material sizes) NavigationRail( containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface, @@ -243,7 +243,7 @@ fun MainScreen( Spacer(modifier = Modifier.weight(1f)) navItems.forEach { screen -> NavigationRailItem( - icon = { Icon(screen.icon, contentDescription = null, modifier = Modifier.size(32.dp)) }, + icon = { Icon(screen.icon, contentDescription = null) }, label = { Text(stringResource(screen.titleRes), maxLines = 1) }, selected = isNavItemSelected(screen), onClick = { @@ -254,7 +254,6 @@ fun MainScreen( } onNavItemClick(screen) }, - modifier = Modifier.padding(vertical = 8.dp), colors = NavigationRailItemDefaults.colors( selectedIconColor = AmaradioAmber, selectedTextColor = AmaradioAmber, diff --git a/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt b/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt index a081968d..99e68bd4 100644 --- a/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt +++ b/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt @@ -446,7 +446,7 @@ fun FullPlayer( /** * Permanent player column for the wide (landscape) layout, e.g. car tablets. - * Large centred artwork and text that can be read at a glance, and big touch targets. + * Large centred artwork and text that can be read at a glance, and comfortable touch targets. * On short screens (small head units) the recent-tracks list is dropped so the * controls always stay fully visible. */ @@ -548,58 +548,58 @@ fun SidePanelPlayer( HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outline) - // Big controls: sized to fit the narrowest panel (300dp) + // Controls: still comfortable touch targets (>= 44dp), but compact enough for small panels Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), + .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { IconButton( onClick = { playerViewModel.skipToPrevious() }, - modifier = Modifier.size(64.dp) + modifier = Modifier.size(48.dp) ) { Icon( imageVector = Icons.Default.SkipPrevious, contentDescription = null, - modifier = Modifier.size(52.dp), + modifier = Modifier.size(36.dp), tint = MaterialTheme.colorScheme.onSurface ) } IconButton( onClick = { playerViewModel.togglePlayPause() }, - modifier = Modifier.size(96.dp) + modifier = Modifier.size(72.dp) ) { Icon( imageVector = if (isPlaying) Icons.Default.PauseCircle else Icons.Default.PlayCircle, contentDescription = null, - modifier = Modifier.size(88.dp), + modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary ) } IconButton( onClick = { playerViewModel.skipToNext() }, - modifier = Modifier.size(64.dp) + modifier = Modifier.size(48.dp) ) { Icon( imageVector = Icons.Default.SkipNext, contentDescription = null, - modifier = Modifier.size(52.dp), + modifier = Modifier.size(36.dp), tint = MaterialTheme.colorScheme.onSurface ) } IconButton( onClick = { playerViewModel.toggleFavorite() }, - modifier = Modifier.size(56.dp) + modifier = Modifier.size(44.dp) ) { Icon( imageVector = if (uiState.isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline, contentDescription = null, - modifier = Modifier.size(36.dp), + modifier = Modifier.size(26.dp), tint = if (uiState.isFavorite) AmaradioAmber else MaterialTheme.colorScheme.onSurfaceVariant ) } From 40af2b359561f841844ddfefb82a7ab5fff5b536 Mon Sep 17 00:00:00 2001 From: str0ng4le <83457499+str0ng4le@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:02:21 +0100 Subject: [PATCH 3/4] README: mention landscape support under Dynamic UI Scaling --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b16171a..d09c0a1d 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Special thanks to the [RadioDroid](https://github.com/Segler-Alex/RadioDroid) pr - **Advanced Search & Filtering**: Find stations by Name, Country, Language, or Tags. Metadata for over **11,000 tags** is cached locally in a high-performance SQL database to enable instant offline suggestions. - **Community Support (Click Counting)**: Supports the global ranking of radio stations by reporting playback clicks directly to the official radio-browser.info API. -- **Dynamic UI Scaling**: Custom settings allow for the adjustment of the user interface size from Compact to Extra Large. +- **Dynamic UI Scaling**: Custom settings allow for the adjustment of the user interface size from Compact to Extra Large. The interface also adapts to landscape and other wide screens, such as car tablets and head units, with a side navigation rail and a player panel that stays visible next to the station lists. - **Broad Language Support**: Support for over **74 languages**, including a variety of African languages (Afrikaans, Amharic, Swahili, Zulu) with a dedicated in-app language selector. From 629ecbeb090aebc985838bafba7ff118a5a46222 Mon Sep 17 00:00:00 2001 From: str0ng4le <83457499+str0ng4le@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:34:59 +0100 Subject: [PATCH 4/4] SidePanelPlayer: confirm before removing a favourite Same confirmation dialog as FullPlayer (#156): tapping the filled star asks before removing the station from the favourites. --- .../com/ounben/amaradio/ui/PlayerScreens.kt | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt b/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt index 99e68bd4..56dd9609 100644 --- a/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt +++ b/app/src/main/kotlin/com/ounben/amaradio/ui/PlayerScreens.kt @@ -460,6 +460,7 @@ fun SidePanelPlayer( val historyFlow = remember(trackHistoryViewModel) { trackHistoryViewModel.allHistoryPaged } val tracks = historyFlow.collectAsLazyPagingItems() var trackWithOptions by remember { mutableStateOf(null) } + var showRemoveFavoriteDialog by remember { mutableStateOf(false) } val station = uiState.currentStation val liveInfo = uiState.liveInfo @@ -593,7 +594,14 @@ fun SidePanelPlayer( } IconButton( - onClick = { playerViewModel.toggleFavorite() }, + onClick = { + // Same as FullPlayer: removing a favourite asks for confirmation + if (uiState.isFavorite) { + showRemoveFavoriteDialog = true + } else { + playerViewModel.toggleFavorite() + } + }, modifier = Modifier.size(44.dp) ) { Icon( @@ -607,6 +615,49 @@ fun SidePanelPlayer( } } + if (showRemoveFavoriteDialog) { + AlertDialog( + onDismissRequest = { showRemoveFavoriteDialog = false }, + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + title = { + Text( + text = stringResource(R.string.detail_unstar), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Text( + text = "${stringResource(R.string.detail_unstar)}: ${station?.Name ?: ""}?", + color = MaterialTheme.colorScheme.onSurface + ) + }, + confirmButton = { + TextButton( + onClick = { + playerViewModel.toggleFavorite() + showRemoveFavoriteDialog = false + } + ) { + Text( + text = stringResource(R.string.action_ok), + fontWeight = FontWeight.Bold, + color = AmaradioAmber + ) + } + }, + dismissButton = { + TextButton(onClick = { showRemoveFavoriteDialog = false }) { + Text( + text = stringResource(R.string.action_cancel), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } + trackWithOptions?.let { track -> TrackOptionsDialog( track = track,