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 RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
|
||||||
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
|
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
|
||||||
private val COUNTRY_CODE = stringPreferencesKey("country_code")
|
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] }
|
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 fun getCountryCode(): Flow<String?> = dataStore.data.map { it[COUNTRY_CODE] }
|
||||||
override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = 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 — не определён.
|
// Код страны пользователя (по IP), напр. "RU". null — не определён.
|
||||||
fun getCountryCode(): Flow<String?>
|
fun getCountryCode(): Flow<String?>
|
||||||
suspend fun setCountryCode(code: 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) }
|
var showQuality by remember { mutableStateOf(false) }
|
||||||
val currentQuality by viewModel.currentQuality.collectAsState()
|
val currentQuality by viewModel.currentQuality.collectAsState()
|
||||||
val spectrum by viewModel.spectrum.collectAsState()
|
val spectrum by viewModel.spectrum.collectAsState()
|
||||||
|
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -185,13 +186,14 @@ fun PlayerBottomSheet(
|
|||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
|
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
|
||||||
LiveEqualizer(
|
com.radiola.ui.components.Visualizer(
|
||||||
modifier = Modifier
|
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
||||||
.fillMaxWidth()
|
levels = spectrum,
|
||||||
.height(40.dp),
|
|
||||||
playing = isPlaying,
|
playing = isPlaying,
|
||||||
color = colors.accent,
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
|
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
|
||||||
val spectrum: StateFlow<FloatArray> = playerController.spectrum
|
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)
|
private val _currentStation = MutableStateFlow<Station?>(null)
|
||||||
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
|
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fun SettingsScreen(
|
|||||||
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
|
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
|
||||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||||
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
||||||
|
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
|
||||||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||||||
val isTesting by viewModel.isTesting.collectAsState()
|
val isTesting by viewModel.isTesting.collectAsState()
|
||||||
val testProgress by viewModel.testProgress.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 {
|
item {
|
||||||
SectionLabel("МУЗЫКАЛЬНЫЕ СЕРВИСЫ")
|
SectionLabel("МУЗЫКАЛЬНЫЕ СЕРВИСЫ")
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
|
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat")
|
.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()
|
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
@@ -69,6 +72,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) }
|
viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVisualizerStyle(style: String) {
|
||||||
|
viewModelScope.launch { settingsRepository.setVisualizerStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setRecordingEnabled(enabled: Boolean) {
|
fun setRecordingEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
|
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user