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. 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..38796e22 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,234 @@ 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 labelled targets (standard Material sizes) + 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) }, + 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) } + 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..56dd9609 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,228 @@ 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 comfortable 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) } + var showRemoveFavoriteDialog by remember { mutableStateOf(false) } + + 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) + + // Controls: still comfortable touch targets (>= 44dp), but compact enough for small panels + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { playerViewModel.skipToPrevious() }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + IconButton( + onClick = { playerViewModel.togglePlayPause() }, + modifier = Modifier.size(72.dp) + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.PauseCircle else Icons.Default.PlayCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + IconButton( + onClick = { playerViewModel.skipToNext() }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + IconButton( + onClick = { + // Same as FullPlayer: removing a favourite asks for confirmation + if (uiState.isFavorite) { + showRemoveFavoriteDialog = true + } else { + playerViewModel.toggleFavorite() + } + }, + modifier = Modifier.size(44.dp) + ) { + Icon( + imageVector = if (uiState.isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = if (uiState.isFavorite) AmaradioAmber else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + 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, + onDismiss = { trackWithOptions = null } + ) + } +} + @Composable fun PlayerStatusRow(