From 229d6de4c6a3443ed346353725eaf95e03cfc5d0 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:02:40 +0000 Subject: [PATCH] settings: add the replay gain controls Final slice: the screen that drives the mode and preamp preferences the playback side already reads. Until now `setReplayGainMode` and `setReplayGainPreampDb` had no production caller, so Off and the preamp were unreachable. - `SettingsViewModel` exposes both preferences and writes them back. - The Playback section gains a "Replay gain" row opening an Off / Track / Album choice, and a "Preamp" row opening a slider across the +-15 dB range in 0.5 dB steps. - `ReplayGainMode` is ordered Off, Track, Album so the choice list reads that way; the stored value is the name, so the order is not persisted and the default is still Track. - The labels live in their own file so the signed decibel formatting can be tested, as `formatTrackTime` does. --- .../subtracks/data/prefs/UserPreferences.kt | 2 +- .../subtracks/ui/settings/ReplayGainLabels.kt | 23 +++ .../subtracks/ui/settings/SettingsScreen.kt | 135 +++++++++++++++++- .../ui/settings/SettingsViewModel.kt | 19 +++ app/src/main/res/values/strings.xml | 8 ++ .../ui/settings/ReplayGainLabelsTest.kt | 28 ++++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainLabels.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainLabelsTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index d12c7771..e4b829fa 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -57,7 +57,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f enum class SyncMode { Off, WifiOnly, Always } -enum class ReplayGainMode { Track, Album, Off } +enum class ReplayGainMode { Off, Track, Album } data class ArtworkSeedValue( val cacheKey: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainLabels.kt b/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainLabels.kt new file mode 100644 index 00000000..4bfc533e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainLabels.kt @@ -0,0 +1,23 @@ +package com.subtracks.ui.settings + +import android.content.res.Resources +import com.subtracks.R +import com.subtracks.data.prefs.ReplayGainMode + +internal fun replayGainModeLabel( + resources: Resources, + mode: ReplayGainMode, +): String = + when (mode) { + ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off) + ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track) + ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album) + } + +internal fun replayGainPreampLabel( + resources: Resources, + decibels: Float, +): String { + val sign = if (decibels > 0f) "+" else "" + return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$decibels") +} diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 58b95948..3709857a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -46,6 +47,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -66,6 +68,9 @@ import com.subtracks.BuildConfig import com.subtracks.R import com.subtracks.data.model.Source import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES +import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB +import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB +import com.subtracks.data.prefs.ReplayGainMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.SyncMode import com.subtracks.log.Log @@ -84,7 +89,16 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks" private const val SUPPORT_URL = "https://ko-fi.com/austinried" -private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode } +private enum class SettingsDialog { + WifiQuality, + MobileQuality, + DownloadQuality, + SyncConcurrency, + SyncInterval, + SyncMode, + ReplayGain, + ReplayGainPreamp, +} @Composable fun SettingsRoute( @@ -108,6 +122,8 @@ fun SettingsRoute( val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle() val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle() + val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle() + val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() SettingsScreen( @@ -121,6 +137,8 @@ fun SettingsRoute( downloadQuality = downloadQuality, downloadOverMetered = downloadOverMetered, scrobbling = scrobbling, + replayGainMode = replayGainMode, + replayGainPreamp = replayGainPreamp, offline = offline, verboseLogging = verboseLogging, onSelectSource = viewModel::selectSource, @@ -133,6 +151,8 @@ fun SettingsRoute( onDownloadQualityChange = viewModel::setDownloadQuality, onDownloadOverMeteredChange = viewModel::setDownloadOverMetered, onScrobblingChange = viewModel::setScrobbling, + onReplayGainModeChange = viewModel::setReplayGainMode, + onReplayGainPreampChange = viewModel::setReplayGainPreamp, onOfflineChange = viewModel::setOfflineMode, onVerboseLoggingChange = viewModel::setVerboseLogging, onAddServer = onAddServer, @@ -176,6 +196,8 @@ fun SettingsScreen( verboseLogging: Boolean = false, syncMode: SyncMode = SyncMode.WifiOnly, syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES, + replayGainMode: ReplayGainMode = ReplayGainMode.Track, + replayGainPreamp: Float = 0f, onSelectSource: (Long) -> Unit, onEditServer: (Long) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit, @@ -186,6 +208,8 @@ fun SettingsScreen( onDownloadQualityChange: (StreamQuality) -> Unit, onDownloadOverMeteredChange: (Boolean) -> Unit, onScrobblingChange: (Boolean) -> Unit, + onReplayGainModeChange: (ReplayGainMode) -> Unit = {}, + onReplayGainPreampChange: (Float) -> Unit = {}, onOfflineChange: (Boolean) -> Unit = {}, onVerboseLoggingChange: (Boolean) -> Unit = {}, onAddServer: () -> Unit, @@ -337,6 +361,23 @@ fun SettingsScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + item { SectionHeader(stringResource(R.string.settings_music_name)) } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) }, + supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) }, + modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) }, + supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) }, + modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } item { SectionHeader(stringResource(R.string.downloads_title)) } item { ListItem( @@ -519,6 +560,22 @@ fun SettingsScreen( ) } + SettingsDialog.ReplayGain -> { + ReplayGainModeDialog( + selected = replayGainMode, + onSelect = onReplayGainModeChange, + onDismiss = { dialog = null }, + ) + } + + SettingsDialog.ReplayGainPreamp -> { + ReplayGainPreampDialog( + selected = replayGainPreamp, + onSelect = onReplayGainPreampChange, + onDismiss = { dialog = null }, + ) + } + null -> { Unit } @@ -672,6 +729,82 @@ private fun SyncModeDialog( ) } +private const val REPLAY_GAIN_PREAMP_STEPS = 59 + +@Composable +private fun ReplayGainModeDialog( + selected: ReplayGainMode, + onSelect: (ReplayGainMode) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + var draft by remember(selected) { mutableStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_replay_gain_title)) }, + text = { + ChoiceGroup( + header = stringResource(R.string.settings_replay_gain_prompt), + options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) }, + selected = draft, + onSelect = { draft = it }, + ) + }, + confirmButton = { + TextButton( + onClick = { + onSelect(draft) + onDismiss() + }, + ) { Text(stringResource(R.string.action_done)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } + }, + ) +} + +@Composable +private fun ReplayGainPreampDialog( + selected: Float, + onSelect: (Float) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + var draft by remember(selected) { mutableFloatStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) }, + text = { + Column { + Text( + stringResource(R.string.settings_replay_gain_preamp_prompt), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Slider( + value = draft, + onValueChange = { draft = it }, + valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB, + steps = REPLAY_GAIN_PREAMP_STEPS, + ) + Text(replayGainPreampLabel(resources, draft)) + } + }, + confirmButton = { + TextButton( + onClick = { + onSelect(draft) + onDismiss() + }, + ) { Text(stringResource(R.string.action_done)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } + }, + ) +} + @Composable private fun QualityDialog( title: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index dfdfffb9..f59011d7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.subtracks.data.model.Source import com.subtracks.data.net.NetworkMode import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES +import com.subtracks.data.prefs.ReplayGainMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.UserPreferences @@ -65,6 +66,16 @@ class SettingsViewModel( .scrobbling() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) + val replayGainMode: StateFlow = + userPreferences + .replayGainMode() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track) + + val replayGainPreamp: StateFlow = + userPreferences + .replayGainPreampDb() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f) + val offline: StateFlow = sourceRepository.offline val verboseLogging: StateFlow = @@ -115,4 +126,12 @@ class SettingsViewModel( fun setScrobbling(enabled: Boolean) { viewModelScope.launch { userPreferences.setScrobbling(enabled) } } + + fun setReplayGainMode(mode: ReplayGainMode) { + viewModelScope.launch { userPreferences.setReplayGainMode(mode) } + } + + fun setReplayGainPreamp(value: Float) { + viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 544a2d98..b266bccf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,14 @@ Playback Send now playing and play counts Scrobble to server + Album gain + Off + Track gain + Adjust level on top of the replay gain + Preamp + %1$s dB + Normalise loudness using the gain from the server + Replay gain Network Stream quality: Mobile Stream quality: Wi-Fi diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainLabelsTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainLabelsTest.kt new file mode 100644 index 00000000..3d5bddbe --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainLabelsTest.kt @@ -0,0 +1,28 @@ +package com.subtracks.ui.settings + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.prefs.ReplayGainMode +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReplayGainLabelsTest { + private val resources = ApplicationProvider.getApplicationContext().resources + + @Test + fun modeLabelsNameTheGainEachOneUses() { + assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off)) + assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track)) + assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album)) + } + + @Test + fun preampLabelsCarryTheSignAndTheUnit() { + assertEquals("+3.0 dB", replayGainPreampLabel(resources, 3f)) + assertEquals("-2.5 dB", replayGainPreampLabel(resources, -2.5f)) + assertEquals("0.0 dB", replayGainPreampLabel(resources, 0f)) + } +}