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

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