feat(player): 4 стиля визуализатора + выбор в настройках

Добавлены стили анимации воспроизведения: столбики от центра, столбики
снизу (спектр), плавная волна, радиальный — все от реального спектра звука
(Visualizer.kt). Пользователь выбирает стиль в Настройках → «Анимация
воспроизведения» (живые превью каждого стиля, тап выбирает). Сохраняется
пер-юзер (DataStore visualizer_style). Плеер рисует выбранный стиль
(радиальный — повыше). Превью и пауза — мягкая «дышащая» анимация.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 18:18:55 +03:00
parent 900a4ad813
commit 1e00287486
7 changed files with 221 additions and 5 deletions

View File

@@ -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,
)
}
}

View File

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

View File

@@ -44,6 +44,9 @@ class PlayerViewModel @Inject constructor(
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
val spectrum: StateFlow<FloatArray> = playerController.spectrum
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), "bars_center")
private val _currentStation = MutableStateFlow<Station?>(null)
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()

View File

@@ -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("МУЗЫКАЛЬНЫЕ СЕРВИСЫ")

View File

@@ -32,6 +32,9 @@ class SettingsViewModel @Inject constructor(
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat")
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center")
val isRecordingEnabled: StateFlow<Boolean> = 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) }
}