Files
radiola-android/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt
nk bda2c5b30f feat(player): таймер сна с плавным затуханием (fade-out)
P0-фича из спеки. PlayerController: startSleepTimer/cancelSleepTimer — в последние
20с экспоненциальный fade-out громкости (frac^2), затем пауза + возврат громкости.
В плеере — пилюля «Таймер сна» (иконка Moon): при активном показывает остаток
M:SS акцентом. Шторка с интервалами 15/30/45/60/90/120 мин + «Выключить».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:08:54 +03:00

756 lines
30 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) }
var showSleep by remember { mutableStateOf(false) }
val currentQuality by viewModel.currentQuality.collectAsState()
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
val spectrum by viewModel.spectrum.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
)
}
}
}
val visualizerSection: @Composable () -> Unit = {
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
com.radiola.ui.components.Visualizer(
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
levels = spectrum,
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()
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()
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)
)
// Если активен — показываем остаток и кнопку отмены
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)
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
)
}
}
}
/** Форматирует оставшееся время таймера сна в 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
)
}
}