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.
This commit is contained in:
austinried
2026-10-16 13:07:11 +00:00
parent 72784ee914
commit 229d6de4c6
6 changed files with 213 additions and 2 deletions
@@ -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,
@@ -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")
}
@@ -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,
@@ -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<ReplayGainMode> =
userPreferences
.replayGainMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
val replayGainPreamp: StateFlow<Float> =
userPreferences
.replayGainPreampDb()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
val offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> =
@@ -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) }
}
}
+8
View File
@@ -74,6 +74,14 @@
<string name="settings_music_name">Playback</string>
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
<string name="settings_replay_gain_mode_album">Album gain</string>
<string name="settings_replay_gain_mode_off">Off</string>
<string name="settings_replay_gain_mode_track">Track gain</string>
<string name="settings_replay_gain_preamp_prompt">Adjust level on top of the replay gain</string>
<string name="settings_replay_gain_preamp_title">Preamp</string>
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
<string name="settings_replay_gain_prompt">Normalise loudness using the gain from the server</string>
<string name="settings_replay_gain_title">Replay gain</string>
<string name="settings_network_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
@@ -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<Context>().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))
}
}