feat(orientation): полноценная поддержка альбомной ориентации

- боковой nav-rail слева вместо нижнего бара в альбоме (SideNavRail)
- мини-плеер уезжает под контент в альбомной раскладке
- плеер эфира: двухпанельный (обложка слева, инфо/эквалайзер/контролы справа)
- плеер записи: слева управление, справа прокручиваемый список треков
- сетки станций и избранного: 4 колонки в альбоме вместо 2
- хелпер isLandscape() через LocalConfiguration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 20:19:47 +03:00
parent fabf780450
commit 06cb6c16f1
7 changed files with 335 additions and 92 deletions

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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,

View File

@@ -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

View File

@@ -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())
}
}
}

View File

@@ -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<String?>(null) + tags }
@@ -136,7 +138,7 @@ fun StationsScreen(
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
columns = GridCells.Fixed(gridColumns),
modifier = Modifier.fillMaxSize(),
// top = высота чипов: грид уходит ПОД них, свечение верхнего ряда
// не обрезается и проступает за чипами.

View File

@@ -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