feat(player): 4 стиля визуализатора + выбор в настройках
Добавлены стили анимации воспроизведения: столбики от центра, столбики снизу (спектр), плавная волна, радиальный — все от реального спектра звука (Visualizer.kt). Пользователь выбирает стиль в Настройках → «Анимация воспроизведения» (живые превью каждого стиля, тап выбирает). Сохраняется пер-юзер (DataStore visualizer_style). Плеер рисует выбранный стиль (радиальный — повыше). Превью и пауза — мягкая «дышащая» анимация. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
150
app/src/main/java/com/radiola/ui/components/Visualizer.kt
Normal file
150
app/src/main/java/com/radiola/ui/components/Visualizer.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user