diff --git a/app/src/main/java/com/radiola/MainActivity.kt b/app/src/main/java/com/radiola/MainActivity.kt index 791c6f5..894ceca 100644 --- a/app/src/main/java/com/radiola/MainActivity.kt +++ b/app/src/main/java/com/radiola/MainActivity.kt @@ -78,9 +78,13 @@ class MainActivity : ComponentActivity() { .currentBackStackEntryAsState().value?.destination?.route val showChrome = currentRoute != NavDestinations.Auth.route + // Альбомная ориентация: вместо нижнего бара — боковой рейл слева, + // мини-плеер уезжает под контент. Портрет — прежняя раскладка. + val landscape = com.radiola.ui.util.isLandscape() + Scaffold( bottomBar = { - if (showChrome) { + if (showChrome && !landscape) { Column(Modifier.navigationBarsPadding()) { if (currentStation != null) { MiniPlayer( @@ -99,10 +103,20 @@ class MainActivity : ComponentActivity() { } } ) { paddingValues -> + Row( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .then(if (landscape) Modifier.displayCutoutPadding() else Modifier) + ) { + if (showChrome && landscape) { + com.radiola.ui.navigation.SideNavRail(navController) + } + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { NavHost( navController = navController, startDestination = startDestination, - modifier = Modifier.padding(paddingValues), + modifier = Modifier.weight(1f).fillMaxWidth(), enterTransition = { androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) + androidx.compose.animation.slideInVertically( @@ -160,6 +174,18 @@ class MainActivity : ComponentActivity() { ) } } + if (showChrome && landscape && currentStation != null) { + MiniPlayer( + stationName = currentStation!!.name, + track = currentTrack, + isPlaying = isPlaying, + onClick = { showPlayer = true }, + onPlayPause = { playerViewModel.togglePlayPause() } + ) + Spacer(Modifier.height(12.dp)) + } + } // конец контентной колонки + } // конец Row (рейл + контент) } if (showPlayer) { diff --git a/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt b/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt index d10926a..8a8ed69 100644 --- a/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt +++ b/app/src/main/java/com/radiola/ui/favorites/FavoritesScreen.kt @@ -82,7 +82,7 @@ fun FavoritesScreen( } } else { LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(if (com.radiola.ui.util.isLandscape()) 4 else 2), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), diff --git a/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt index 4f9db3d..b399974 100644 --- a/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt @@ -12,13 +12,19 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,20 +39,33 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.radiola.ui.theme.Motion import com.radiola.ui.theme.RadiolaTheme +// Обращаемся к объектам напрямую: companion-список NavDestinations.items +// при холодном старте может содержать null (порядок инициализации Kotlin). +private val navItems = listOf( + NavDestinations.Stations, + NavDestinations.Charts, + NavDestinations.Favorites, + NavDestinations.History, + NavDestinations.Recordings, + NavDestinations.Settings +) + +/** Переход на раздел с сохранением состояния (общий для нижнего бара и бокового рейла). */ +private fun NavController.navigateToTab(route: String, currentRoute: String?) { + if (currentRoute != route) { + navigate(route) { + popUpTo(graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } + } +} + @Composable fun BottomNavBar(navController: NavController) { val colors = RadiolaTheme.colors val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route - // Обращаемся к объектам напрямую: companion-список NavDestinations.items - // при холодном старте может содержать null (порядок инициализации Kotlin). - val items = listOf( - NavDestinations.Stations, - NavDestinations.Charts, - NavDestinations.Favorites, - NavDestinations.History, - NavDestinations.Recordings, - NavDestinations.Settings - ) + val items = navItems Row( modifier = Modifier @@ -67,20 +86,86 @@ fun BottomNavBar(navController: NavController) { icon = destination.icon, selected = selected, modifier = Modifier.weight(1f), - onClick = { - if (currentRoute != destination.route) { - navController.navigate(destination.route) { - popUpTo(navController.graph.startDestinationId) { saveState = true } - launchSingleTop = true - restoreState = true - } - } - } + onClick = { navController.navigateToTab(destination.route, currentRoute) } ) } } } +/** + * Боковой навигационный рейл для альбомной ориентации. + * Вертикальная капсула с теми же иконками-вкладками, что и нижний бар. + */ +@Composable +fun SideNavRail(navController: NavController) { + val colors = RadiolaTheme.colors + val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + + Column( + modifier = Modifier + .fillMaxHeight() + .statusBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp) + .width(64.dp) + .clip(RoundedCornerShape(32.dp)) + .background(colors.surface2) + .border(1.dp, colors.border, RoundedCornerShape(32.dp)) + .verticalScroll(rememberScrollState()) + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + navItems.forEach { destination -> + VerticalPillTab( + label = destination.labelRes, + icon = destination.icon, + selected = currentRoute == destination.route, + onClick = { navController.navigateToTab(destination.route, currentRoute) } + ) + } + } +} + +@Composable +private fun VerticalPillTab( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + selected: Boolean, + onClick: () -> Unit +) { + val colors = RadiolaTheme.colors + val bg by animateColorAsState( + targetValue = if (selected) colors.accent else androidx.compose.ui.graphics.Color.Transparent, + animationSpec = tween(Motion.Medium), + label = "railTabBg" + ) + val content by animateColorAsState( + targetValue = if (selected) colors.bgBase else colors.textSecondary, + animationSpec = tween(Motion.Medium), + label = "railTabFg" + ) + Row( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(26.dp)) + .background(bg) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = content, + modifier = Modifier.size(22.dp) + ) + } +} + @Composable private fun PillTab( label: String, diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 6b297fc..7c22e75 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -86,17 +86,12 @@ fun PlayerBottomSheet( val spectrum by viewModel.spectrum.collectAsState() val vizStyle by viewModel.visualizerStyle.collectAsState() - Column( - modifier = modifier - .fillMaxWidth() - .background(colors.bgBase) - .navigationBarsPadding() - // Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi) - // низ плеера (кнопка «Текст песни») не обрезался шторкой. - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + val landscape = com.radiola.ui.util.isLandscape() + + // ── Секции плеера как лямбды: переиспользуются в портретной (колонка) + // и альбомной (две панели) раскладках. ── + + val labelSection: @Composable () -> Unit = { // Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text( @@ -118,8 +113,9 @@ fun PlayerBottomSheet( ) } } - Spacer(Modifier.height(6.dp)) + } + val nameSection: @Composable () -> Unit = { // Название радиостанции — под меткой, над обложкой Text( text = station?.name ?: "", @@ -131,12 +127,13 @@ fun PlayerBottomSheet( textAlign = androidx.compose.ui.text.style.TextAlign.Center, modifier = Modifier.basicMarquee() ) - Spacer(Modifier.height(16.dp)) + } + val coverSection: @Composable (Dp) -> Unit = { coverSize -> // Обложка станции/трека Box( modifier = Modifier - .size(190.dp) + .size(coverSize) .clip(RoundedCornerShape(24.dp)) .background(colors.surface2), contentAlignment = Alignment.Center @@ -155,8 +152,9 @@ fun PlayerBottomSheet( ) } } - Spacer(Modifier.height(14.dp)) + } + val trackInfoSection: @Composable () -> Unit = { // Название трека и исполнитель с Crossfade при смене Crossfade( targetState = track?.song to track?.artist, @@ -183,8 +181,9 @@ fun PlayerBottomSheet( ) } } - Spacer(Modifier.height(20.dp)) + } + val visualizerSection: @Composable () -> Unit = { // Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать) com.radiola.ui.components.Visualizer( style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle), @@ -195,8 +194,9 @@ fun PlayerBottomSheet( .fillMaxWidth() .height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp) ) - Spacer(Modifier.height(16.dp)) + } + val controlsSection: @Composable () -> Unit = { // Управление воспроизведением Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), @@ -286,8 +286,9 @@ fun PlayerBottomSheet( } } } - Spacer(Modifier.height(20.dp)) + } + val servicesSection: @Composable () -> Unit = { // Ряд кнопок музыкальных сервисов if (enabledServices.isNotEmpty()) { LazyRow( @@ -307,16 +308,16 @@ fun PlayerBottomSheet( ) } } - Spacer(Modifier.height(12.dp)) } + } + val lyricsSection: @Composable () -> Unit = { // Кнопка «Текст песни» — активна только когда играет трек. // Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден. if (track != null) { val lyricsInteraction = remember { MutableInteractionSource() } Row( modifier = Modifier - .align(Alignment.CenterHorizontally) .clip(RoundedCornerShape(50)) .background(colors.surface2) .pressScale(interactionSource = lyricsInteraction) @@ -343,6 +344,77 @@ fun PlayerBottomSheet( } } + if (landscape) { + // Альбом: слева обложка с названием станции, справа — трек, эквалайзер, + // управление и сервисы (правая панель скроллится на низких экранах). + Row( + modifier = modifier + .fillMaxWidth() + .background(colors.bgBase) + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(0.42f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + labelSection() + Spacer(Modifier.height(6.dp)) + nameSection() + Spacer(Modifier.height(14.dp)) + coverSection(170.dp) + } + Spacer(Modifier.width(24.dp)) + Column( + modifier = Modifier + .weight(0.58f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + trackInfoSection() + Spacer(Modifier.height(16.dp)) + visualizerSection() + Spacer(Modifier.height(16.dp)) + controlsSection() + Spacer(Modifier.height(16.dp)) + servicesSection() + if (track != null) { + Spacer(Modifier.height(12.dp)) + lyricsSection() + } + } + } + } else { + Column( + modifier = modifier + .fillMaxWidth() + .background(colors.bgBase) + .navigationBarsPadding() + // Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi) + // низ плеера (кнопка «Текст песни») не обрезался шторкой. + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + labelSection() + Spacer(Modifier.height(6.dp)) + nameSection() + Spacer(Modifier.height(16.dp)) + coverSection(190.dp) + Spacer(Modifier.height(14.dp)) + trackInfoSection() + Spacer(Modifier.height(20.dp)) + visualizerSection() + Spacer(Modifier.height(16.dp)) + controlsSection() + Spacer(Modifier.height(20.dp)) + servicesSection() + if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp)) + lyricsSection() + } + } + // Шторка выбора качества if (showQuality && station != null) { val qualities = station.qualities diff --git a/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt index dcc3edb..b2c4b48 100644 --- a/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt +++ b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt @@ -56,18 +56,10 @@ fun RecordingPlayerSheet( viewModel.play(recording) } - Column( - modifier = Modifier - .fillMaxWidth() - // Отступ под системную навигацию — иначе список треков уходит под кнопки - .navigationBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(8.dp)) + val landscape = com.radiola.ui.util.isLandscape() + val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L) + val headerSection: @Composable () -> Unit = { // Метка «ЗАПИСЬ ЭФИРА» Text( text = "ЗАПИСЬ ЭФИРА", @@ -77,9 +69,7 @@ fun RecordingPlayerSheet( ), color = colors.accent ) - Spacer(modifier = Modifier.height(12.dp)) - // Название станции Text( text = recording.stationName, @@ -88,9 +78,7 @@ fun RecordingPlayerSheet( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(4.dp)) - // Трек (если есть) + дата val meta = buildString { if (!recording.trackName.isNullOrBlank()) { @@ -106,11 +94,10 @@ fun RecordingPlayerSheet( maxLines = 1, overflow = TextOverflow.Ellipsis ) + } - Spacer(modifier = Modifier.height(24.dp)) - + val seekSection: @Composable () -> Unit = { // Seekbar - val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L) Slider( value = positionMs.toFloat(), onValueChange = { viewModel.seekTo(it.toLong()) }, @@ -122,7 +109,6 @@ fun RecordingPlayerSheet( inactiveTrackColor = colors.surface2 ) ) - // Время: текущее и общее Row( modifier = Modifier.fillMaxWidth(), @@ -139,9 +125,9 @@ fun RecordingPlayerSheet( color = colors.textMuted ) } + } - Spacer(modifier = Modifier.height(24.dp)) - + val controlsSection: @Composable () -> Unit = { // Ряд управления: rewind15, play/pause, forward15 Row( verticalAlignment = Alignment.CenterVertically, @@ -210,40 +196,96 @@ fun RecordingPlayerSheet( ) } } + } - // Список треков записи с переходом по тайм-коду + // Заголовок + строки списка треков. modifier — чтобы в альбоме правая + // панель скроллилась отдельно. + val markersSection: @Composable (Modifier) -> Unit = { listModifier -> if (recording.markers.isNotEmpty()) { - Spacer(modifier = Modifier.height(28.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column(modifier = listModifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Треки в записи", + style = MaterialTheme.typography.titleSmall, + color = colors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "${recording.markers.size}", + style = MaterialTheme.typography.labelMedium, + color = colors.textMuted + ) + } + Spacer(modifier = Modifier.height(8.dp)) + // Индекс текущего трека: последняя метка, до которой уже дошло время + val activeIndex = recording.markers.indexOfLast { positionMs >= it.offsetMs } + recording.markers.forEachIndexed { index, marker -> + MarkerRow( + timecode = formatMs(marker.offsetMs), + title = marker.title, + active = index == activeIndex, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.seekTo(marker.offsetMs) + } + ) + } + } + } + } + + if (landscape) { + // Альбом: слева управление, справа — прокручиваемый список треков. + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 16.dp) + ) { + Column( + modifier = Modifier.weight(0.5f), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "Треки в записи", - style = MaterialTheme.typography.titleSmall, - color = colors.textPrimary, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "${recording.markers.size}", - style = MaterialTheme.typography.labelMedium, - color = colors.textMuted + headerSection() + Spacer(modifier = Modifier.height(20.dp)) + seekSection() + Spacer(modifier = Modifier.height(20.dp)) + controlsSection() + } + if (recording.markers.isNotEmpty()) { + Spacer(modifier = Modifier.width(24.dp)) + markersSection( + Modifier + .weight(0.5f) + .verticalScroll(rememberScrollState()) ) } + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + // Отступ под системную навигацию — иначе список треков уходит под кнопки + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { Spacer(modifier = Modifier.height(8.dp)) - // Индекс текущего трека: последняя метка, до которой уже дошло время - val activeIndex = recording.markers.indexOfLast { positionMs >= it.offsetMs } - recording.markers.forEachIndexed { index, marker -> - MarkerRow( - timecode = formatMs(marker.offsetMs), - title = marker.title, - active = index == activeIndex, - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - viewModel.seekTo(marker.offsetMs) - } - ) + headerSection() + Spacer(modifier = Modifier.height(24.dp)) + seekSection() + Spacer(modifier = Modifier.height(24.dp)) + controlsSection() + if (recording.markers.isNotEmpty()) { + Spacer(modifier = Modifier.height(28.dp)) + markersSection(Modifier.fillMaxWidth()) } } } diff --git a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt index cd3fdbc..81d4878 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsScreen.kt @@ -46,6 +46,8 @@ fun StationsScreen( val isPlaying by viewModel.isPlaying.collectAsState() val colors = RadiolaTheme.colors val haptics = LocalHapticFeedback.current + // В альбоме шире окно — больше колонок, иначе карточки растягиваются. + val gridColumns = if (com.radiola.ui.util.isLandscape()) 4 else 2 // Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему. val orderedTags = remember(tags) { listOf(null) + tags } @@ -136,7 +138,7 @@ fun StationsScreen( } else -> LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(gridColumns), modifier = Modifier.fillMaxSize(), // top = высота чипов: грид уходит ПОД них, свечение верхнего ряда // не обрезается и проступает за чипами. diff --git a/app/src/main/java/com/radiola/ui/util/Orientation.kt b/app/src/main/java/com/radiola/ui/util/Orientation.kt new file mode 100644 index 0000000..cf32550 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/util/Orientation.kt @@ -0,0 +1,16 @@ +package com.radiola.ui.util + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration + +/** + * Альбомная ориентация (ширина больше высоты). + * Используется для адаптивной раскладки: боковой nav-rail вместо нижнего бара, + * двухпанельный плеер, больше колонок в сетках станций. + */ +@Composable +@ReadOnlyComposable +fun isLandscape(): Boolean = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE