dolby: Introduce graphical equalizer

Squashed:

dolby: Refresh preset name on main screen

Change-Id: I96783e2a03c384f031787f4cc9140f7d64dadb2f
Signed-off-by: Pranav Vashi <neobuddy89@gmail.com>

Change-Id: I38ee6ce594e5671af42afc3d4bf0f004329482b9
This commit is contained in:
Adithya R 2024-08-03 08:36:46 +05:30 committed by basamaryan
parent ab222058f5
commit b10f3eefc6
No known key found for this signature in database
GPG Key ID: 707BA6C82329E8F9
22 changed files with 1156 additions and 21 deletions

View File

@ -18,6 +18,10 @@ android_app {
overrides: ["MusicFX"],
static_libs: [
"SettingsLib",
"SpaLib",
"androidx.activity_activity-compose",
"androidx.compose.material3_material3",
"androidx.compose.runtime_runtime",
"androidx.preference_preference",
],
}

View File

@ -47,6 +47,17 @@
android:value="content://co.aospa.dolby.xiaomi.summary/dolby" />
</activity>
<activity
android:name=".geq.EqualizerActivity"
android:label="@string/dolby_preset"
android:theme="@style/Theme.SubSettingsBase"
android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".DolbyTileService"
android:icon="@drawable/ic_dolby_qs"

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="?android:attr/colorControlNormal"
android:pathData="M520,630L520,570L680,570L680,630L520,630ZM580,840L580,790L520,790L520,730L580,730L580,680L640,680L640,840L580,840ZM680,790L680,730L840,730L840,790L680,790ZM720,680L720,520L780,520L780,570L840,570L840,630L780,630L780,680L720,680ZM831,400L748,400Q722,312 649,256Q576,200 480,200Q363,200 281.5,281.5Q200,363 200,480Q200,552 232.5,612Q265,672 320,710L320,600L400,600L400,840L160,840L160,760L254,760Q192,710 156,637.5Q120,565 120,480Q120,405 148.5,339.5Q177,274 225.5,225.5Q274,177 339.5,148.5Q405,120 480,120Q609,120 706.5,199.5Q804,279 831,400Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="?android:attr/colorControlNormal"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280L840,492Q821,484 800.5,481.5Q780,479 760,482L760,313L647,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L440,760L440,764L440,840L200,840ZM200,200L200,313L200,482Q200,485 200,494.5Q200,504 200,519L200,760L200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200ZM520,920L520,797L741,577Q750,568 761,564Q772,560 783,560Q795,560 806,564.5Q817,569 826,578L863,615Q871,624 875.5,635Q880,646 880,657Q880,668 876,679.5Q872,691 863,700L643,920L520,920ZM820,657L820,657L783,620L783,620L820,657ZM580,860L618,860L739,738L721,719L702,701L580,822L580,860ZM721,719L702,701L702,701L739,738L739,738L721,719ZM240,400L600,400L600,240L240,240L240,400ZM480,720Q481,720 482,720Q483,720 484,720L600,605Q600,603 600,602.5Q600,602 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Z"/>
</vector>

View File

@ -9,7 +9,7 @@
<string name="dolby_title">Dolby Atmos</string>
<string name="dolby_enable">Use Dolby Atmos</string>
<string name="dolby_profile_title">Choose a profile</string>
<string name="dolby_preset">Equalizer preset</string>
<string name="dolby_preset">Graphic equalizer</string>
<string name="dolby_off">Off</string>
<string name="dolby_on">On</string>
<string name="dolby_low">Low</string>
@ -36,7 +36,7 @@
<string name="dolby_profile_voice">Voice</string>
<!-- Dolby equalizer presets -->
<string name="dolby_preset_default">Default</string>
<string name="dolby_preset_default">Flat (off)</string>
<string name="dolby_preset_rock">Rock</string>
<string name="dolby_preset_jazz">Jazz</string>
<string name="dolby_preset_pop">Pop</string>
@ -48,4 +48,17 @@
<string name="dolby_preset_dance">Dance</string>
<string name="dolby_preset_metal">Metal</string>
<!-- Dolby equalizer UI -->
<string name="dolby_geq_slider_label_gain">Gain</string>
<string name="dolby_geq_preset">Preset</string>
<string name="dolby_geq_preset_name">Preset name</string>
<string name="dolby_geq_new_preset">New preset</string>
<string name="dolby_geq_rename_preset">Rename preset</string>
<string name="dolby_geq_delete_preset">Delete preset</string>
<string name="dolby_geq_delete_preset_prompt">Do you want to delete this preset?</string>
<string name="dolby_geq_reset_gains">Reset gains</string>
<string name="dolby_geq_reset_gains_prompt">Do you want to reset this preset to defaults?</string>
<string name="dolby_geq_preset_name_exists">Preset name already exists!</string>
<string name="dolby_geq_preset_name_too_long">Preset name is too long!</string>
</resources>

View File

@ -24,11 +24,14 @@
<PreferenceCategory
android:title="@string/dolby_category_settings">
<ListPreference
<Preference
android:key="dolby_preset"
android:entries="@array/dolby_preset_entries"
android:entryValues="@array/dolby_preset_values"
android:title="@string/dolby_preset" />
android:title="@string/dolby_preset">
<intent
android:action="android.intent.action.MAIN"
android:targetPackage="co.aospa.dolby.xiaomi"
android:targetClass="co.aospa.dolby.xiaomi.geq.EqualizerActivity" />
</Preference>
<SwitchPreference
android:key="dolby_spk_virtualizer"

View File

@ -200,6 +200,18 @@ internal class DolbyController private constructor(
dolbyEffect.setDapParameter(DsParam.GEQ_BAND_GAINS, gains, profile)
}
fun getPresetName(): String {
val presets = context.resources.getStringArray(R.array.dolby_preset_values)
val presetIndex = presets.indexOf(getPreset())
return if (presetIndex == -1) {
"Custom"
} else {
context.resources.getStringArray(
R.array.dolby_preset_entries
)[presetIndex]
}
}
fun getHeadphoneVirtEnabled(profile: Int = this.profile) =
dolbyEffect.getDapParameterBool(DsParam.HEADPHONE_VIRTUALIZER, profile).also {
dlog(TAG, "getHeadphoneVirtEnabled: $it")

View File

@ -34,7 +34,7 @@ class DolbySettingsFragment : PreferenceFragment(),
findPreference<ListPreference>(DolbyConstants.PREF_PROFILE)!!
}
private val presetPref by lazy {
findPreference<ListPreference>(DolbyConstants.PREF_PRESET)!!
findPreference<Preference>(DolbyConstants.PREF_PRESET)!!
}
private val stereoPref by lazy {
findPreference<ListPreference>(DolbyConstants.PREF_STEREO)!!
@ -106,7 +106,6 @@ class DolbySettingsFragment : PreferenceFragment(),
}
}
presetPref.onPreferenceChangeListener = this
hpVirtPref.onPreferenceChangeListener = this
spkVirtPref.onPreferenceChangeListener = this
stereoPref.onPreferenceChangeListener = this
@ -136,6 +135,11 @@ class DolbySettingsFragment : PreferenceFragment(),
super.onDestroyView()
}
override fun onResume() {
super.onResume()
updateProfileSpecificPrefs()
}
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
dlog(TAG, "onPreferenceChange: key=${preference.key} value=$newValue")
when (preference.key) {
@ -146,10 +150,6 @@ class DolbySettingsFragment : PreferenceFragment(),
updateProfileSpecificPrefs()
}
DolbyConstants.PREF_PRESET -> {
dolbyController.setPreset(newValue.toString())
}
DolbyConstants.PREF_SPK_VIRTUALIZER -> {
dolbyController.setSpeakerVirtEnabled(newValue as Boolean)
}
@ -214,15 +214,7 @@ class DolbySettingsFragment : PreferenceFragment(),
if (!enable) return
val preset = dolbyController.getPreset(currentProfile)
presetPref.apply {
if (entryValues.contains(preset)) {
summary = "%s"
value = preset
} else {
summary = unknownRes
}
}
presetPref.summary = dolbyController.getPresetName()
val deValue = dolbyController.getDialogueEnhancerAmount(currentProfile).toString()
dialoguePref.apply {

View File

@ -0,0 +1,53 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.rememberNavController
import co.aospa.dolby.xiaomi.R
import co.aospa.dolby.xiaomi.geq.ui.EqualizerScreen
import co.aospa.dolby.xiaomi.geq.ui.EqualizerViewModel
import com.android.settingslib.spa.framework.compose.localNavController
import com.android.settingslib.spa.framework.theme.SettingsTheme
import com.android.settingslib.spa.widget.scaffold.SettingsScaffold
class EqualizerActivity : ComponentActivity() {
private val viewModel: EqualizerViewModel by viewModels { EqualizerViewModel.Factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SettingsTheme {
MainContent()
}
}
}
@Composable
private fun MainContent() {
val navController = rememberNavController()
CompositionLocalProvider(navController.localNavController()) {
SettingsScaffold(
title = stringResource(id = R.string.dolby_preset)
) { paddingValues ->
EqualizerScreen(
viewModel = viewModel,
modifier = Modifier.padding(paddingValues)
)
}
}
}
}

View File

@ -0,0 +1,12 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.data
data class BandGain(
val band: Int,
var gain: Int = 0
)

View File

@ -0,0 +1,183 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_PRESET
import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog
import co.aospa.dolby.xiaomi.DolbyController
import co.aospa.dolby.xiaomi.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
class EqualizerRepository(
private val context: Context
) {
private val dolbyController by lazy { DolbyController.getInstance(context) }
// Preset is saved as a string of comma separated gains in SharedPreferences
// and is unique to each profile ID
private val profile = dolbyController.profile
private val profileSharedPrefs by lazy {
context.getSharedPreferences(
"profile_$profile",
Context.MODE_PRIVATE
)
}
private val presetsSharedPrefs by lazy {
context.getSharedPreferences(
"presets",
Context.MODE_PRIVATE
)
}
val builtInPresets: List<Preset> by lazy {
val names = context.resources.getStringArray(
R.array.dolby_preset_entries
)
val presets = context.resources.getStringArray(
R.array.dolby_preset_values
)
List(names.size) { index ->
Preset(
name = names[index],
bandGains = deserializeGains(presets[index]),
)
}
}
val defaultPreset by lazy { builtInPresets[0] } // Flat
// User defined presets are stored in a SharedPreferences as
// key - preset name
// value - comma separated string of gains
val userPresets: Flow<List<Preset>> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
dlog(TAG, "presetsSharedPrefs changed")
trySend(
presetsSharedPrefs.all.map { (key, value) ->
Preset(
name = key,
bandGains = deserializeGains(value.toString()),
isUserDefined = true
)
}
)
}
presetsSharedPrefs.registerOnSharedPreferenceChangeListener(listener)
dlog(TAG, "presetsSharedPrefs registered listener")
// trigger an initial emission
listener.onSharedPreferenceChanged(presetsSharedPrefs, null)
awaitClose {
presetsSharedPrefs.unregisterOnSharedPreferenceChangeListener(listener)
dlog(TAG, "presetsSharedPrefs unregistered listener")
}
}
suspend fun getBandGains(): List<BandGain> = withContext(Dispatchers.IO) {
val gains = profileSharedPrefs.getString(PREF_PRESET, dolbyController.getPreset())
return@withContext if (gains.isNullOrEmpty()) {
defaultPreset.bandGains
} else {
deserializeGains(gains)
}.also {
dlog(TAG, "getBandGains: $it")
}
}
suspend fun setBandGains(bandGains: List<BandGain>) = withContext(Dispatchers.IO) {
dlog(TAG, "setBandGains($bandGains)")
val gains = serializeGains(bandGains)
dolbyController.setPreset(gains)
profileSharedPrefs.edit()
.putString(PREF_PRESET, gains)
.apply()
}
suspend fun addPreset(preset: Preset) = withContext(Dispatchers.IO) {
dlog(TAG, "addPreset($preset)")
presetsSharedPrefs.edit()
.putString(preset.name, serializeGains(preset.bandGains))
.apply()
}
suspend fun removePreset(preset: Preset) = withContext(Dispatchers.IO) {
dlog(TAG, "removePreset($preset)")
presetsSharedPrefs.edit()
.remove(preset.name)
.apply()
}
private companion object {
const val TAG = "EqRepository"
val tenBandFreqs = intArrayOf(
32,
64,
125,
250,
500,
1000,
2000,
4000,
8000,
16000
)
fun deserializeGains(bandGains: String): List<BandGain> {
val gains: List<Int> =
bandGains.split(",").runCatching {
require(size == 20) {
"Preset must have 20 elements, has only $size!"
}
map { it.toInt() }
.twentyToTenBandGains()
}.onFailure { exception ->
Log.e(TAG, "Failed to parse preset", exception)
}.getOrDefault(
// fallback to flat
List<Int>(10) { 0 }
)
return List(10) { index ->
BandGain(
band = tenBandFreqs[index],
gain = gains[index]
)
}
}
fun serializeGains(bandGains: List<BandGain>): String {
return bandGains.map { it.gain }
.tenToTwentyBandGains()
.joinToString(",")
}
// we show only 10 bands in UI however backend requires 20 bands
fun List<Int>.tenToTwentyBandGains() =
List<Int>(20) { index ->
if (index % 2 == 1 && index < 19) {
// every odd element is the average of its surrounding elements
(this[(index - 1) / 2] + this[(index + 1) / 2]) / 2
} else {
this[index / 2]
}
}
fun List<Int>.twentyToTenBandGains() =
// skip every odd element
filterIndexed { index, _ -> index % 2 == 0 }
}
}

View File

@ -0,0 +1,14 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.data
data class Preset(
var name: String,
val bandGains: List<BandGain>,
var isUserDefined: Boolean = false,
var isMutated: Boolean = false
)

View File

@ -0,0 +1,101 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.aospa.dolby.xiaomi.geq.data.BandGain
@Composable
fun BandGainSlider(
bandGain: BandGain,
onValueChangeFinished: (Int) -> Unit
) {
// Gain range is of -1->1 in UI, -100->100 in backend, but actually is -10->10 dB.
// Ensure we update the slider when gain is changed,
// for eg. when changing the preset
var sliderPosition by remember(bandGain.gain) {
mutableFloatStateOf(bandGain.gain / 100f)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
SliderText(
"%.1f".format(sliderPosition * 10f)
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
onValueChangeFinished((sliderPosition * 100f).toInt())
},
valueRange = -1f..1f,
modifier = Modifier
.graphicsLayer {
rotationZ = 270f
transformOrigin = TransformOrigin(0f, 0f)
}
.layout { measurable, constraints ->
val placeable = measurable.measure(
Constraints(
minWidth = constraints.minHeight,
maxWidth = constraints.maxHeight,
minHeight = constraints.minWidth,
maxHeight = constraints.maxHeight,
)
)
layout(placeable.height, placeable.width) {
placeable.place(-placeable.width, 0)
}
}
// horizontal and vertical dimensions are inverted due to rotation
.width(200.dp)
.height(40.dp)
.padding(8.dp)
)
SliderText(
with(bandGain.band) {
if (this >= 1000) {
"${this / 1000}k"
} else {
"$this"
}
}
)
}
}
@Composable
fun SliderText(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
modifier = modifier,
fontSize = 12.sp
)
}

View File

@ -0,0 +1,71 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.aospa.dolby.xiaomi.R
@Composable
fun BandGainSliderLabels() {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(end = 8.dp)
) {
LabelText(
stringResource(id = R.string.dolby_geq_slider_label_gain)
)
Column(
modifier = Modifier.height(200.dp),
horizontalAlignment = Alignment.End
) {
LabelText(
"+10 dB",
modifier = Modifier.padding(
top = 10.dp
)
)
Spacer(
modifier = Modifier.weight(1f)
)
LabelText("0 dB")
Spacer(
modifier = Modifier.weight(1f)
)
LabelText(
"-10 dB",
modifier = Modifier.padding(
bottom = 10.dp
)
)
}
LabelText("Hz")
}
}
@Composable
fun LabelText(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
modifier = modifier,
color = MaterialTheme.colorScheme.secondary,
fontSize = 12.sp
)
}

View File

@ -0,0 +1,58 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
@Composable
fun ConfirmationDialog(
text: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
var showDialog by remember { mutableStateOf(true) }
if (!showDialog) {
onDismiss()
return
}
AlertDialog(
onDismissRequest = { showDialog = false },
confirmButton = {
TextButton(
onClick = {
showDialog = false
onConfirm()
}
) {
Text(
stringResource(id = android.R.string.ok)
)
}
},
dismissButton = {
TextButton(
onClick = { showDialog = false }
) {
Text(
stringResource(id = android.R.string.cancel)
)
}
},
text = {
Text(text)
}
)
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
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
@Composable
fun EqualizerBands(viewModel: EqualizerViewModel) {
val preset by viewModel.preset.collectAsState()
val bandGains = preset.bandGains
LazyRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
item {
BandGainSliderLabels()
}
items(bandGains.size) { index ->
BandGainSlider(
bandGains[index],
onValueChangeFinished = {
viewModel.setGain(index, it)
}
)
}
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.settingslib.spa.framework.theme.SettingsDimension
import com.android.settingslib.spa.framework.theme.SettingsTheme
@Composable
fun EqualizerScreen(
viewModel: EqualizerViewModel,
modifier: Modifier = Modifier
) {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(SettingsDimension.itemPadding)
.then(modifier),
color = SettingsTheme.colorScheme.background
) {
Column(
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight()
) {
PresetSelector(viewModel = viewModel)
EqualizerBands(viewModel = viewModel)
}
}
}

View File

@ -0,0 +1,175 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.aospa.dolby.xiaomi.geq.data.EqualizerRepository
import co.aospa.dolby.xiaomi.geq.data.Preset
import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
const val TAG = "EqViewModel"
class EqualizerViewModel(
private val repository: EqualizerRepository
) : ViewModel() {
private val _presets = MutableStateFlow(repository.builtInPresets)
val presets = _presets.asStateFlow()
private val _preset = MutableStateFlow(repository.defaultPreset)
val preset = _preset.asStateFlow()
private var presetRestored = false
init {
// Update the list of presets: combined list of user defined presets if any,
// and then the built in presets.
repository.userPresets
.onEach { presets ->
dlog(TAG, "updated userPresets: $presets")
_presets.value = mutableListOf<Preset>().apply {
addAll(presets)
addAll(repository.builtInPresets)
}.toList()
// We can restore the active preset only after the presets list is populated,
// since we do not save the preset name but only its gains.
if (!presetRestored) {
val bandGains = repository.getBandGains()
_preset.value = _presets.value.find {
bandGains == it.bandGains
} ?: Preset(
name = "Custom",
bandGains = bandGains
)
dlog(TAG, "restored preset: ${_preset.value}")
presetRestored = true
}
}
.launchIn(viewModelScope)
// Update the preset in repository everytime we set it here
_preset
.drop(1) // skip the initial value
.onEach {
// wait till the active preset is restored
if (!presetRestored) {
return@onEach
}
dlog(TAG, "updated preset: $it")
repository.setBandGains(it.bandGains)
if (it.isUserDefined) {
repository.addPreset(it)
}
}
.launchIn(viewModelScope)
}
fun reset() {
dlog(TAG, "reset()")
if (_preset.value.isUserDefined) {
// Reset gains to 0
_preset.value = _preset.value.copy(
bandGains = repository.defaultPreset.bandGains
)
} else {
// Switch to flat preset
_preset.value = repository.defaultPreset
}
}
fun setPreset(preset: Preset) {
dlog(TAG, "setPreset($preset)")
_preset.value = preset
}
fun setGain(index: Int, gain: Int) {
dlog(TAG, "setGain($index, $gain)")
_preset.value = _preset.value.run {
copy(
name = if (!isUserDefined) "Custom" else name,
bandGains = bandGains
.toMutableList()
// create a new object to ensure the flow emits an update.
.apply { this[index] = this[index].copy(gain = gain) }
.toList(),
isMutated = true
)
}
}
// Returns string containing the error message if it failed, otherwise null
private fun validatePresetName(name: String): PresetNameValidationError? {
// Ensure we don't have another preset with the same name
return if (
_presets.value
.any { it.name.equals(name.trim(), ignoreCase = true) }
) {
PresetNameValidationError.NAME_EXISTS
} else if (name.length > 50) {
PresetNameValidationError.NAME_TOO_LONG
} else null
}
fun createNewPreset(name: String): PresetNameValidationError? {
dlog(TAG, "createNewPreset($name)")
validatePresetName(name)?.let {
dlog(TAG, "createNewPreset failed: $it")
return it
}
_preset.value = _preset.value.copy(
name = name.trim(),
isUserDefined = true,
isMutated = false
)
return null
}
fun renamePreset(preset: Preset, name: String): PresetNameValidationError? {
dlog(TAG, "renamePreset($preset, $name)")
// create a preset with the new name and same gains
createNewPreset(name = name)?.let {
dlog(TAG, "renamePreset failed")
return it
}
// and delete the old one.
deletePreset(preset, shouldReset = false)
return null
}
fun deletePreset(preset: Preset, shouldReset: Boolean = true) {
dlog(TAG, "deletePreset($preset)")
viewModelScope.launch {
repository.removePreset(preset)
}
if (shouldReset) {
_preset.value = repository.defaultPreset
}
}
companion object {
val Factory = viewModelFactory {
initializer {
EqualizerViewModel(
repository = EqualizerRepository(
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]!!
)
)
}
}
}
}

View File

@ -0,0 +1,94 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import co.aospa.dolby.xiaomi.R
@Composable
fun PresetNameDialog(
title: String,
presetName: String = "",
onPresetNameSet: (String) -> PresetNameValidationError?,
onDismissDialog: () -> Unit
) {
var showDialog by remember { mutableStateOf(true) }
if (!showDialog) {
onDismissDialog()
return
}
var text by remember { mutableStateOf(presetName) }
var error by remember { mutableStateOf<PresetNameValidationError?>(null) }
AlertDialog(
onDismissRequest = { showDialog = false },
confirmButton = {
TextButton(
onClick = {
onPresetNameSet(text)?.let {
// validation failed
error = it
return@TextButton
}
// succeeded
showDialog = false
error = null
}
) {
Text(
stringResource(id = android.R.string.ok)
)
}
},
dismissButton = {
TextButton(
onClick = { showDialog = false }
) {
Text(
stringResource(id = android.R.string.cancel)
)
}
},
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = {
Text(
stringResource(id = R.string.dolby_geq_preset_name)
)
},
isError = error != null,
singleLine = true
)
error?.let {
Text(
text = it.toErrorMessage(),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
)
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import co.aospa.dolby.xiaomi.R
enum class PresetNameValidationError {
NAME_EXISTS,
NAME_TOO_LONG;
@Composable
fun toErrorMessage() =
stringResource(
id = when (this) {
NAME_EXISTS -> R.string.dolby_geq_preset_name_exists
NAME_TOO_LONG -> R.string.dolby_geq_preset_name_too_long
}
)
}

View File

@ -0,0 +1,176 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import co.aospa.dolby.xiaomi.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PresetSelector(viewModel: EqualizerViewModel) {
val presets by viewModel.presets.collectAsState()
val currentPreset by viewModel.preset.collectAsState()
var expanded by remember { mutableStateOf(false) }
var showNewPresetDialog by remember { mutableStateOf(false) }
var showRenamePresetDialog by remember { mutableStateOf(false) }
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showResetConfirmDialog by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier
.padding(end = 8.dp)
.weight(1f)
) {
TextField(
value = currentPreset.name,
onValueChange = { },
readOnly = true,
label = {
Text(
stringResource(id = R.string.dolby_geq_preset)
)
},
singleLine = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier.menuAnchor()
// prevent keyboard from popping up
.focusProperties { canFocus = false }
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
presets.forEach { preset ->
DropdownMenuItem(
text = { Text(text = preset.name) },
onClick = {
viewModel.setPreset(preset)
expanded = false
}
)
}
}
}
TooltipIconButton(
icon = ImageVector.vectorResource(
id = R.drawable.save_as_24px
),
text = stringResource(id = R.string.dolby_geq_new_preset),
onClick = { showNewPresetDialog = true }
)
if (currentPreset.isUserDefined) {
TooltipIconButton(
icon = Icons.Default.Edit,
text = stringResource(id = R.string.dolby_geq_rename_preset),
onClick = { showRenamePresetDialog = true }
)
TooltipIconButton(
icon = Icons.Default.Delete,
text = stringResource(id = R.string.dolby_geq_delete_preset),
onClick = { showDeleteConfirmDialog = true }
)
}
TooltipIconButton(
icon = ImageVector.vectorResource(
id = R.drawable.reset_settings_24px
),
text = stringResource(id = R.string.dolby_geq_reset_gains),
onClick = {
if (currentPreset.isUserDefined) {
showResetConfirmDialog = true
} else {
viewModel.reset()
}
}
)
}
// Dialogs
if (showNewPresetDialog) {
PresetNameDialog(
title = stringResource(id = R.string.dolby_geq_new_preset),
onPresetNameSet = {
return@PresetNameDialog viewModel.createNewPreset(name = it)
},
onDismissDialog = { showNewPresetDialog = false }
)
}
if (showRenamePresetDialog) {
PresetNameDialog(
title = stringResource(id = R.string.dolby_geq_rename_preset),
presetName = currentPreset.name,
onPresetNameSet = {
return@PresetNameDialog viewModel.renamePreset(
preset = currentPreset,
name = it
)
},
onDismissDialog = { showRenamePresetDialog = false }
)
}
if (showDeleteConfirmDialog) {
ConfirmationDialog(
text = stringResource(id = R.string.dolby_geq_delete_preset_prompt),
onConfirm = { viewModel.deletePreset(currentPreset) },
onDismiss = { showDeleteConfirmDialog = false }
)
}
if (showResetConfirmDialog) {
ConfirmationDialog(
text = stringResource(id = R.string.dolby_geq_reset_gains_prompt),
onConfirm = { viewModel.reset() },
onDismiss = { showResetConfirmDialog = false }
)
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2024 Paranoid Android
*
* SPDX-License-Identifier: Apache-2.0
*/
package co.aospa.dolby.xiaomi.geq.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TooltipIconButton(
icon: ImageVector,
text: String,
onClick: () -> Unit
) {
PlainTooltipBox(
tooltip = { Text(text) }
) {
IconButton(
onClick = onClick
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(24.dp)
)
}
}
}