mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
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:
@@ -57,7 +57,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
|||||||
|
|
||||||
enum class SyncMode { Off, WifiOnly, Always }
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
enum class ReplayGainMode { Track, Album, Off }
|
enum class ReplayGainMode { Off, Track, Album }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -46,6 +47,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -66,6 +68,9 @@ import com.subtracks.BuildConfig
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
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.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.log.Log
|
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 PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
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
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
@@ -108,6 +122,8 @@ fun SettingsRoute(
|
|||||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
|
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||||
|
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -121,6 +137,8 @@ fun SettingsRoute(
|
|||||||
downloadQuality = downloadQuality,
|
downloadQuality = downloadQuality,
|
||||||
downloadOverMetered = downloadOverMetered,
|
downloadOverMetered = downloadOverMetered,
|
||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
|
replayGainMode = replayGainMode,
|
||||||
|
replayGainPreamp = replayGainPreamp,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
verboseLogging = verboseLogging,
|
verboseLogging = verboseLogging,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
@@ -133,6 +151,8 @@ fun SettingsRoute(
|
|||||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
|
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||||
|
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
@@ -176,6 +196,8 @@ fun SettingsScreen(
|
|||||||
verboseLogging: Boolean = false,
|
verboseLogging: Boolean = false,
|
||||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
|
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
|
||||||
|
replayGainPreamp: Float = 0f,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
@@ -186,6 +208,8 @@ fun SettingsScreen(
|
|||||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
|
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||||
|
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
@@ -337,6 +361,23 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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 { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
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 -> {
|
null -> {
|
||||||
Unit
|
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
|
@Composable
|
||||||
private fun QualityDialog(
|
private fun QualityDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
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.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
@@ -65,6 +66,16 @@ class SettingsViewModel(
|
|||||||
.scrobbling()
|
.scrobbling()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
.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 offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val verboseLogging: StateFlow<Boolean> =
|
val verboseLogging: StateFlow<Boolean> =
|
||||||
@@ -115,4 +126,12 @@ class SettingsViewModel(
|
|||||||
fun setScrobbling(enabled: Boolean) {
|
fun setScrobbling(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setReplayGainPreamp(value: Float) {
|
||||||
|
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,14 @@
|
|||||||
<string name="settings_music_name">Playback</string>
|
<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_description_on">Send now playing and play counts</string>
|
||||||
<string name="settings_music_options_scrobble_title">Scrobble to server</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_name">Network</string>
|
||||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</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>
|
<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user