feat(eq): настоящий эквалайзер + улучшайзеры звука (audiofx)

Раньше пресет эквалайзера в настройках был косметикой (лежал в DataStore, к звуку
не подключён). Теперь — реальные системные эффекты на фикс. аудиосессии плеера:
- AudioEffectsController: графический Equalizer (полосы устройства), BassBoost,
  Virtualizer (объём), LoudnessEnhancer (громкость тихих, до +12 дБ). Привязка к
  generateAudioSessionId() в PlayerController, переживает смену станций. Применение
  в реальном времени, сохранение в DataStore (commit на отпускании слайдера).
- Отдельный экран EqualizerScreen (Настройки → ЗВУК → Эквалайзер): тумблер,
  системные пресеты + «Свой», слайдеры полос (±дБ), bass/virtualizer/loudness.
- Эффекты best-effort: при отсутствии поддержки блок недоступен (null), UI скрывает.
- Убран фейковый чип-пресет Flat/Rock/Pop/Jazz/Bass.
This commit is contained in:
nk
2026-06-06 21:14:38 +03:00
parent 0c01eaab2d
commit e736c2393f
9 changed files with 679 additions and 33 deletions

View File

@@ -0,0 +1,311 @@
package com.radiola.ui.equalizer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.Lucide
import com.radiola.service.EqBand
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun EqualizerScreen(
onNavigateBack: () -> Unit,
viewModel: EqualizerViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val state by viewModel.state.collectAsState()
val on = state.enabled
Column(modifier = Modifier.fillMaxSize()) {
// Шапка с кнопкой «назад»
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Lucide.ChevronLeft,
contentDescription = "Назад",
tint = colors.textPrimary,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable { onNavigateBack() }
.padding(8.dp)
.size(24.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Эквалайзер",
style = MaterialTheme.typography.headlineSmall,
color = colors.textPrimary,
fontWeight = FontWeight.Bold
)
}
if (!state.available) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Эквалайзер недоступен на этом устройстве",
color = colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(32.dp)
)
}
return
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Главный тумблер
Card(colors) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(
"Эквалайзер",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
"Тонкая настройка звука и улучшайзеры",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Switch(
checked = on,
onCheckedChange = { viewModel.setEnabled(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
}
// Пресеты
if (state.presets.isNotEmpty()) {
Label("ПРЕСЕТЫ")
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
state.presets.forEachIndexed { index, name ->
val selected = state.currentPreset == index
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (selected) colors.accent else colors.surface2)
.clickable(enabled = on) { viewModel.selectPreset(index) }
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
text = name,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
// «Свой» — активен, когда полосы правились вручную
val custom = state.currentPreset == -1
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (custom) colors.accent else colors.surface2)
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
"Свой",
style = MaterialTheme.typography.labelLarge,
color = if (custom) colors.bgBase else colors.textSecondary,
fontWeight = if (custom) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
// Полосы эквалайзера
Label("ЧАСТОТЫ")
Card(colors) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
state.bands.forEach { band ->
BandRow(
band = band,
enabled = on,
colors = colors,
onChange = { viewModel.setBand(band.index, it) },
onCommit = { viewModel.commit() }
)
}
}
}
// Улучшайзеры
Label("УЛУЧШАЙЗЕРЫ")
Card(colors) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (state.hasBass) {
EnhancerSlider("Bass Boost", "Усиление баса", state.bass, on, colors,
{ viewModel.setBass(it) }, { viewModel.commit() })
}
if (state.hasVirtualizer) {
EnhancerSlider("Virtualizer", "Объём / ширина стерео", state.virtualizer, on, colors,
{ viewModel.setVirtualizer(it) }, { viewModel.commit() })
}
if (state.hasLoudness) {
EnhancerSlider("Громкость", "Подъём тихих станций (до +12 дБ)", state.loudness, on, colors,
{ viewModel.setLoudness(it) }, { viewModel.commit() })
}
}
}
}
}
}
@Composable
private fun Card(colors: com.radiola.ui.theme.RadiolaColors, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) { content() }
}
@Composable
private fun Label(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = RadiolaTheme.colors.textMuted,
letterSpacing = 1.sp
)
}
@Composable
private fun BandRow(
band: EqBand,
enabled: Boolean,
colors: com.radiola.ui.theme.RadiolaColors,
onChange: (Int) -> Unit,
onCommit: () -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = freqLabel(band.centerHz),
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary,
modifier = Modifier.width(60.dp)
)
Slider(
value = band.levelMb.toFloat(),
onValueChange = { onChange(it.toInt()) },
onValueChangeFinished = onCommit,
valueRange = band.minMb.toFloat()..band.maxMb.toFloat(),
enabled = enabled,
colors = SliderDefaults.colors(
thumbColor = colors.accent,
activeTrackColor = colors.accent,
inactiveTrackColor = colors.surface2
),
modifier = Modifier.weight(1f)
)
Text(
text = "%+d dB".format(band.levelMb / 100),
style = MaterialTheme.typography.labelMedium,
color = colors.accent,
modifier = Modifier.width(52.dp),
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun EnhancerSlider(
title: String,
subtitle: String,
value: Int,
enabled: Boolean,
colors: com.radiola.ui.theme.RadiolaColors,
onChange: (Int) -> Unit,
onCommit: () -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = colors.textPrimary)
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = colors.textSecondary)
}
Text(
"$value%",
style = MaterialTheme.typography.titleMedium,
color = colors.accent,
fontWeight = FontWeight.SemiBold
)
}
Slider(
value = value.toFloat(),
onValueChange = { onChange(it.toInt()) },
onValueChangeFinished = onCommit,
valueRange = 0f..100f,
enabled = enabled,
colors = SliderDefaults.colors(
thumbColor = colors.accent,
activeTrackColor = colors.accent,
inactiveTrackColor = colors.surface2
)
)
}
}
private fun freqLabel(hz: Int): String =
if (hz >= 1000) "%.1f kHz".format(hz / 1000f) else "$hz Hz"

View File

@@ -0,0 +1,24 @@
package com.radiola.ui.equalizer
import androidx.lifecycle.ViewModel
import com.radiola.service.AudioEffectsController
import com.radiola.service.EqState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class EqualizerViewModel @Inject constructor(
private val audioEffects: AudioEffectsController
) : ViewModel() {
val state: StateFlow<EqState> = audioEffects.state
fun setEnabled(on: Boolean) = audioEffects.setEnabled(on)
fun selectPreset(index: Int) = audioEffects.selectPreset(index)
fun setBand(index: Int, levelMb: Int) = audioEffects.setBand(index, levelMb)
fun setBass(value: Int) = audioEffects.setBass(value)
fun setVirtualizer(value: Int) = audioEffects.setVirtualizer(value)
fun setLoudness(value: Int) = audioEffects.setLoudness(value)
fun commit() = audioEffects.commit()
}

View File

@@ -24,6 +24,7 @@ sealed class NavDestinations(
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
data object Equalizer : NavDestinations("equalizer", "Эквалайзер", Lucide.Settings, showInBottomBar = false)
companion object {
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)

View File

@@ -29,6 +29,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.SlidersHorizontal
import com.composables.icons.lucide.User
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StationTestStatus
@@ -41,12 +42,12 @@ import com.radiola.ui.theme.ThemePalette
fun SettingsScreen(
onNavigateToAuth: () -> Unit,
onNavigateToAlarms: () -> Unit = {},
onNavigateToEqualizer: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel()
) {
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 themePalette by viewModel.themePalette.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
@@ -57,7 +58,6 @@ fun SettingsScreen(
val testResults by viewModel.testResults.collectAsState()
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
val currentUser by viewModel.currentUser.collectAsState()
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
var showReport by remember { mutableStateOf(false) }
val colors = RadiolaTheme.colors
@@ -309,9 +309,9 @@ fun SettingsScreen(
)
}
// --- Эквалайзер ---
// --- Эквалайзер (отдельный детальный экран) ---
item {
SectionLabel("ЭКВАЛАЙЗЕР")
SectionLabel("ЗВУК")
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
@@ -319,38 +319,35 @@ fun SettingsScreen(
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
.clickable { onNavigateToEqualizer() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
presets.forEach { preset ->
val selected = equalizerPreset == preset
val bgColor by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "eqSegment"
Icon(
Lucide.SlidersHorizontal,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(22.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Эквалайзер",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
val textColor by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "eqText"
Text(
text = "Полосы, пресеты, бас, объём, громкость",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(bgColor)
.clickable { viewModel.setEqualizerPreset(preset) }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = preset,
style = MaterialTheme.typography.labelLarge,
color = textColor,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
Icon(
Lucide.ChevronRight,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(18.dp)
)
}
}