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

View File

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

View File

@@ -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
// Обращаемся к объектам напрямую: 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 @Composable
fun BottomNavBar(navController: NavController) { fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
// Обращаемся к объектам напрямую: companion-список NavDestinations.items val items = navItems
// при холодном старте может содержать null (порядок инициализации Kotlin).
val items = listOf(
NavDestinations.Stations,
NavDestinations.Charts,
NavDestinations.Favorites,
NavDestinations.History,
NavDestinations.Recordings,
NavDestinations.Settings
)
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,

View File

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

View File

@@ -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,40 +196,96 @@ 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,
verticalAlignment = Alignment.CenterVertically 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( headerSection()
text = "Треки в записи", Spacer(modifier = Modifier.height(20.dp))
style = MaterialTheme.typography.titleSmall, seekSection()
color = colors.textPrimary, Spacer(modifier = Modifier.height(20.dp))
fontWeight = FontWeight.SemiBold controlsSection()
) }
Text( if (recording.markers.isNotEmpty()) {
text = "${recording.markers.size}", Spacer(modifier = Modifier.width(24.dp))
style = MaterialTheme.typography.labelMedium, markersSection(
color = colors.textMuted 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)) Spacer(modifier = Modifier.height(8.dp))
// Индекс текущего трека: последняя метка, до которой уже дошло время headerSection()
val activeIndex = recording.markers.indexOfLast { positionMs >= it.offsetMs } Spacer(modifier = Modifier.height(24.dp))
recording.markers.forEachIndexed { index, marker -> seekSection()
MarkerRow( Spacer(modifier = Modifier.height(24.dp))
timecode = formatMs(marker.offsetMs), controlsSection()
title = marker.title, if (recording.markers.isNotEmpty()) {
active = index == activeIndex, Spacer(modifier = Modifier.height(28.dp))
onClick = { markersSection(Modifier.fillMaxWidth())
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.seekTo(marker.offsetMs)
}
)
} }
} }
} }

View File

@@ -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 = высота чипов: грид уходит ПОД них, свечение верхнего ряда
// не обрезается и проступает за чипами. // не обрезается и проступает за чипами.

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