Files
radiola-android/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt
nk 6aa2588641 fix(player): не обрезать низ плеера на телефонах (скролл + компактнее)
Плеер живёт в ModalBottomSheet без скролла. На телефонах с высоким dpi
высоты в dp меньше (480dpi → ~800dp против ~914dp у эмулятора 420dpi),
из-за чего низ — кнопка «Текст песни» — обрезался шторкой и был виден
лишь полоской. Добавлен verticalScroll (низ доступен на любом экране) и
ужата высота (обложка 220→190, крупные отступы), чтобы влезало без скролла.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:51:15 +03:00

543 lines
21 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.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()
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
if (!coverModel.isNullOrBlank()) {
AsyncImage(
model = com.radiola.ui.components.crossfadeModel(coverModel),
contentDescription = station?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
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
)
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
)
}
}