feat(player): переключатель качества звука на экране воспроизведения
Перепроверены все 594 рабочие станции на наличие битрейт-вариантов потока (скрипт-пробер). У 71 станции найдено по 2–4 качества (Record-флагманы 96/64/32, zaycev 256/128/48, ВГТРК 192/128/64, НАШЕ/Орфей/Шансон HQ и др.) — записаны в поле qualities в stations.json. HLS (EMG) и Love (UID-привязка) корректно пропущены. Клиент: модель StreamQuality, хранение в Room (миграция v5), предпочтение битрейта в настройках. На экране плеера — чип текущего качества (виден только если вариантов ≥2) и шторка «Качество звука» со ступенями; переключение на лету без сброса now-playing, выбор запоминается между станциями. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ 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
|
||||
@@ -46,6 +47,7 @@ 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
|
||||
@@ -77,6 +79,8 @@ fun PlayerBottomSheet(
|
||||
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
|
||||
@@ -86,14 +90,27 @@ fun PlayerBottomSheet(
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Метка «В ЭФИРЕ»
|
||||
Text(
|
||||
text = "В ЭФИРЕ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.accent,
|
||||
letterSpacing = 2.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
|
||||
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))
|
||||
|
||||
// Название радиостанции — под меткой, над обложкой
|
||||
@@ -309,6 +326,43 @@ fun PlayerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Шторка выбора качества
|
||||
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(
|
||||
@@ -324,6 +378,80 @@ fun PlayerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
/** Компактный чип текущего качества звука. */
|
||||
@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(
|
||||
|
||||
Reference in New Issue
Block a user