From 1e00287486a8c718c072415ce1557b866c1909a5 Mon Sep 17 00:00:00 2001 From: nk Date: Thu, 4 Jun 2026 18:18:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(player):=204=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20+=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены стили анимации воспроизведения: столбики от центра, столбики снизу (спектр), плавная волна, радиальный — все от реального спектра звука (Visualizer.kt). Пользователь выбирает стиль в Настройках → «Анимация воспроизведения» (живые превью каждого стиля, тап выбирает). Сохраняется пер-юзер (DataStore visualizer_style). Плеер рисует выбранный стиль (радиальный — повыше). Превью и пауза — мягкая «дышащая» анимация. Co-Authored-By: Claude Opus 4.8 --- .../data/repository/SettingsRepositoryImpl.kt | 4 + .../domain/repository/SettingsRepository.kt | 3 + .../com/radiola/ui/components/Visualizer.kt | 150 ++++++++++++++++++ .../radiola/ui/player/PlayerBottomSheet.kt | 12 +- .../com/radiola/ui/player/PlayerViewModel.kt | 3 + .../com/radiola/ui/settings/SettingsScreen.kt | 47 ++++++ .../radiola/ui/settings/SettingsViewModel.kt | 7 + 7 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/radiola/ui/components/Visualizer.kt diff --git a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt index 0f682c8..311c04a 100644 --- a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt @@ -31,6 +31,7 @@ class SettingsRepositoryImpl @Inject constructor( private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled") private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate") private val COUNTRY_CODE = stringPreferencesKey("country_code") + private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style") } override fun getLastStationId(): Flow = dataStore.data.map { it[LAST_STATION_ID] } @@ -57,4 +58,7 @@ class SettingsRepositoryImpl @Inject constructor( override fun getCountryCode(): Flow = dataStore.data.map { it[COUNTRY_CODE] } override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = code } } + + override fun getVisualizerStyle(): Flow = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" } + override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } } } diff --git a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt index 0235c0c..e0eb04a 100644 --- a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt @@ -20,4 +20,7 @@ interface SettingsRepository { // Код страны пользователя (по IP), напр. "RU". null — не определён. fun getCountryCode(): Flow suspend fun setCountryCode(code: String) + // Стиль визуализатора звука в плеере (ключ VisualizerStyle). + fun getVisualizerStyle(): Flow + suspend fun setVisualizerStyle(style: String) } diff --git a/app/src/main/java/com/radiola/ui/components/Visualizer.kt b/app/src/main/java/com/radiola/ui/components/Visualizer.kt new file mode 100644 index 0000000..501c32f --- /dev/null +++ b/app/src/main/java/com/radiola/ui/components/Visualizer.kt @@ -0,0 +1,150 @@ +package com.radiola.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin + +/** Стиль анимации воспроизведения (выбирается пользователем в настройках). */ +enum class VisualizerStyle(val key: String, val label: String) { + BARS_CENTER("bars_center", "Центр"), + BARS_BOTTOM("bars_bottom", "Снизу"), + WAVE("wave", "Волна"), + RADIAL("radial", "Круг"); + + companion object { + fun fromKey(k: String?): VisualizerStyle = entries.firstOrNull { it.key == k } ?: BARS_CENTER + } +} + +/** + * Визуализатор звука выбранного стиля. levels — реальный спектр (0..1 по полосам); + * если его нет (пауза/превью) — мягкая «дышащая» анимация. + */ +@Composable +fun Visualizer( + style: VisualizerStyle, + levels: FloatArray?, + playing: Boolean, + color: Color, + modifier: Modifier = Modifier, +) { + val phase by rememberInfiniteTransition(label = "viz").animateFloat( + initialValue = 0f, + targetValue = (Math.PI * 2).toFloat(), + animationSpec = infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), + label = "vizPhase", + ) + Canvas(modifier = modifier) { + when (style) { + VisualizerStyle.BARS_CENTER -> drawBars(levels, phase, playing, color, centered = true) + VisualizerStyle.BARS_BOTTOM -> drawBars(levels, phase, playing, color, centered = false) + VisualizerStyle.WAVE -> drawWave(levels, phase, playing, color) + VisualizerStyle.RADIAL -> drawRadial(levels, phase, playing, color) + } + } +} + +/** Уровень полосы i из n: реальный спектр → иначе «дышащая» синус-волна. */ +private fun levelAt(i: Int, n: Int, levels: FloatArray?, phase: Float, playing: Boolean): Float { + if (!playing) return 0.16f + if (levels != null && levels.isNotEmpty()) { + val idx = (i * levels.size / n).coerceIn(0, levels.size - 1) + return (0.06f + 0.94f * levels[idx]).coerceIn(0f, 1f) + } + return 0.42f + 0.5f * abs(sin(phase + i * 0.7f)) +} + +private fun DrawScope.drawBars( + levels: FloatArray?, + phase: Float, + playing: Boolean, + color: Color, + centered: Boolean, +) { + val barCount = 36 + val gap = 3.dp.toPx() + val barWidth = (size.width - gap * (barCount - 1)) / barCount + val maxH = size.height + for (i in 0 until barCount) { + val h = maxH * levelAt(i, barCount, levels, phase, playing) + val x = i * (barWidth + gap) + val y = if (centered) (maxH - h) / 2f else (maxH - h) + drawRoundRect( + color = color, + topLeft = Offset(x, y), + size = Size(barWidth, h), + cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f), + ) + } +} + +private fun DrawScope.drawWave(levels: FloatArray?, phase: Float, playing: Boolean, color: Color) { + val points = 48 + val maxH = size.height + val stepX = size.width / (points - 1) + val ys = FloatArray(points) { i -> maxH * (1f - levelAt(i, points, levels, phase, playing)) } + + val line = Path() + val fill = Path() + line.moveTo(0f, ys[0]) + fill.moveTo(0f, maxH) + fill.lineTo(0f, ys[0]) + for (i in 1 until points) { + val x = i * stepX + val px = (i - 1) * stepX + val midX = (px + x) / 2f + // Сглаживание кубическими кривыми между точками. + line.cubicTo(midX, ys[i - 1], midX, ys[i], x, ys[i]) + fill.cubicTo(midX, ys[i - 1], midX, ys[i], x, ys[i]) + } + fill.lineTo(size.width, maxH) + fill.close() + drawPath(fill, color = color.copy(alpha = 0.18f)) + drawPath(line, color = color, style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round)) +} + +private fun DrawScope.drawRadial(levels: FloatArray?, phase: Float, playing: Boolean, color: Color) { + val n = 40 + val cx = size.width / 2f + val cy = size.height / 2f + val rInner = minOf(cx, cy) * 0.42f + val rMax = minOf(cx, cy) * 0.98f + val stroke = (2 * Math.PI * rInner / n / 2).toFloat().coerceIn(2f, 6f) + // тонкое кольцо-основа + drawCircle(color = color.copy(alpha = 0.25f), radius = rInner * 0.9f, center = Offset(cx, cy), style = Stroke(1.5.dp.toPx())) + for (i in 0 until n) { + val lv = levelAt(i, n, levels, phase, playing) + val ang = (i.toFloat() / n) * (2 * Math.PI).toFloat() - (Math.PI / 2).toFloat() + val len = rInner + (rMax - rInner) * lv + val sx = cx + cos(ang) * rInner + val sy = cy + sin(ang) * rInner + val ex = cx + cos(ang) * len + val ey = cy + sin(ang) * len + drawLine( + color = color, + start = Offset(sx, sy), + end = Offset(ex, ey), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } +} diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 0afe3db..6b297fc 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -84,6 +84,7 @@ fun PlayerBottomSheet( var showQuality by remember { mutableStateOf(false) } val currentQuality by viewModel.currentQuality.collectAsState() val spectrum by viewModel.spectrum.collectAsState() + val vizStyle by viewModel.visualizerStyle.collectAsState() Column( modifier = modifier @@ -185,13 +186,14 @@ fun PlayerBottomSheet( Spacer(Modifier.height(20.dp)) // Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать) - LiveEqualizer( - modifier = Modifier - .fillMaxWidth() - .height(40.dp), + com.radiola.ui.components.Visualizer( + style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle), + levels = spectrum, playing = isPlaying, color = colors.accent, - levels = spectrum + modifier = Modifier + .fillMaxWidth() + .height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp) ) Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index 91341dc..f68c18d 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -44,6 +44,9 @@ class PlayerViewModel @Inject constructor( val currentStationPrefix: StateFlow = playerController.currentStationPrefix val spectrum: StateFlow = playerController.spectrum + val visualizerStyle: StateFlow = settingsRepository.getVisualizerStyle() + .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), "bars_center") + private val _currentStation = MutableStateFlow(null) val currentStation: StateFlow = _currentStation.asStateFlow() diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt index e6d025a..7307827 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt @@ -41,6 +41,7 @@ fun SettingsScreen( val sleepTimer by viewModel.sleepTimerMinutes.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState() val equalizerPreset by viewModel.equalizerPreset.collectAsState() + val visualizerStyle by viewModel.visualizerStyle.collectAsState() val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState() val isTesting by viewModel.isTesting.collectAsState() val testProgress by viewModel.testProgress.collectAsState() @@ -231,6 +232,52 @@ fun SettingsScreen( } } + // --- Стиль визуализации воспроизведения --- + item { + SectionLabel("АНИМАЦИЯ ВОСПРОИЗВЕДЕНИЯ") + Spacer(Modifier.height(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + com.radiola.ui.components.VisualizerStyle.entries.chunked(2).forEach { rowStyles -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + rowStyles.forEach { style -> + val selected = visualizerStyle == style.key + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .background(colors.surface) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) colors.accent else colors.border, + shape = RoundedCornerShape(16.dp) + ) + .clickable { viewModel.setVisualizerStyle(style.key) } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + com.radiola.ui.components.Visualizer( + style = style, + levels = null, + playing = true, + color = colors.accent, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + Text( + text = style.label, + style = MaterialTheme.typography.labelLarge, + color = if (selected) colors.accent else colors.textSecondary, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } + } + } + } + // --- Музыкальные сервисы --- item { SectionLabel("МУЗЫКАЛЬНЫЕ СЕРВИСЫ") diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt index 586e7bb..ae0d723 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt @@ -32,6 +32,9 @@ class SettingsViewModel @Inject constructor( val equalizerPreset: StateFlow = settingsRepository.getEqualizerPreset() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat") + val visualizerStyle: StateFlow = settingsRepository.getVisualizerStyle() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center") + val isRecordingEnabled: StateFlow = settingsRepository.isRecordingEnabled() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -69,6 +72,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) } } + fun setVisualizerStyle(style: String) { + viewModelScope.launch { settingsRepository.setVisualizerStyle(style) } + } + fun setRecordingEnabled(enabled: Boolean) { viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) } }