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

@@ -177,6 +177,9 @@ class MainActivity : ComponentActivity() {
}, },
onNavigateToAlarms = { onNavigateToAlarms = {
navController.navigate(NavDestinations.Alarms.route) navController.navigate(NavDestinations.Alarms.route)
},
onNavigateToEqualizer = {
navController.navigate(NavDestinations.Equalizer.route)
} }
) )
} }
@@ -185,6 +188,11 @@ class MainActivity : ComponentActivity() {
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
composable(NavDestinations.Equalizer.route) {
com.radiola.ui.equalizer.EqualizerScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(NavDestinations.Auth.route) { composable(NavDestinations.Auth.route) {
AuthScreen( AuthScreen(
onAuthSuccess = { onAuthSuccess = {

View File

@@ -33,6 +33,12 @@ class SettingsRepositoryImpl @Inject constructor(
private val COUNTRY_CODE = stringPreferencesKey("country_code") private val COUNTRY_CODE = stringPreferencesKey("country_code")
private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style") private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style")
private val THEME_PALETTE = stringPreferencesKey("theme_palette") private val THEME_PALETTE = stringPreferencesKey("theme_palette")
private val EQ_ENABLED = booleanPreferencesKey("eq_enabled")
private val EQ_PRESET = intPreferencesKey("eq_preset")
private val EQ_BANDS = stringPreferencesKey("eq_bands")
private val EQ_BASS = intPreferencesKey("eq_bass")
private val EQ_VIRTUALIZER = intPreferencesKey("eq_virtualizer")
private val EQ_LOUDNESS = intPreferencesKey("eq_loudness")
} }
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] } override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
@@ -65,4 +71,22 @@ class SettingsRepositoryImpl @Inject constructor(
override fun getThemePalette(): Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "forest" } override fun getThemePalette(): Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "forest" }
override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } } override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } }
override fun getEqEnabled(): Flow<Boolean> = dataStore.data.map { it[EQ_ENABLED] ?: false }
override suspend fun setEqEnabled(enabled: Boolean) { dataStore.edit { it[EQ_ENABLED] = enabled } }
override fun getEqPreset(): Flow<Int> = dataStore.data.map { it[EQ_PRESET] ?: -1 }
override suspend fun setEqPreset(index: Int) { dataStore.edit { it[EQ_PRESET] = index } }
override fun getEqBands(): Flow<String> = dataStore.data.map { it[EQ_BANDS] ?: "" }
override suspend fun setEqBands(csv: String) { dataStore.edit { it[EQ_BANDS] = csv } }
override fun getEqBass(): Flow<Int> = dataStore.data.map { it[EQ_BASS] ?: 0 }
override suspend fun setEqBass(value: Int) { dataStore.edit { it[EQ_BASS] = value } }
override fun getEqVirtualizer(): Flow<Int> = dataStore.data.map { it[EQ_VIRTUALIZER] ?: 0 }
override suspend fun setEqVirtualizer(value: Int) { dataStore.edit { it[EQ_VIRTUALIZER] = value } }
override fun getEqLoudness(): Flow<Int> = dataStore.data.map { it[EQ_LOUDNESS] ?: 0 }
override suspend fun setEqLoudness(value: Int) { dataStore.edit { it[EQ_LOUDNESS] = value } }
} }

View File

@@ -26,4 +26,20 @@ interface SettingsRepository {
// Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest". // Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest".
fun getThemePalette(): Flow<String> fun getThemePalette(): Flow<String>
suspend fun setThemePalette(id: String) suspend fun setThemePalette(id: String)
// ── Эквалайзер и улучшайзеры звука (android.media.audiofx) ──
fun getEqEnabled(): Flow<Boolean>
suspend fun setEqEnabled(enabled: Boolean)
// Индекс системного пресета эквалайзера; -1 = свой (ручные полосы).
fun getEqPreset(): Flow<Int>
suspend fun setEqPreset(index: Int)
// Уровни полос в миллибелах через запятую (под текущее число полос устройства).
fun getEqBands(): Flow<String>
suspend fun setEqBands(csv: String)
fun getEqBass(): Flow<Int> // 0..100 %
suspend fun setEqBass(value: Int)
fun getEqVirtualizer(): Flow<Int> // 0..100 %
suspend fun setEqVirtualizer(value: Int)
fun getEqLoudness(): Flow<Int> // 0..100 % → 0..+12 дБ
suspend fun setEqLoudness(value: Int)
} }

View File

@@ -0,0 +1,259 @@
package com.radiola.service
import android.content.Context
import android.media.audiofx.BassBoost
import android.media.audiofx.Equalizer
import android.media.audiofx.LoudnessEnhancer
import android.media.audiofx.Virtualizer
import android.util.Log
import com.radiola.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/** Одна полоса эквалайзера: центральная частота и текущий/предельный уровень в мБ. */
data class EqBand(
val index: Int,
val centerHz: Int,
val minMb: Int,
val maxMb: Int,
val levelMb: Int
)
/** Снимок состояния эквалайзера и улучшайзеров для UI. */
data class EqState(
val available: Boolean = false,
val enabled: Boolean = false,
val bands: List<EqBand> = emptyList(),
val presets: List<String> = emptyList(),
val currentPreset: Int = -1, // -1 = свой
val hasBass: Boolean = false,
val hasVirtualizer: Boolean = false,
val hasLoudness: Boolean = false,
val bass: Int = 0, // 0..100 %
val virtualizer: Int = 0, // 0..100 %
val loudness: Int = 0 // 0..100 % → 0..+12 дБ
)
/**
* Управляет системными аудиоэффектами (android.media.audiofx), привязанными к
* аудиосессии плеера: графический эквалайзер + Bass Boost + Virtualizer (объём) +
* LoudnessEnhancer (громкость тихих). Применяет в реальном времени, переживает смену
* станции (сессия фиксированная), сохраняет настройки в DataStore. Эффекты — best-effort:
* на устройствах без поддержки соответствующий блок просто недоступен (null).
*/
@Singleton
class AudioEffectsController @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsRepository
) {
private val tag = "AudioEffects"
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var equalizer: Equalizer? = null
private var bassBoost: BassBoost? = null
private var virtualizer: Virtualizer? = null
private var loudness: LoudnessEnhancer? = null
private var masterEnabled = false
private val _state = MutableStateFlow(EqState())
val state: StateFlow<EqState> = _state.asStateFlow()
/** Привязывает эффекты к аудиосессии и применяет сохранённые настройки. */
fun attach(sessionId: Int) {
release()
equalizer = try {
Equalizer(0, sessionId)
} catch (e: Exception) {
Log.w(tag, "Equalizer недоступен: ${e.message}"); null
}
bassBoost = try {
BassBoost(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
} catch (e: Exception) {
null
}
virtualizer = try {
Virtualizer(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
} catch (e: Exception) {
null
}
loudness = try {
LoudnessEnhancer(sessionId)
} catch (e: Exception) {
null
}
scope.launch { loadAndApply() }
}
private suspend fun loadAndApply() {
val enabled = settings.getEqEnabled().first()
val preset = settings.getEqPreset().first()
val bandsCsv = settings.getEqBands().first()
val bass = settings.getEqBass().first()
val virt = settings.getEqVirtualizer().first()
val loud = settings.getEqLoudness().first()
masterEnabled = enabled
val eq = equalizer
if (eq != null) {
runCatching { eq.enabled = enabled }
val saved = bandsCsv.split(",").mapNotNull { it.trim().toShortOrNull() }
if (preset in 0 until eq.numberOfPresets) {
runCatching { eq.usePreset(preset.toShort()) }
} else if (saved.size == eq.numberOfBands.toInt()) {
saved.forEachIndexed { i, lvl -> runCatching { eq.setBandLevel(i.toShort(), lvl) } }
}
}
applyBassInternal(bass)
applyVirtualizerInternal(virt)
applyLoudnessInternal(loud)
emitState(enabled, preset, bass, virt, loud)
}
// ── Публичные действия (UI) ──
fun setEnabled(on: Boolean) {
masterEnabled = on
runCatching { equalizer?.enabled = on }
val s = _state.value
applyBassInternal(s.bass)
applyVirtualizerInternal(s.virtualizer)
applyLoudnessInternal(s.loudness)
persist { settings.setEqEnabled(on) }
_state.value = s.copy(enabled = on)
}
fun selectPreset(index: Int) {
val eq = equalizer ?: return
if (index !in 0 until eq.numberOfPresets) return
runCatching { eq.usePreset(index.toShort()) }
persist { settings.setEqPreset(index); settings.setEqBands(currentBandsCsv()) }
emitState(_state.value.enabled, index, _state.value.bass, _state.value.virtualizer, _state.value.loudness)
}
// setBand/setBass/... применяют к железу + правят in-memory состояние БЕЗ записи в
// DataStore (вызываются на каждое движение слайдера). Запись — один раз в commit()
// на onValueChangeFinished, чтобы не спамить хранилище десятками правок за драг.
fun setBand(index: Int, levelMb: Int) {
val eq = equalizer ?: return
runCatching { eq.setBandLevel(index.toShort(), levelMb.toShort()) }
val s = _state.value
_state.value = s.copy(
currentPreset = -1, // ручная правка → «свой»
bands = s.bands.map { if (it.index == index) it.copy(levelMb = levelMb) else it }
)
}
fun setBass(value: Int) {
applyBassInternal(value)
_state.value = _state.value.copy(bass = value)
}
fun setVirtualizer(value: Int) {
applyVirtualizerInternal(value)
_state.value = _state.value.copy(virtualizer = value)
}
fun setLoudness(value: Int) {
applyLoudnessInternal(value)
_state.value = _state.value.copy(loudness = value)
}
/** Сохраняет текущее состояние полос/улучшайзеров (вызывать при отпускании слайдера). */
fun commit() {
val s = _state.value
persist {
settings.setEqPreset(s.currentPreset)
settings.setEqBands(currentBandsCsv())
settings.setEqBass(s.bass)
settings.setEqVirtualizer(s.virtualizer)
settings.setEqLoudness(s.loudness)
}
}
// ── Внутреннее применение к железу ──
private fun applyBassInternal(value: Int) {
val bb = bassBoost ?: return
runCatching {
bb.enabled = masterEnabled && value > 0
if (masterEnabled && value > 0) bb.setStrength((value.coerceIn(0, 100) * 10).toShort())
}
}
private fun applyVirtualizerInternal(value: Int) {
val vz = virtualizer ?: return
runCatching {
vz.enabled = masterEnabled && value > 0
if (masterEnabled && value > 0) vz.setStrength((value.coerceIn(0, 100) * 10).toShort())
}
}
private fun applyLoudnessInternal(value: Int) {
val le = loudness ?: return
runCatching {
le.enabled = masterEnabled && value > 0
// 0..100 % → 0..1200 мБ (= 0..+12 дБ)
if (masterEnabled && value > 0) le.setTargetGain(value.coerceIn(0, 100) * 12)
}
}
private fun currentBandsCsv(): String {
val eq = equalizer ?: return ""
return (0 until eq.numberOfBands).joinToString(",") { eq.getBandLevel(it.toShort()).toString() }
}
private fun emitState(enabled: Boolean, preset: Int, bass: Int, virt: Int, loud: Int) {
val eq = equalizer
val bands = if (eq != null) {
val range = eq.bandLevelRange // [min, max] в мБ
(0 until eq.numberOfBands).map { i ->
val b = i.toShort()
EqBand(
index = i,
centerHz = eq.getCenterFreq(b) / 1000, // мГц → Гц
minMb = range[0].toInt(),
maxMb = range[1].toInt(),
levelMb = eq.getBandLevel(b).toInt()
)
}
} else emptyList()
val presets = if (eq != null) {
(0 until eq.numberOfPresets).map { eq.getPresetName(it.toShort()) }
} else emptyList()
_state.value = EqState(
available = eq != null,
enabled = enabled,
bands = bands,
presets = presets,
currentPreset = preset,
hasBass = bassBoost != null,
hasVirtualizer = virtualizer != null,
hasLoudness = loudness != null,
bass = bass,
virtualizer = virt,
loudness = loud
)
}
private fun persist(block: suspend () -> Unit) {
scope.launch { runCatching { block() } }
}
fun release() {
runCatching { equalizer?.release() }
runCatching { bassBoost?.release() }
runCatching { virtualizer?.release() }
runCatching { loudness?.release() }
equalizer = null; bassBoost = null; virtualizer = null; loudness = null
}
}

View File

@@ -42,7 +42,8 @@ import javax.inject.Singleton
@Singleton @Singleton
class PlayerController @Inject constructor( class PlayerController @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
private val sleepSoundPlayer: SleepSoundPlayer private val sleepSoundPlayer: SleepSoundPlayer,
private val audioEffects: AudioEffectsController
) { ) {
// Анализатор спектра реального звука — для «живого» эквалайзера. // Анализатор спектра реального звука — для «живого» эквалайзера.
private val spectrumAnalyzer = AudioSpectrumAnalyzer() private val spectrumAnalyzer = AudioSpectrumAnalyzer()
@@ -180,6 +181,11 @@ class PlayerController @Inject constructor(
} }
} }
}) })
// Фиксированная аудиосессия → эффекты (эквалайзер и т.д.) держатся на ней
// и переживают смену станций. Привязываем их сразу после создания плеера.
val sessionId = audioManager.generateAudioSessionId()
runCatching { setAudioSessionId(sessionId) }
audioEffects.attach(sessionId)
} }
val player: Player = object : ForwardingPlayer(exoPlayer) { val player: Player = object : ForwardingPlayer(exoPlayer) {

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