From e736c2393f527d407c13e682e6be3f4a07e34f60 Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 21:14:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(eq):=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D1=89=D0=B8=D0=B9=20=D1=8D=D0=BA=D0=B2=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=B7=D0=B5=D1=80=20+=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B0=D0=B9=D0=B7=D0=B5=D1=80=D1=8B=20=D0=B7=D0=B2=D1=83=D0=BA?= =?UTF-8?q?=D0=B0=20(audiofx)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше пресет эквалайзера в настройках был косметикой (лежал в DataStore, к звуку не подключён). Теперь — реальные системные эффекты на фикс. аудиосессии плеера: - AudioEffectsController: графический Equalizer (полосы устройства), BassBoost, Virtualizer (объём), LoudnessEnhancer (громкость тихих, до +12 дБ). Привязка к generateAudioSessionId() в PlayerController, переживает смену станций. Применение в реальном времени, сохранение в DataStore (commit на отпускании слайдера). - Отдельный экран EqualizerScreen (Настройки → ЗВУК → Эквалайзер): тумблер, системные пресеты + «Свой», слайдеры полос (±дБ), bass/virtualizer/loudness. - Эффекты best-effort: при отсутствии поддержки блок недоступен (null), UI скрывает. - Убран фейковый чип-пресет Flat/Rock/Pop/Jazz/Bass. --- app/src/main/java/com/radiola/MainActivity.kt | 8 + .../data/repository/SettingsRepositoryImpl.kt | 24 ++ .../domain/repository/SettingsRepository.kt | 16 + .../radiola/service/AudioEffectsController.kt | 259 +++++++++++++++ .../com/radiola/service/PlayerController.kt | 8 +- .../radiola/ui/equalizer/EqualizerScreen.kt | 311 ++++++++++++++++++ .../ui/equalizer/EqualizerViewModel.kt | 24 ++ .../radiola/ui/navigation/NavDestinations.kt | 1 + .../com/radiola/ui/settings/SettingsScreen.kt | 61 ++-- 9 files changed, 679 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/radiola/service/AudioEffectsController.kt create mode 100644 app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt create mode 100644 app/src/main/java/com/radiola/ui/equalizer/EqualizerViewModel.kt diff --git a/app/src/main/java/com/radiola/MainActivity.kt b/app/src/main/java/com/radiola/MainActivity.kt index 51d0060..525ec83 100644 --- a/app/src/main/java/com/radiola/MainActivity.kt +++ b/app/src/main/java/com/radiola/MainActivity.kt @@ -177,6 +177,9 @@ class MainActivity : ComponentActivity() { }, onNavigateToAlarms = { navController.navigate(NavDestinations.Alarms.route) + }, + onNavigateToEqualizer = { + navController.navigate(NavDestinations.Equalizer.route) } ) } @@ -185,6 +188,11 @@ class MainActivity : ComponentActivity() { onNavigateBack = { navController.popBackStack() } ) } + composable(NavDestinations.Equalizer.route) { + com.radiola.ui.equalizer.EqualizerScreen( + onNavigateBack = { navController.popBackStack() } + ) + } composable(NavDestinations.Auth.route) { AuthScreen( onAuthSuccess = { diff --git a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt index b9976c6..88616cf 100644 --- a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt @@ -33,6 +33,12 @@ class SettingsRepositoryImpl @Inject constructor( private val COUNTRY_CODE = stringPreferencesKey("country_code") private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style") 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 = dataStore.data.map { it[LAST_STATION_ID] } @@ -65,4 +71,22 @@ class SettingsRepositoryImpl @Inject constructor( override fun getThemePalette(): Flow = dataStore.data.map { it[THEME_PALETTE] ?: "forest" } override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } } + + override fun getEqEnabled(): Flow = dataStore.data.map { it[EQ_ENABLED] ?: false } + override suspend fun setEqEnabled(enabled: Boolean) { dataStore.edit { it[EQ_ENABLED] = enabled } } + + override fun getEqPreset(): Flow = dataStore.data.map { it[EQ_PRESET] ?: -1 } + override suspend fun setEqPreset(index: Int) { dataStore.edit { it[EQ_PRESET] = index } } + + override fun getEqBands(): Flow = dataStore.data.map { it[EQ_BANDS] ?: "" } + override suspend fun setEqBands(csv: String) { dataStore.edit { it[EQ_BANDS] = csv } } + + override fun getEqBass(): Flow = dataStore.data.map { it[EQ_BASS] ?: 0 } + override suspend fun setEqBass(value: Int) { dataStore.edit { it[EQ_BASS] = value } } + + override fun getEqVirtualizer(): Flow = dataStore.data.map { it[EQ_VIRTUALIZER] ?: 0 } + override suspend fun setEqVirtualizer(value: Int) { dataStore.edit { it[EQ_VIRTUALIZER] = value } } + + override fun getEqLoudness(): Flow = dataStore.data.map { it[EQ_LOUDNESS] ?: 0 } + override suspend fun setEqLoudness(value: Int) { dataStore.edit { it[EQ_LOUDNESS] = value } } } diff --git a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt index e4d03f1..0faaa67 100644 --- a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt @@ -26,4 +26,20 @@ interface SettingsRepository { // Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest". fun getThemePalette(): Flow suspend fun setThemePalette(id: String) + + // ── Эквалайзер и улучшайзеры звука (android.media.audiofx) ── + fun getEqEnabled(): Flow + suspend fun setEqEnabled(enabled: Boolean) + // Индекс системного пресета эквалайзера; -1 = свой (ручные полосы). + fun getEqPreset(): Flow + suspend fun setEqPreset(index: Int) + // Уровни полос в миллибелах через запятую (под текущее число полос устройства). + fun getEqBands(): Flow + suspend fun setEqBands(csv: String) + fun getEqBass(): Flow // 0..100 % + suspend fun setEqBass(value: Int) + fun getEqVirtualizer(): Flow // 0..100 % + suspend fun setEqVirtualizer(value: Int) + fun getEqLoudness(): Flow // 0..100 % → 0..+12 дБ + suspend fun setEqLoudness(value: Int) } diff --git a/app/src/main/java/com/radiola/service/AudioEffectsController.kt b/app/src/main/java/com/radiola/service/AudioEffectsController.kt new file mode 100644 index 0000000..7b65b59 --- /dev/null +++ b/app/src/main/java/com/radiola/service/AudioEffectsController.kt @@ -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 = emptyList(), + val presets: List = 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 = _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 + } +} diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index 92b4d09..c5027a2 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -42,7 +42,8 @@ import javax.inject.Singleton @Singleton class PlayerController @Inject constructor( @ApplicationContext context: Context, - private val sleepSoundPlayer: SleepSoundPlayer + private val sleepSoundPlayer: SleepSoundPlayer, + private val audioEffects: AudioEffectsController ) { // Анализатор спектра реального звука — для «живого» эквалайзера. 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) { diff --git a/app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt b/app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt new file mode 100644 index 0000000..cad13f1 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt @@ -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" diff --git a/app/src/main/java/com/radiola/ui/equalizer/EqualizerViewModel.kt b/app/src/main/java/com/radiola/ui/equalizer/EqualizerViewModel.kt new file mode 100644 index 0000000..60143c8 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/equalizer/EqualizerViewModel.kt @@ -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 = 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() +} diff --git a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt index a2fcefc..189d8a1 100644 --- a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt +++ b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt @@ -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) diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt index 1fa6099..f8eb1ec 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt @@ -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) + ) } }