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