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:
311
app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt
Normal file
311
app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt
Normal 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"
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user