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:
File diff suppressed because it is too large
Load Diff
@@ -44,9 +44,15 @@ val MIGRATION_3_4 = object : Migration(3, 4) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE stations ADD COLUMN qualities TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
||||||
version = 4
|
version = 5
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun stationDao(): StationDao
|
abstract fun stationDao(): StationDao
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ class LocalStationDataSource @Inject constructor(
|
|||||||
genre = group?.name ?: "",
|
genre = group?.name ?: "",
|
||||||
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
|
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
|
||||||
sortOrder = dto.id,
|
sortOrder = dto.id,
|
||||||
source = if (isRecord) "record" else "local"
|
source = if (isRecord) "record" else "local",
|
||||||
|
qualities = dto.qualities.orEmpty().map {
|
||||||
|
com.radiola.domain.model.StreamQuality(it.bitrate, it.url, it.type)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ data class LocalStationDto(
|
|||||||
@SerialName("bgColor") val bgColor: String? = null,
|
@SerialName("bgColor") val bgColor: String? = null,
|
||||||
@SerialName("enabled") val enabled: Boolean = true,
|
@SerialName("enabled") val enabled: Boolean = true,
|
||||||
@SerialName("notWorked") val notWorked: Boolean = false,
|
@SerialName("notWorked") val notWorked: Boolean = false,
|
||||||
@SerialName("isNew") val isNew: Boolean = false
|
@SerialName("isNew") val isNew: Boolean = false,
|
||||||
|
@SerialName("qualities") val qualities: List<LocalQualityDto>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LocalQualityDto(
|
||||||
|
@SerialName("bitrate") val bitrate: Int,
|
||||||
|
@SerialName("url") val url: String,
|
||||||
|
@SerialName("type") val type: String = "aac"
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ data class StationEntity(
|
|||||||
val tags: String,
|
val tags: String,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val source: String = "record",
|
val source: String = "record",
|
||||||
val isFavorite: Boolean = false
|
val isFavorite: Boolean = false,
|
||||||
|
// Качества потока, закодированы строкой: строки "bitrate\ttype\turl", разделённые \n.
|
||||||
|
val qualities: String = ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class SettingsRepositoryImpl @Inject constructor(
|
|||||||
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
|
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
|
||||||
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
|
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
|
||||||
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
|
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
|
||||||
|
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
|
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
|
||||||
@@ -49,4 +50,7 @@ class SettingsRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
|
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
|
||||||
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
|
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
|
||||||
|
|
||||||
|
override fun getPreferredBitrate(): Flow<Int> = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 }
|
||||||
|
override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.radiola.data.remote.RecordApi
|
|||||||
import com.radiola.data.remote.RadiolaApi
|
import com.radiola.data.remote.RadiolaApi
|
||||||
import com.radiola.data.remote.ApiMapper.toDomain
|
import com.radiola.data.remote.ApiMapper.toDomain
|
||||||
import com.radiola.domain.model.Station
|
import com.radiola.domain.model.Station
|
||||||
|
import com.radiola.domain.model.StreamQuality
|
||||||
import com.radiola.domain.repository.StationRepository
|
import com.radiola.domain.repository.StationRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -84,7 +85,8 @@ class StationRepositoryImpl @Inject constructor(
|
|||||||
tags = station.tags.joinToString(","),
|
tags = station.tags.joinToString(","),
|
||||||
sortOrder = index,
|
sortOrder = index,
|
||||||
source = station.source,
|
source = station.source,
|
||||||
isFavorite = false
|
isFavorite = false,
|
||||||
|
qualities = encodeQualities(station.qualities)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
db.stationDao().insertAll(entities)
|
db.stationDao().insertAll(entities)
|
||||||
@@ -133,6 +135,22 @@ class StationRepositoryImpl @Inject constructor(
|
|||||||
genre = genre,
|
genre = genre,
|
||||||
tags = tags.split(",").filter { it.isNotBlank() },
|
tags = tags.split(",").filter { it.isNotBlank() },
|
||||||
sortOrder = sortOrder,
|
sortOrder = sortOrder,
|
||||||
source = source
|
source = source,
|
||||||
|
qualities = decodeQualities(qualities)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Качества кодируем строкой "bitrate\ttype\turl" по строкам (URL может содержать ; и |,
|
||||||
|
// но не \t/\n — поэтому такие разделители безопасны).
|
||||||
|
private fun encodeQualities(list: List<StreamQuality>): String =
|
||||||
|
list.joinToString("\n") { "${it.bitrate}\t${it.type}\t${it.url}" }
|
||||||
|
|
||||||
|
private fun decodeQualities(raw: String): List<StreamQuality> {
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
return raw.split("\n").mapNotNull { line ->
|
||||||
|
val parts = line.split("\t")
|
||||||
|
if (parts.size != 3) return@mapNotNull null
|
||||||
|
val br = parts[0].toIntOrNull() ?: return@mapNotNull null
|
||||||
|
StreamQuality(br, parts[2], parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.radiola.data.local.LocalStationDataSource
|
|||||||
import com.radiola.data.local.MIGRATION_1_2
|
import com.radiola.data.local.MIGRATION_1_2
|
||||||
import com.radiola.data.local.MIGRATION_2_3
|
import com.radiola.data.local.MIGRATION_2_3
|
||||||
import com.radiola.data.local.MIGRATION_3_4
|
import com.radiola.data.local.MIGRATION_3_4
|
||||||
|
import com.radiola.data.local.MIGRATION_4_5
|
||||||
import com.radiola.data.remote.AuthInterceptor
|
import com.radiola.data.remote.AuthInterceptor
|
||||||
import com.radiola.data.remote.LrcLibApi
|
import com.radiola.data.remote.LrcLibApi
|
||||||
import com.radiola.data.remote.LoveApi
|
import com.radiola.data.remote.LoveApi
|
||||||
@@ -136,7 +137,7 @@ object AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||||
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
|
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -9,5 +9,24 @@ data class Station(
|
|||||||
val genre: String,
|
val genre: String,
|
||||||
val tags: List<String>,
|
val tags: List<String>,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val source: String = "record"
|
val source: String = "record",
|
||||||
|
// Доступные качества потока (битрейты). Пусто или один элемент — переключателя нет.
|
||||||
|
val qualities: List<StreamQuality> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Один вариант качества потока станции. */
|
||||||
|
data class StreamQuality(
|
||||||
|
val bitrate: Int, // kbps
|
||||||
|
val url: String,
|
||||||
|
val type: String // "aac" | "mp3"
|
||||||
|
) {
|
||||||
|
/** Человекочитаемая ступень качества по битрейту. */
|
||||||
|
val tierLabel: String
|
||||||
|
get() = when {
|
||||||
|
bitrate >= 256 -> "Максимальное"
|
||||||
|
bitrate >= 128 -> "Высокое"
|
||||||
|
bitrate >= 96 -> "Среднее"
|
||||||
|
bitrate >= 64 -> "Экономно"
|
||||||
|
else -> "Минимальное"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,4 +14,7 @@ interface SettingsRepository {
|
|||||||
suspend fun setEqualizerPreset(preset: String)
|
suspend fun setEqualizerPreset(preset: String)
|
||||||
fun isRecordingEnabled(): Flow<Boolean>
|
fun isRecordingEnabled(): Flow<Boolean>
|
||||||
suspend fun setRecordingEnabled(enabled: Boolean)
|
suspend fun setRecordingEnabled(enabled: Boolean)
|
||||||
|
// Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции).
|
||||||
|
fun getPreferredBitrate(): Flow<Int>
|
||||||
|
suspend fun setPreferredBitrate(bitrate: Int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,18 @@ class PlayerController @Inject constructor(
|
|||||||
_currentStationPrefix.value = stationPrefix
|
_currentStationPrefix.value = stationPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сменить URL потока (переключение качества) без потери текущих метаданных/обложки. */
|
||||||
|
fun changeStream(url: String) {
|
||||||
|
Log.d("PlayerController", "changeStream() url=$url")
|
||||||
|
val keepMetadata = exoPlayer.currentMediaItem?.mediaMetadata
|
||||||
|
_icyTitle.value = null
|
||||||
|
val builder = MediaItem.Builder().setUri(url)
|
||||||
|
if (keepMetadata != null) builder.setMediaMetadata(keepMetadata)
|
||||||
|
exoPlayer.setMediaItem(builder.build())
|
||||||
|
exoPlayer.prepare()
|
||||||
|
exoPlayer.play()
|
||||||
|
}
|
||||||
|
|
||||||
fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) {
|
fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) {
|
||||||
val currentMediaItem = exoPlayer.currentMediaItem ?: return
|
val currentMediaItem = exoPlayer.currentMediaItem ?: return
|
||||||
val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }
|
val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import coil.compose.AsyncImage
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import com.composables.icons.lucide.Check
|
||||||
import com.composables.icons.lucide.FileText
|
import com.composables.icons.lucide.FileText
|
||||||
import com.composables.icons.lucide.Heart
|
import com.composables.icons.lucide.Heart
|
||||||
import com.composables.icons.lucide.Lucide
|
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.Radio
|
||||||
import com.composables.icons.lucide.SkipBack
|
import com.composables.icons.lucide.SkipBack
|
||||||
import com.composables.icons.lucide.SkipForward
|
import com.composables.icons.lucide.SkipForward
|
||||||
|
import com.composables.icons.lucide.SlidersHorizontal
|
||||||
import com.radiola.deeplink.DeeplinkNavigator
|
import com.radiola.deeplink.DeeplinkNavigator
|
||||||
import com.radiola.domain.model.DeeplinkService
|
import com.radiola.domain.model.DeeplinkService
|
||||||
import com.radiola.domain.model.Station
|
import com.radiola.domain.model.Station
|
||||||
@@ -77,6 +79,8 @@ fun PlayerBottomSheet(
|
|||||||
val colors = RadiolaTheme.colors
|
val colors = RadiolaTheme.colors
|
||||||
val haptics = LocalHapticFeedback.current
|
val haptics = LocalHapticFeedback.current
|
||||||
var showLyrics by remember { mutableStateOf(false) }
|
var showLyrics by remember { mutableStateOf(false) }
|
||||||
|
var showQuality by remember { mutableStateOf(false) }
|
||||||
|
val currentQuality by viewModel.currentQuality.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -86,14 +90,27 @@ fun PlayerBottomSheet(
|
|||||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
// Метка «В ЭФИРЕ»
|
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
|
||||||
Text(
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
text = "В ЭФИРЕ",
|
Text(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
text = "В ЭФИРЕ",
|
||||||
color = colors.accent,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
letterSpacing = 2.sp,
|
color = colors.accent,
|
||||||
fontWeight = FontWeight.SemiBold
|
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))
|
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) {
|
if (showLyrics && track != null) {
|
||||||
ModalBottomSheet(
|
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
|
@Composable
|
||||||
private fun PlayerIconBtn(
|
private fun PlayerIconBtn(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.radiola.domain.model.DeeplinkService
|
import com.radiola.domain.model.DeeplinkService
|
||||||
import com.radiola.domain.model.Station
|
import com.radiola.domain.model.Station
|
||||||
|
import com.radiola.domain.model.StreamQuality
|
||||||
import com.radiola.domain.model.Track
|
import com.radiola.domain.model.Track
|
||||||
import com.radiola.domain.repository.SettingsRepository
|
import com.radiola.domain.repository.SettingsRepository
|
||||||
import com.radiola.domain.repository.StationRepository
|
import com.radiola.domain.repository.StationRepository
|
||||||
@@ -57,6 +58,13 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
|
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
|
||||||
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
|
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
|
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
|
||||||
|
|
||||||
private var nowPlayingJob: Job? = null
|
private var nowPlayingJob: Job? = null
|
||||||
@@ -72,6 +80,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
|
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_currentTrack
|
_currentTrack
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
@@ -86,10 +97,15 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_currentStation.value = station
|
_currentStation.value = station
|
||||||
_currentTrack.value = null
|
_currentTrack.value = null
|
||||||
_playlist.value = playlist ?: _stations.value
|
_playlist.value = playlist ?: _stations.value
|
||||||
|
// Выбираем стартовое качество: предпочтение пользователя → совпадение с
|
||||||
|
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
|
||||||
|
val quality = pickInitialQuality(station)
|
||||||
|
_currentQuality.value = quality
|
||||||
|
val streamUrl = quality?.url ?: station.streamUrl
|
||||||
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
|
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
|
||||||
// Для остальных resolve вернёт URL как есть.
|
// Для остальных resolve вернёт URL как есть.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val url = loveStreamResolver.resolve(station.streamUrl)
|
val url = loveStreamResolver.resolve(streamUrl)
|
||||||
playerController.play(url, station.prefix, station.name)
|
playerController.play(url, station.prefix, station.name)
|
||||||
}
|
}
|
||||||
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
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? {
|
private fun parseIcyTitle(title: String?): Track? {
|
||||||
if (title.isNullOrBlank()) return null
|
if (title.isNullOrBlank()) return null
|
||||||
val separators = listOf(" - ", " — ", " – ")
|
val separators = listOf(" - ", " — ", " – ")
|
||||||
|
|||||||
Reference in New Issue
Block a user