feat(player): 4 стиля визуализатора + выбор в настройках
Добавлены стили анимации воспроизведения: столбики от центра, столбики снизу (спектр), плавная волна, радиальный — все от реального спектра звука (Visualizer.kt). Пользователь выбирает стиль в Настройках → «Анимация воспроизведения» (живые превью каждого стиля, тап выбирает). Сохраняется пер-юзер (DataStore visualizer_style). Плеер рисует выбранный стиль (радиальный — повыше). Превью и пауза — мягкая «дышащая» анимация. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
|
||||
@@ -57,4 +58,7 @@ class SettingsRepositoryImpl @Inject constructor(
|
||||
|
||||
override fun getCountryCode(): Flow<String?> = dataStore.data.map { it[COUNTRY_CODE] }
|
||||
override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = code } }
|
||||
|
||||
override fun getVisualizerStyle(): Flow<String> = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" }
|
||||
override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } }
|
||||
}
|
||||
|
||||
@@ -20,4 +20,7 @@ interface SettingsRepository {
|
||||
// Код страны пользователя (по IP), напр. "RU". null — не определён.
|
||||
fun getCountryCode(): Flow<String?>
|
||||
suspend fun setCountryCode(code: String)
|
||||
// Стиль визуализатора звука в плеере (ключ VisualizerStyle).
|
||||
fun getVisualizerStyle(): Flow<String>
|
||||
suspend fun setVisualizerStyle(style: String)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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("МУЗЫКАЛЬНЫЕ СЕРВИСЫ")
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user