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:
nk
2026-06-04 12:36:47 +03:00
parent 5b256a3421
commit 5ffaf9a924
13 changed files with 1473 additions and 89 deletions

View File

@@ -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(

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.domain.model.StreamQuality
import com.radiola.domain.model.Track
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
@@ -57,6 +58,13 @@ class PlayerViewModel @Inject constructor(
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
// Выбранное качество текущей станции (битрейт). null — у станции нет вариантов.
private val _currentQuality = MutableStateFlow<StreamQuality?>(null)
val currentQuality: StateFlow<StreamQuality?> = _currentQuality.asStateFlow()
// Предпочитаемый битрейт пользователя (0 = авто/по умолчанию станции).
private var preferredBitrate: Int = 0
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
private var nowPlayingJob: Job? = null
@@ -72,6 +80,9 @@ class PlayerViewModel @Inject constructor(
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
}
}
viewModelScope.launch {
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
}
viewModelScope.launch {
_currentTrack
.filterNotNull()
@@ -86,10 +97,15 @@ class PlayerViewModel @Inject constructor(
_currentStation.value = station
_currentTrack.value = null
_playlist.value = playlist ?: _stations.value
// Выбираем стартовое качество: предпочтение пользователя → совпадение с
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
val quality = pickInitialQuality(station)
_currentQuality.value = quality
val streamUrl = quality?.url ?: station.streamUrl
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
// Для остальных resolve вернёт URL как есть.
viewModelScope.launch {
val url = loveStreamResolver.resolve(station.streamUrl)
val url = loveStreamResolver.resolve(streamUrl)
playerController.play(url, station.prefix, station.name)
}
viewModelScope.launch { pushHistoryUseCase(station.id) }
@@ -142,6 +158,28 @@ class PlayerViewModel @Inject constructor(
}
}
/** Стартовое качество станции с учётом предпочтения пользователя. */
private fun pickInitialQuality(station: Station): StreamQuality? {
val list = station.qualities
if (list.size < 2) return null
return list.firstOrNull { it.bitrate == preferredBitrate }
?: list.firstOrNull { it.url == station.streamUrl }
?: list.first()
}
/** Переключить качество текущей станции на лету (без сброса now-playing). */
fun selectQuality(quality: StreamQuality) {
val station = _currentStation.value ?: return
if (_currentQuality.value?.bitrate == quality.bitrate) return
_currentQuality.value = quality
preferredBitrate = quality.bitrate
viewModelScope.launch { settingsRepository.setPreferredBitrate(quality.bitrate) }
viewModelScope.launch {
val url = loveStreamResolver.resolve(quality.url)
playerController.changeStream(url)
}
}
private fun parseIcyTitle(title: String?): Track? {
if (title.isNullOrBlank()) return null
val separators = listOf(" - ", "", " ")