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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
259
app/src/main/java/com/radiola/service/AudioEffectsController.kt
Normal file
259
app/src/main/java/com/radiola/service/AudioEffectsController.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
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 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user