feat(orientation): полноценная поддержка альбомной ориентации
- боковой nav-rail слева вместо нижнего бара в альбоме (SideNavRail) - мини-плеер уезжает под контент в альбомной раскладке - плеер эфира: двухпанельный (обложка слева, инфо/эквалайзер/контролы справа) - плеер записи: слева управление, справа прокручиваемый список треков - сетки станций и избранного: 4 колонки в альбоме вместо 2 - хелпер isLandscape() через LocalConfiguration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -78,9 +78,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
.currentBackStackEntryAsState().value?.destination?.route
|
.currentBackStackEntryAsState().value?.destination?.route
|
||||||
val showChrome = currentRoute != NavDestinations.Auth.route
|
val showChrome = currentRoute != NavDestinations.Auth.route
|
||||||
|
|
||||||
|
// Альбомная ориентация: вместо нижнего бара — боковой рейл слева,
|
||||||
|
// мини-плеер уезжает под контент. Портрет — прежняя раскладка.
|
||||||
|
val landscape = com.radiola.ui.util.isLandscape()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (showChrome) {
|
if (showChrome && !landscape) {
|
||||||
Column(Modifier.navigationBarsPadding()) {
|
Column(Modifier.navigationBarsPadding()) {
|
||||||
if (currentStation != null) {
|
if (currentStation != null) {
|
||||||
MiniPlayer(
|
MiniPlayer(
|
||||||
@@ -99,10 +103,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { 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(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) +
|
androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) +
|
||||||
androidx.compose.animation.slideInVertically(
|
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) {
|
if (showPlayer) {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ fun FavoritesScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(if (com.radiola.ui.util.isLandscape()) 4 else 2),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
|||||||
@@ -12,13 +12,19 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Motion
|
||||||
import com.radiola.ui.theme.RadiolaTheme
|
import com.radiola.ui.theme.RadiolaTheme
|
||||||
|
|
||||||
@Composable
|
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
|
||||||
fun BottomNavBar(navController: NavController) {
|
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
||||||
val colors = RadiolaTheme.colors
|
private val navItems = listOf(
|
||||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
|
||||||
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
|
|
||||||
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
|
||||||
val items = listOf(
|
|
||||||
NavDestinations.Stations,
|
NavDestinations.Stations,
|
||||||
NavDestinations.Charts,
|
NavDestinations.Charts,
|
||||||
NavDestinations.Favorites,
|
NavDestinations.Favorites,
|
||||||
NavDestinations.History,
|
NavDestinations.History,
|
||||||
NavDestinations.Recordings,
|
NavDestinations.Recordings,
|
||||||
NavDestinations.Settings
|
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
|
||||||
|
val items = navItems
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -67,20 +86,86 @@ fun BottomNavBar(navController: NavController) {
|
|||||||
icon = destination.icon,
|
icon = destination.icon,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = {
|
onClick = { navController.navigateToTab(destination.route, currentRoute) }
|
||||||
if (currentRoute != destination.route) {
|
|
||||||
navController.navigate(destination.route) {
|
|
||||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Боковой навигационный рейл для альбомной ориентации.
|
||||||
|
* Вертикальная капсула с теми же иконками-вкладками, что и нижний бар.
|
||||||
|
*/
|
||||||
|
@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
|
@Composable
|
||||||
private fun PillTab(
|
private fun PillTab(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
@@ -86,17 +86,12 @@ fun PlayerBottomSheet(
|
|||||||
val spectrum by viewModel.spectrum.collectAsState()
|
val spectrum by viewModel.spectrum.collectAsState()
|
||||||
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
||||||
|
|
||||||
Column(
|
val landscape = com.radiola.ui.util.isLandscape()
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
// ── Секции плеера как лямбды: переиспользуются в портретной (колонка)
|
||||||
.background(colors.bgBase)
|
// и альбомной (две панели) раскладках. ──
|
||||||
.navigationBarsPadding()
|
|
||||||
// Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi)
|
val labelSection: @Composable () -> Unit = {
|
||||||
// низ плеера (кнопка «Текст песни») не обрезался шторкой.
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
|
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
@@ -118,8 +113,9 @@ fun PlayerBottomSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
}
|
||||||
|
|
||||||
|
val nameSection: @Composable () -> Unit = {
|
||||||
// Название радиостанции — под меткой, над обложкой
|
// Название радиостанции — под меткой, над обложкой
|
||||||
Text(
|
Text(
|
||||||
text = station?.name ?: "",
|
text = station?.name ?: "",
|
||||||
@@ -131,12 +127,13 @@ fun PlayerBottomSheet(
|
|||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
modifier = Modifier.basicMarquee()
|
modifier = Modifier.basicMarquee()
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
}
|
||||||
|
|
||||||
|
val coverSection: @Composable (Dp) -> Unit = { coverSize ->
|
||||||
// Обложка станции/трека
|
// Обложка станции/трека
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(190.dp)
|
.size(coverSize)
|
||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
.background(colors.surface2),
|
.background(colors.surface2),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -155,8 +152,9 @@ fun PlayerBottomSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(14.dp))
|
}
|
||||||
|
|
||||||
|
val trackInfoSection: @Composable () -> Unit = {
|
||||||
// Название трека и исполнитель с Crossfade при смене
|
// Название трека и исполнитель с Crossfade при смене
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = track?.song to track?.artist,
|
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(
|
com.radiola.ui.components.Visualizer(
|
||||||
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
||||||
@@ -195,8 +194,9 @@ fun PlayerBottomSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp)
|
.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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
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()) {
|
if (enabledServices.isNotEmpty()) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -307,16 +308,16 @@ fun PlayerBottomSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val lyricsSection: @Composable () -> Unit = {
|
||||||
// Кнопка «Текст песни» — активна только когда играет трек.
|
// Кнопка «Текст песни» — активна только когда играет трек.
|
||||||
// Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден.
|
// Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден.
|
||||||
if (track != null) {
|
if (track != null) {
|
||||||
val lyricsInteraction = remember { MutableInteractionSource() }
|
val lyricsInteraction = remember { MutableInteractionSource() }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterHorizontally)
|
|
||||||
.clip(RoundedCornerShape(50))
|
.clip(RoundedCornerShape(50))
|
||||||
.background(colors.surface2)
|
.background(colors.surface2)
|
||||||
.pressScale(interactionSource = lyricsInteraction)
|
.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) {
|
if (showQuality && station != null) {
|
||||||
val qualities = station.qualities
|
val qualities = station.qualities
|
||||||
|
|||||||
@@ -56,18 +56,10 @@ fun RecordingPlayerSheet(
|
|||||||
viewModel.play(recording)
|
viewModel.play(recording)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
val landscape = com.radiola.ui.util.isLandscape()
|
||||||
modifier = Modifier
|
val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L)
|
||||||
.fillMaxWidth()
|
|
||||||
// Отступ под системную навигацию — иначе список треков уходит под кнопки
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
|
val headerSection: @Composable () -> Unit = {
|
||||||
// Метка «ЗАПИСЬ ЭФИРА»
|
// Метка «ЗАПИСЬ ЭФИРА»
|
||||||
Text(
|
Text(
|
||||||
text = "ЗАПИСЬ ЭФИРА",
|
text = "ЗАПИСЬ ЭФИРА",
|
||||||
@@ -77,9 +69,7 @@ fun RecordingPlayerSheet(
|
|||||||
),
|
),
|
||||||
color = colors.accent
|
color = colors.accent
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Название станции
|
// Название станции
|
||||||
Text(
|
Text(
|
||||||
text = recording.stationName,
|
text = recording.stationName,
|
||||||
@@ -88,9 +78,7 @@ fun RecordingPlayerSheet(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
// Трек (если есть) + дата
|
// Трек (если есть) + дата
|
||||||
val meta = buildString {
|
val meta = buildString {
|
||||||
if (!recording.trackName.isNullOrBlank()) {
|
if (!recording.trackName.isNullOrBlank()) {
|
||||||
@@ -106,11 +94,10 @@ fun RecordingPlayerSheet(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
val seekSection: @Composable () -> Unit = {
|
||||||
|
|
||||||
// Seekbar
|
// Seekbar
|
||||||
val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L)
|
|
||||||
Slider(
|
Slider(
|
||||||
value = positionMs.toFloat(),
|
value = positionMs.toFloat(),
|
||||||
onValueChange = { viewModel.seekTo(it.toLong()) },
|
onValueChange = { viewModel.seekTo(it.toLong()) },
|
||||||
@@ -122,7 +109,6 @@ fun RecordingPlayerSheet(
|
|||||||
inactiveTrackColor = colors.surface2
|
inactiveTrackColor = colors.surface2
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Время: текущее и общее
|
// Время: текущее и общее
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -139,9 +125,9 @@ fun RecordingPlayerSheet(
|
|||||||
color = colors.textMuted
|
color = colors.textMuted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
val controlsSection: @Composable () -> Unit = {
|
||||||
|
|
||||||
// Ряд управления: rewind15, play/pause, forward15
|
// Ряд управления: rewind15, play/pause, forward15
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -210,10 +196,13 @@ fun RecordingPlayerSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Список треков записи с переходом по тайм-коду
|
// Заголовок + строки списка треков. modifier — чтобы в альбоме правая
|
||||||
|
// панель скроллилась отдельно.
|
||||||
|
val markersSection: @Composable (Modifier) -> Unit = { listModifier ->
|
||||||
if (recording.markers.isNotEmpty()) {
|
if (recording.markers.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(28.dp))
|
Column(modifier = listModifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -247,6 +236,59 @@ fun RecordingPlayerSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Строка трека в записи: тайм-код + название, тап → переход. */
|
/** Строка трека в записи: тайм-код + название, тап → переход. */
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ fun StationsScreen(
|
|||||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||||
val colors = RadiolaTheme.colors
|
val colors = RadiolaTheme.colors
|
||||||
val haptics = LocalHapticFeedback.current
|
val haptics = LocalHapticFeedback.current
|
||||||
|
// В альбоме шире окно — больше колонок, иначе карточки растягиваются.
|
||||||
|
val gridColumns = if (com.radiola.ui.util.isLandscape()) 4 else 2
|
||||||
|
|
||||||
// Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему.
|
// Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему.
|
||||||
val orderedTags = remember(tags) { listOf<String?>(null) + tags }
|
val orderedTags = remember(tags) { listOf<String?>(null) + tags }
|
||||||
@@ -136,7 +138,7 @@ fun StationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> LazyVerticalGrid(
|
else -> LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(gridColumns),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
// top = высота чипов: грид уходит ПОД них, свечение верхнего ряда
|
// top = высота чипов: грид уходит ПОД них, свечение верхнего ряда
|
||||||
// не обрезается и проступает за чипами.
|
// не обрезается и проступает за чипами.
|
||||||
|
|||||||
16
app/src/main/java/com/radiola/ui/util/Orientation.kt
Normal file
16
app/src/main/java/com/radiola/ui/util/Orientation.kt
Normal 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
|
||||||
Reference in New Issue
Block a user