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 = {
|
||||
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 = {
|
||||
|
||||
@@ -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<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 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".
|
||||
fun getThemePalette(): Flow<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
|
||||
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) {
|
||||
|
||||
311
app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt
Normal file
311
app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt
Normal file
@@ -0,0 +1,311 @@
|
||||
package com.radiola.ui.equalizer
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.ChevronLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.radiola.service.EqBand
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
@Composable
|
||||
fun EqualizerScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: EqualizerViewModel = hiltViewModel()
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val state by viewModel.state.collectAsState()
|
||||
val on = state.enabled
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Шапка с кнопкой «назад»
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Lucide.ChevronLeft,
|
||||
contentDescription = "Назад",
|
||||
tint = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onNavigateBack() }
|
||||
.padding(8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Эквалайзер",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.available) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Эквалайзер недоступен на этом устройстве",
|
||||
color = colors.textSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// Главный тумблер
|
||||
Card(colors) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Эквалайзер",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
"Тонкая настройка звука и улучшайзеры",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = on,
|
||||
onCheckedChange = { viewModel.setEnabled(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = colors.bgBase,
|
||||
checkedTrackColor = colors.accent,
|
||||
uncheckedThumbColor = colors.textMuted,
|
||||
uncheckedTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Пресеты
|
||||
if (state.presets.isNotEmpty()) {
|
||||
Label("ПРЕСЕТЫ")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
state.presets.forEachIndexed { index, name ->
|
||||
val selected = state.currentPreset == index
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (selected) colors.accent else colors.surface2)
|
||||
.clickable(enabled = on) { viewModel.selectPreset(index) }
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
// «Свой» — активен, когда полосы правились вручную
|
||||
val custom = state.currentPreset == -1
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (custom) colors.accent else colors.surface2)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Свой",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (custom) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = if (custom) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Полосы эквалайзера
|
||||
Label("ЧАСТОТЫ")
|
||||
Card(colors) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
state.bands.forEach { band ->
|
||||
BandRow(
|
||||
band = band,
|
||||
enabled = on,
|
||||
colors = colors,
|
||||
onChange = { viewModel.setBand(band.index, it) },
|
||||
onCommit = { viewModel.commit() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Улучшайзеры
|
||||
Label("УЛУЧШАЙЗЕРЫ")
|
||||
Card(colors) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (state.hasBass) {
|
||||
EnhancerSlider("Bass Boost", "Усиление баса", state.bass, on, colors,
|
||||
{ viewModel.setBass(it) }, { viewModel.commit() })
|
||||
}
|
||||
if (state.hasVirtualizer) {
|
||||
EnhancerSlider("Virtualizer", "Объём / ширина стерео", state.virtualizer, on, colors,
|
||||
{ viewModel.setVirtualizer(it) }, { viewModel.commit() })
|
||||
}
|
||||
if (state.hasLoudness) {
|
||||
EnhancerSlider("Громкость", "Подъём тихих станций (до +12 дБ)", state.loudness, on, colors,
|
||||
{ viewModel.setLoudness(it) }, { viewModel.commit() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Card(colors: com.radiola.ui.theme.RadiolaColors, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Label(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = RadiolaTheme.colors.textMuted,
|
||||
letterSpacing = 1.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BandRow(
|
||||
band: EqBand,
|
||||
enabled: Boolean,
|
||||
colors: com.radiola.ui.theme.RadiolaColors,
|
||||
onChange: (Int) -> Unit,
|
||||
onCommit: () -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = freqLabel(band.centerHz),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.width(60.dp)
|
||||
)
|
||||
Slider(
|
||||
value = band.levelMb.toFloat(),
|
||||
onValueChange = { onChange(it.toInt()) },
|
||||
onValueChangeFinished = onCommit,
|
||||
valueRange = band.minMb.toFloat()..band.maxMb.toFloat(),
|
||||
enabled = enabled,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = colors.accent,
|
||||
activeTrackColor = colors.accent,
|
||||
inactiveTrackColor = colors.surface2
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "%+d dB".format(band.levelMb / 100),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.accent,
|
||||
modifier = Modifier.width(52.dp),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnhancerSlider(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
colors: com.radiola.ui.theme.RadiolaColors,
|
||||
onChange: (Int) -> Unit,
|
||||
onCommit: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = colors.textPrimary)
|
||||
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = colors.textSecondary)
|
||||
}
|
||||
Text(
|
||||
"$value%",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value.toFloat(),
|
||||
onValueChange = { onChange(it.toInt()) },
|
||||
onValueChangeFinished = onCommit,
|
||||
valueRange = 0f..100f,
|
||||
enabled = enabled,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = colors.accent,
|
||||
activeTrackColor = colors.accent,
|
||||
inactiveTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun freqLabel(hz: Int): String =
|
||||
if (hz >= 1000) "%.1f kHz".format(hz / 1000f) else "$hz Hz"
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.radiola.ui.equalizer
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.radiola.service.AudioEffectsController
|
||||
import com.radiola.service.EqState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EqualizerViewModel @Inject constructor(
|
||||
private val audioEffects: AudioEffectsController
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<EqState> = audioEffects.state
|
||||
|
||||
fun setEnabled(on: Boolean) = audioEffects.setEnabled(on)
|
||||
fun selectPreset(index: Int) = audioEffects.selectPreset(index)
|
||||
fun setBand(index: Int, levelMb: Int) = audioEffects.setBand(index, levelMb)
|
||||
fun setBass(value: Int) = audioEffects.setBass(value)
|
||||
fun setVirtualizer(value: Int) = audioEffects.setVirtualizer(value)
|
||||
fun setLoudness(value: Int) = audioEffects.setLoudness(value)
|
||||
fun commit() = audioEffects.commit()
|
||||
}
|
||||
@@ -24,6 +24,7 @@ sealed class NavDestinations(
|
||||
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
|
||||
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
|
||||
data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
|
||||
data object Equalizer : NavDestinations("equalizer", "Эквалайзер", Lucide.Settings, showInBottomBar = false)
|
||||
|
||||
companion object {
|
||||
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.SlidersHorizontal
|
||||
import com.composables.icons.lucide.User
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.StationTestStatus
|
||||
@@ -41,12 +42,12 @@ import com.radiola.ui.theme.ThemePalette
|
||||
fun SettingsScreen(
|
||||
onNavigateToAuth: () -> Unit,
|
||||
onNavigateToAlarms: () -> Unit = {},
|
||||
onNavigateToEqualizer: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
|
||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
||||
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
|
||||
val themePalette by viewModel.themePalette.collectAsState()
|
||||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||||
@@ -57,7 +58,6 @@ fun SettingsScreen(
|
||||
val testResults by viewModel.testResults.collectAsState()
|
||||
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
|
||||
val currentUser by viewModel.currentUser.collectAsState()
|
||||
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
|
||||
var showReport by remember { mutableStateOf(false) }
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
@@ -309,9 +309,9 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// --- Эквалайзер ---
|
||||
// --- Эквалайзер (отдельный детальный экран) ---
|
||||
item {
|
||||
SectionLabel("ЭКВАЛАЙЗЕР")
|
||||
SectionLabel("ЗВУК")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -319,38 +319,35 @@ fun SettingsScreen(
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
.clickable { onNavigateToEqualizer() }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
presets.forEach { preset ->
|
||||
val selected = equalizerPreset == preset
|
||||
val bgColor by animateColorAsState(
|
||||
targetValue = if (selected) colors.accent else colors.surface2,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "eqSegment"
|
||||
Icon(
|
||||
Lucide.SlidersHorizontal,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Эквалайзер",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "eqText"
|
||||
Text(
|
||||
text = "Полосы, пресеты, бас, объём, громкость",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bgColor)
|
||||
.clickable { viewModel.setEqualizerPreset(preset) }
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = preset,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = textColor,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Lucide.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user