track == null почти не выполнялось: «безымянные» станции шлют ICY-строку без разделителя → parseIcyTitle делает трек с пустым artist. Показываем кнопку, когда нет РЕАЛЬНОГО трека (track null ИЛИ пустой artist/song ИЛИ song == имя станции). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
882 lines
35 KiB
Kotlin
882 lines
35 KiB
Kotlin
package com.radiola.ui.player
|
||
|
||
import android.util.Log
|
||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||
import androidx.compose.foundation.basicMarquee
|
||
import androidx.compose.animation.Crossfade
|
||
import androidx.compose.animation.animateColorAsState
|
||
import androidx.compose.animation.core.tween
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.clickable
|
||
import androidx.compose.foundation.rememberScrollState
|
||
import androidx.compose.foundation.verticalScroll
|
||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||
import androidx.compose.foundation.layout.*
|
||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||
import androidx.compose.foundation.lazy.LazyRow
|
||
import androidx.compose.foundation.lazy.items
|
||
import androidx.compose.foundation.shape.CircleShape
|
||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||
import androidx.compose.material3.*
|
||
import androidx.compose.runtime.*
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.draw.clip
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.layout.ContentScale
|
||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||
import androidx.compose.ui.platform.LocalContext
|
||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||
import androidx.compose.ui.text.font.FontWeight
|
||
import androidx.compose.ui.text.style.TextOverflow
|
||
import androidx.compose.ui.unit.Dp
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.compose.ui.unit.sp
|
||
import androidx.hilt.navigation.compose.hiltViewModel
|
||
import coil.compose.AsyncImage
|
||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||
import androidx.compose.material3.ModalBottomSheet
|
||
import androidx.compose.material3.rememberModalBottomSheetState
|
||
import com.composables.icons.lucide.Check
|
||
import com.composables.icons.lucide.FileText
|
||
import com.composables.icons.lucide.Heart
|
||
import com.composables.icons.lucide.Lucide
|
||
import com.composables.icons.lucide.Mic
|
||
import com.composables.icons.lucide.MicOff
|
||
import com.composables.icons.lucide.Moon
|
||
import com.composables.icons.lucide.Music
|
||
import com.composables.icons.lucide.Pause
|
||
import com.composables.icons.lucide.Play
|
||
import com.composables.icons.lucide.Radio
|
||
import com.composables.icons.lucide.SkipBack
|
||
import com.composables.icons.lucide.SkipForward
|
||
import com.composables.icons.lucide.SlidersHorizontal
|
||
import com.radiola.deeplink.DeeplinkNavigator
|
||
import com.radiola.domain.model.DeeplinkService
|
||
import com.radiola.domain.model.Station
|
||
import com.radiola.domain.model.Track
|
||
import com.radiola.ui.lyrics.LyricsSheet
|
||
import com.radiola.ui.theme.LiveEqualizer
|
||
import com.radiola.ui.theme.Motion
|
||
import com.radiola.ui.theme.RadiolaTheme
|
||
import com.radiola.ui.theme.pressScale
|
||
|
||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||
@Composable
|
||
fun PlayerBottomSheet(
|
||
station: Station?,
|
||
track: Track?,
|
||
isPlaying: Boolean,
|
||
onPlayPause: () -> Unit,
|
||
onNext: () -> Unit,
|
||
onPrevious: () -> Unit,
|
||
isFavorite: Boolean,
|
||
onToggleFavorite: () -> Unit,
|
||
isRecording: Boolean,
|
||
onToggleRecording: () -> Unit,
|
||
modifier: Modifier = Modifier,
|
||
viewModel: PlayerViewModel = hiltViewModel()
|
||
) {
|
||
val context = LocalContext.current
|
||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||
val recognizing by viewModel.recognizing.collectAsState()
|
||
val colors = RadiolaTheme.colors
|
||
val haptics = LocalHapticFeedback.current
|
||
|
||
LaunchedEffect(Unit) {
|
||
viewModel.recognizeEvent.collect { msg ->
|
||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||
}
|
||
}
|
||
var showLyrics by remember { mutableStateOf(false) }
|
||
var showQuality by remember { mutableStateOf(false) }
|
||
var showSleep by remember { mutableStateOf(false) }
|
||
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
|
||
val currentQuality by viewModel.currentQuality.collectAsState()
|
||
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
|
||
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
||
|
||
val landscape = com.radiola.ui.util.isLandscape()
|
||
|
||
// ── Секции плеера как лямбды: переиспользуются в портретной (колонка)
|
||
// и альбомной (две панели) раскладках. ──
|
||
|
||
val labelSection: @Composable () -> Unit = {
|
||
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
|
||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||
Text(
|
||
text = "В ЭФИРЕ",
|
||
style = MaterialTheme.typography.labelSmall,
|
||
color = colors.accent,
|
||
letterSpacing = 2.sp,
|
||
fontWeight = FontWeight.SemiBold
|
||
)
|
||
val qualities = station?.qualities.orEmpty()
|
||
if (qualities.size >= 2) {
|
||
QualityChip(
|
||
label = "${(currentQuality?.bitrate ?: qualities.first().bitrate)}k",
|
||
onClick = {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
showQuality = true
|
||
},
|
||
modifier = Modifier.align(Alignment.CenterEnd)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
val nameSection: @Composable () -> Unit = {
|
||
// Название радиостанции — под меткой, над обложкой
|
||
Text(
|
||
text = station?.name ?: "",
|
||
style = MaterialTheme.typography.titleLarge,
|
||
color = colors.textPrimary,
|
||
fontWeight = FontWeight.SemiBold,
|
||
maxLines = 1,
|
||
overflow = TextOverflow.Ellipsis,
|
||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||
modifier = Modifier.basicMarquee()
|
||
)
|
||
}
|
||
|
||
val coverSection: @Composable (Dp) -> Unit = { coverSize ->
|
||
// Обложка станции/трека
|
||
Box(
|
||
modifier = Modifier
|
||
.size(coverSize)
|
||
.clip(RoundedCornerShape(24.dp))
|
||
.background(colors.surface2),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
val coverModel = track?.coverUrl ?: station?.coverUrl
|
||
com.radiola.ui.components.FlipCover(
|
||
model = coverModel,
|
||
contentDescription = station?.name,
|
||
modifier = Modifier.fillMaxSize()
|
||
) {
|
||
Icon(
|
||
imageVector = Lucide.Radio,
|
||
contentDescription = null,
|
||
tint = colors.textMuted,
|
||
modifier = Modifier.size(56.dp)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
val trackInfoSection: @Composable () -> Unit = {
|
||
// Название трека и исполнитель с Crossfade при смене
|
||
Crossfade(
|
||
targetState = track?.song to track?.artist,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "trackInfo"
|
||
) { (song, artist) ->
|
||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||
Text(
|
||
text = song ?: (station?.name ?: ""),
|
||
style = MaterialTheme.typography.headlineLarge,
|
||
color = colors.textPrimary,
|
||
maxLines = 1,
|
||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||
modifier = Modifier.basicMarquee()
|
||
)
|
||
Spacer(Modifier.height(4.dp))
|
||
Text(
|
||
text = artist ?: (station?.genre ?: ""),
|
||
style = MaterialTheme.typography.bodyLarge,
|
||
color = colors.textSecondary,
|
||
maxLines = 1,
|
||
overflow = TextOverflow.Ellipsis,
|
||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Кнопка распознавания (Shazam) — только для музыкальных станций без РЕАЛЬНЫХ
|
||
// метаданных эфира. «Безымянные» станции часто шлют ICY-строку (слоган/название)
|
||
// без разделителя → parseIcyTitle делает трек с ПУСТЫМ исполнителем; такой трек
|
||
// и есть «нет названия» → кнопку показываем. Настоящий «Исполнитель — Трек»
|
||
// (artist и song заполнены) → кнопка скрыта.
|
||
val recognizeSection: @Composable () -> Unit = {
|
||
val noRealTrack = track == null ||
|
||
track.artist.isBlank() ||
|
||
track.song.isBlank() ||
|
||
track.song == station?.name
|
||
val show = station != null &&
|
||
noRealTrack &&
|
||
com.radiola.domain.model.MusicGenres.isMusicStation(station.genre)
|
||
if (show) {
|
||
val interaction = remember { MutableInteractionSource() }
|
||
Row(
|
||
modifier = Modifier
|
||
.clip(RoundedCornerShape(50))
|
||
.background(colors.accent.copy(alpha = 0.15f))
|
||
.pressScale(interactionSource = interaction)
|
||
.clickable(
|
||
interactionSource = interaction,
|
||
indication = null,
|
||
enabled = !recognizing
|
||
) {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
viewModel.recognizeCurrentTrack()
|
||
}
|
||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
if (recognizing) {
|
||
CircularProgressIndicator(
|
||
color = colors.accent,
|
||
strokeWidth = 2.dp,
|
||
modifier = Modifier.size(18.dp)
|
||
)
|
||
} else {
|
||
Icon(
|
||
imageVector = Lucide.Mic,
|
||
contentDescription = null,
|
||
tint = colors.accent,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
}
|
||
Text(
|
||
text = if (recognizing) "Распознаём…" else "Распознать трек",
|
||
color = colors.accent,
|
||
fontSize = 15.sp,
|
||
fontWeight = FontWeight.Medium
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
val visualizerSection: @Composable () -> Unit = {
|
||
// Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost —
|
||
// чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf.
|
||
VisualizerHost(
|
||
viewModel = viewModel,
|
||
vizStyle = vizStyle,
|
||
playing = isPlaying,
|
||
color = colors.accent,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp)
|
||
)
|
||
}
|
||
|
||
val controlsSection: @Composable () -> Unit = {
|
||
// Управление воспроизведением
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||
horizontalArrangement = Arrangement.SpaceBetween,
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
// Кнопка избранного
|
||
val heartTint by animateColorAsState(
|
||
targetValue = if (isFavorite) colors.accent else colors.textSecondary,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "heartTint"
|
||
)
|
||
PlayerIconBtn(size = 48.dp) {
|
||
IconButton(
|
||
onClick = {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
onToggleFavorite()
|
||
},
|
||
modifier = Modifier.size(48.dp)
|
||
) {
|
||
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(24.dp))
|
||
}
|
||
}
|
||
|
||
// Кнопка «предыдущая станция»
|
||
PlayerIconBtn(size = 48.dp) {
|
||
IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) {
|
||
Icon(Lucide.SkipBack, "Предыдущая", tint = colors.textPrimary, modifier = Modifier.size(24.dp))
|
||
}
|
||
}
|
||
|
||
// Главная кнопка play/pause
|
||
val playInteraction = remember { MutableInteractionSource() }
|
||
Box(
|
||
modifier = Modifier
|
||
.size(68.dp)
|
||
.clip(CircleShape)
|
||
.background(colors.accent)
|
||
.pressScale(interactionSource = playInteraction)
|
||
.clickable(interactionSource = playInteraction, indication = null) {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
onPlayPause()
|
||
},
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
Crossfade(
|
||
targetState = isPlaying,
|
||
animationSpec = tween(Motion.Fast),
|
||
label = "playPause"
|
||
) { playing ->
|
||
Icon(
|
||
imageVector = if (playing) Lucide.Pause else Lucide.Play,
|
||
contentDescription = if (playing) "Пауза" else "Воспроизвести",
|
||
tint = colors.bgBase,
|
||
modifier = Modifier.size(30.dp)
|
||
)
|
||
}
|
||
}
|
||
|
||
// Кнопка «следующая станция»
|
||
PlayerIconBtn(size = 48.dp) {
|
||
IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) {
|
||
Icon(Lucide.SkipForward, "Следующая", tint = colors.textPrimary, modifier = Modifier.size(24.dp))
|
||
}
|
||
}
|
||
|
||
// Кнопка записи
|
||
val recordTint by animateColorAsState(
|
||
targetValue = if (isRecording) colors.live else colors.textSecondary,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "recordTint"
|
||
)
|
||
PlayerIconBtn(size = 48.dp) {
|
||
IconButton(onClick = onToggleRecording, modifier = Modifier.size(48.dp)) {
|
||
Crossfade(
|
||
targetState = isRecording,
|
||
animationSpec = tween(Motion.Fast),
|
||
label = "recordIcon"
|
||
) { recording ->
|
||
Icon(
|
||
imageVector = if (recording) Lucide.MicOff else Lucide.Mic,
|
||
contentDescription = if (recording) "Остановить запись" else "Запись",
|
||
tint = recordTint,
|
||
modifier = Modifier.size(24.dp)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
val servicesSection: @Composable () -> Unit = {
|
||
// Ряд кнопок музыкальных сервисов
|
||
if (enabledServices.isNotEmpty()) {
|
||
LazyRow(
|
||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||
modifier = Modifier.fillMaxWidth(),
|
||
contentPadding = PaddingValues(horizontal = 4.dp)
|
||
) {
|
||
items(enabledServices) { service ->
|
||
ServiceDeeplinkBtn(
|
||
service = service,
|
||
onClick = {
|
||
track?.let { t ->
|
||
Log.d("PlayerBottomSheet", "Deeplink: ${t.artist} - ${t.song}")
|
||
DeeplinkNavigator.openSearch(context, t, service)
|
||
} ?: Log.d("PlayerBottomSheet", "Deeplink нажат, но трек null")
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
val lyricsSection: @Composable () -> Unit = {
|
||
// Кнопка «Текст песни» — активна только когда играет трек.
|
||
// Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден.
|
||
if (track != null) {
|
||
val lyricsInteraction = remember { MutableInteractionSource() }
|
||
Row(
|
||
modifier = Modifier
|
||
.clip(RoundedCornerShape(50))
|
||
.background(colors.surface2)
|
||
.pressScale(interactionSource = lyricsInteraction)
|
||
.clickable(interactionSource = lyricsInteraction, indication = null) {
|
||
showLyrics = true
|
||
}
|
||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
Icon(
|
||
imageVector = Lucide.FileText,
|
||
contentDescription = null,
|
||
tint = colors.accent,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
Text(
|
||
text = "Текст песни",
|
||
color = colors.accent,
|
||
fontSize = 15.sp,
|
||
fontWeight = FontWeight.Medium
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Кнопка таймера сна. Активен → подсветка акцентом + оставшееся время MM:SS.
|
||
val sleepSection: @Composable () -> Unit = {
|
||
val active = sleepRemainingMs != null
|
||
val sleepInteraction = remember { MutableInteractionSource() }
|
||
Row(
|
||
modifier = Modifier
|
||
.clip(RoundedCornerShape(50))
|
||
.background(if (active) colors.accent.copy(alpha = 0.15f) else colors.surface2)
|
||
.pressScale(interactionSource = sleepInteraction)
|
||
.clickable(interactionSource = sleepInteraction, indication = null) {
|
||
showSleep = true
|
||
}
|
||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
Icon(
|
||
imageVector = Lucide.Moon,
|
||
contentDescription = null,
|
||
tint = if (active) colors.accent else colors.textSecondary,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
Text(
|
||
text = sleepRemainingMs?.let { "Сон · ${formatSleep(it)}" } ?: "Таймер сна",
|
||
color = if (active) colors.accent else colors.textSecondary,
|
||
fontSize = 15.sp,
|
||
fontWeight = FontWeight.Medium
|
||
)
|
||
}
|
||
}
|
||
|
||
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()
|
||
recognizeSection()
|
||
Spacer(Modifier.height(16.dp))
|
||
visualizerSection()
|
||
Spacer(Modifier.height(16.dp))
|
||
controlsSection()
|
||
Spacer(Modifier.height(16.dp))
|
||
servicesSection()
|
||
Spacer(Modifier.height(12.dp))
|
||
sleepSection()
|
||
if (track != null) {
|
||
Spacer(Modifier.height(10.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()
|
||
recognizeSection()
|
||
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))
|
||
sleepSection()
|
||
Spacer(Modifier.height(10.dp))
|
||
lyricsSection()
|
||
}
|
||
}
|
||
|
||
// Шторка выбора качества
|
||
if (showQuality && station != null) {
|
||
val qualities = station.qualities
|
||
ModalBottomSheet(
|
||
onDismissRequest = { showQuality = false },
|
||
containerColor = colors.bgBase,
|
||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||
) {
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.navigationBarsPadding()
|
||
.padding(horizontal = 24.dp)
|
||
.padding(bottom = 16.dp)
|
||
) {
|
||
Text(
|
||
text = "Качество звука",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary,
|
||
fontWeight = FontWeight.SemiBold,
|
||
modifier = Modifier.padding(vertical = 12.dp)
|
||
)
|
||
qualities.forEach { q ->
|
||
QualityRow(
|
||
quality = q,
|
||
selected = currentQuality?.bitrate == q.bitrate,
|
||
onClick = {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
viewModel.selectQuality(q)
|
||
showQuality = false
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Шторка текста песни
|
||
if (showLyrics && track != null) {
|
||
ModalBottomSheet(
|
||
onDismissRequest = { showLyrics = false },
|
||
containerColor = colors.bgBase,
|
||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||
) {
|
||
LyricsSheet(
|
||
artist = track.artist,
|
||
song = track.song
|
||
)
|
||
}
|
||
}
|
||
|
||
// Шторка таймера сна
|
||
if (showSleep) {
|
||
ModalBottomSheet(
|
||
onDismissRequest = { showSleep = false },
|
||
containerColor = colors.bgBase,
|
||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||
) {
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.navigationBarsPadding()
|
||
.padding(horizontal = 24.dp)
|
||
.padding(bottom = 16.dp)
|
||
) {
|
||
Text(
|
||
text = "Таймер сна",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary,
|
||
fontWeight = FontWeight.SemiBold,
|
||
modifier = Modifier.padding(vertical = 12.dp)
|
||
)
|
||
Text(
|
||
text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = colors.textSecondary,
|
||
modifier = Modifier.padding(bottom = 12.dp)
|
||
)
|
||
// Выбор звука для сна: радио плавно перетечёт в выбранный шум.
|
||
LazyRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.padding(bottom = 12.dp)
|
||
) {
|
||
item {
|
||
SoundChip("Без звука", selectedSound == null) { selectedSound = null }
|
||
}
|
||
items(com.radiola.service.SleepSound.entries) { snd ->
|
||
SoundChip(snd.title, selectedSound == snd) { selectedSound = snd }
|
||
}
|
||
}
|
||
// Если активен — показываем остаток и кнопку отмены
|
||
if (sleepRemainingMs != null) {
|
||
SleepRow(
|
||
label = "Осталось ${formatSleep(sleepRemainingMs!!)}",
|
||
selected = true,
|
||
onClick = {
|
||
viewModel.cancelSleepTimer()
|
||
showSleep = false
|
||
},
|
||
trailing = "Выключить"
|
||
)
|
||
Spacer(Modifier.height(4.dp))
|
||
}
|
||
listOf(15, 30, 45, 60, 90, 120).forEach { min ->
|
||
SleepRow(
|
||
label = "$min минут",
|
||
selected = false,
|
||
onClick = {
|
||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||
viewModel.startSleepTimer(min, selectedSound)
|
||
showSleep = false
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Строка выбора интервала таймера сна. */
|
||
@Composable
|
||
private fun SleepRow(
|
||
label: String,
|
||
selected: Boolean,
|
||
onClick: () -> Unit,
|
||
trailing: String? = null
|
||
) {
|
||
val colors = RadiolaTheme.colors
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(14.dp))
|
||
.clickable(onClick = onClick)
|
||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Icon(
|
||
imageVector = Lucide.Moon,
|
||
contentDescription = null,
|
||
tint = if (selected) colors.accent else colors.textSecondary,
|
||
modifier = Modifier.size(18.dp)
|
||
)
|
||
Spacer(Modifier.width(12.dp))
|
||
Text(
|
||
text = label,
|
||
style = MaterialTheme.typography.bodyLarge,
|
||
color = if (selected) colors.accent else colors.textPrimary,
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
if (trailing != null) {
|
||
Text(
|
||
text = trailing,
|
||
style = MaterialTheme.typography.labelLarge,
|
||
color = colors.live,
|
||
fontWeight = FontWeight.SemiBold
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Leaf-обёртка эквалайзера: сама собирает спектр (обновляется ~45/с) и включает
|
||
* расчёт FFT только пока скомпонована (открыт плеер) — это и изолирует частые
|
||
* рекомпозиции от остального плеера, и гасит FFT в фоне (батарея).
|
||
*/
|
||
@Composable
|
||
private fun VisualizerHost(
|
||
viewModel: PlayerViewModel,
|
||
vizStyle: String,
|
||
playing: Boolean,
|
||
color: Color,
|
||
modifier: Modifier
|
||
) {
|
||
val spectrum by viewModel.spectrum.collectAsState()
|
||
DisposableEffect(Unit) {
|
||
viewModel.setSpectrumActive(true)
|
||
onDispose { viewModel.setSpectrumActive(false) }
|
||
}
|
||
com.radiola.ui.components.Visualizer(
|
||
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
||
levels = spectrum,
|
||
playing = playing,
|
||
color = color,
|
||
modifier = modifier
|
||
)
|
||
}
|
||
|
||
/** Чип выбора звука для сна. */
|
||
@Composable
|
||
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||
val colors = RadiolaTheme.colors
|
||
Text(
|
||
text = label,
|
||
style = MaterialTheme.typography.labelLarge,
|
||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||
fontWeight = FontWeight.Medium,
|
||
modifier = Modifier
|
||
.clip(RoundedCornerShape(50))
|
||
.background(if (selected) colors.accent else colors.surface2)
|
||
.clickable(
|
||
interactionSource = remember { MutableInteractionSource() },
|
||
indication = null,
|
||
onClick = onClick
|
||
)
|
||
.padding(horizontal = 14.dp, vertical = 9.dp)
|
||
)
|
||
}
|
||
|
||
/** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */
|
||
private fun formatSleep(ms: Long): String {
|
||
val total = (ms / 1000).coerceAtLeast(0)
|
||
return "%d:%02d".format(total / 60, total % 60)
|
||
}
|
||
|
||
/** Компактный чип текущего качества звука. */
|
||
@Composable
|
||
private fun QualityChip(
|
||
label: String,
|
||
onClick: () -> Unit,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
val colors = RadiolaTheme.colors
|
||
val interaction = remember { MutableInteractionSource() }
|
||
Row(
|
||
modifier = modifier
|
||
.clip(RoundedCornerShape(50))
|
||
.background(colors.surface2)
|
||
.pressScale(interactionSource = interaction)
|
||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||
) {
|
||
Icon(
|
||
imageVector = Lucide.SlidersHorizontal,
|
||
contentDescription = "Качество",
|
||
tint = colors.accent,
|
||
modifier = Modifier.size(13.dp)
|
||
)
|
||
Text(
|
||
text = label,
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = colors.textPrimary,
|
||
fontWeight = FontWeight.SemiBold
|
||
)
|
||
}
|
||
}
|
||
|
||
/** Строка выбора одного качества в шторке. */
|
||
@Composable
|
||
private fun QualityRow(
|
||
quality: com.radiola.domain.model.StreamQuality,
|
||
selected: Boolean,
|
||
onClick: () -> Unit
|
||
) {
|
||
val colors = RadiolaTheme.colors
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(14.dp))
|
||
.clickable(onClick = onClick)
|
||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Column(modifier = Modifier.weight(1f)) {
|
||
Text(
|
||
text = quality.tierLabel,
|
||
style = MaterialTheme.typography.bodyLarge,
|
||
color = if (selected) colors.accent else colors.textPrimary,
|
||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||
)
|
||
Text(
|
||
text = "${quality.bitrate} kbps · ${quality.type.uppercase()}",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = colors.textSecondary
|
||
)
|
||
}
|
||
if (selected) {
|
||
Icon(
|
||
imageVector = Lucide.Check,
|
||
contentDescription = "Выбрано",
|
||
tint = colors.accent,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Обёртка для иконок-кнопок управления — прозрачный фон. */
|
||
@Composable
|
||
private fun PlayerIconBtn(
|
||
size: Dp,
|
||
content: @Composable () -> Unit
|
||
) {
|
||
Box(modifier = Modifier.size(size), contentAlignment = Alignment.Center) {
|
||
content()
|
||
}
|
||
}
|
||
|
||
/** Короткая подпись сервиса под кнопкой (без обрезки слов). */
|
||
private fun serviceShortName(service: DeeplinkService): String = when (service.serviceId) {
|
||
"yandex" -> "Яндекс"
|
||
"vk" -> "ВК Музыка"
|
||
"boom" -> "BOOM"
|
||
"spotify" -> "Spotify"
|
||
"apple" -> "Apple Music"
|
||
"youtube" -> "YT Music"
|
||
"tidal" -> "Tidal"
|
||
"deezer" -> "Deezer"
|
||
else -> service.displayName
|
||
}
|
||
|
||
/** Монохромная кнопка сервиса для поиска трека. */
|
||
@Composable
|
||
private fun ServiceDeeplinkBtn(
|
||
service: DeeplinkService,
|
||
onClick: () -> Unit
|
||
) {
|
||
val colors = RadiolaTheme.colors
|
||
val interaction = remember { MutableInteractionSource() }
|
||
Column(
|
||
modifier = Modifier
|
||
.pressScale(interactionSource = interaction)
|
||
.clickable(interactionSource = interaction, indication = null, onClick = onClick),
|
||
horizontalAlignment = Alignment.CenterHorizontally,
|
||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||
) {
|
||
Box(
|
||
modifier = Modifier
|
||
.size(44.dp)
|
||
.clip(CircleShape)
|
||
.background(colors.surface2),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
val logoRes = com.radiola.ui.components.serviceLogoRes(service)
|
||
if (logoRes != null) {
|
||
Icon(
|
||
painter = androidx.compose.ui.res.painterResource(logoRes),
|
||
contentDescription = service.displayName,
|
||
tint = colors.textSecondary,
|
||
modifier = Modifier.size(22.dp)
|
||
)
|
||
} else {
|
||
Icon(
|
||
imageVector = Lucide.Music,
|
||
contentDescription = service.displayName,
|
||
tint = colors.textSecondary,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
}
|
||
}
|
||
Text(
|
||
text = serviceShortName(service),
|
||
style = MaterialTheme.typography.labelSmall,
|
||
color = colors.textSecondary,
|
||
maxLines = 1,
|
||
overflow = TextOverflow.Ellipsis
|
||
)
|
||
}
|
||
}
|