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.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) } val currentQuality by viewModel.currentQuality.collectAsState() val spectrum by viewModel.spectrum.collectAsState() Column( modifier = modifier .fillMaxWidth() .background(colors.bgBase) .navigationBarsPadding() // Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi) // низ плеера (кнопка «Текст песни») не обрезался шторкой. .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты) 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) ) } } Spacer(Modifier.height(6.dp)) // Название радиостанции — под меткой, над обложкой 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() ) Spacer(Modifier.height(16.dp)) // Обложка станции/трека Box( modifier = Modifier .size(190.dp) .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) ) } } Spacer(Modifier.height(14.dp)) // Название трека и исполнитель с 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 ) } } Spacer(Modifier.height(20.dp)) // Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать) LiveEqualizer( modifier = Modifier .fillMaxWidth() .height(40.dp), playing = isPlaying, color = colors.accent, levels = spectrum ) Spacer(Modifier.height(16.dp)) // Управление воспроизведением 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) ) } } } } Spacer(Modifier.height(20.dp)) // Ряд кнопок музыкальных сервисов 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") } ) } } Spacer(Modifier.height(12.dp)) } // Кнопка «Текст песни» — активна только когда играет трек. // Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден. if (track != null) { val lyricsInteraction = remember { MutableInteractionSource() } Row( modifier = Modifier .align(Alignment.CenterHorizontally) .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 ) } } } // Шторка выбора качества 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 ) } } } /** Компактный чип текущего качества звука. */ @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 ) } }