Files
subtracks/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt
T
austinried d81efe168e playback: make Auto the default ReplayGain mode
Use Auto for the preference fallback, the settings initial state and the controller's pre-preference default. Existing stored modes are unchanged.
2026-10-19 14:13:22 +00:00

966 lines
40 KiB
Kotlin

package com.subtracks.ui.settings
import android.content.res.Resources
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
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.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
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
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.rememberViewportFill
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac")
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,
ReplayGain,
ReplayGainPreamp,
}
@Composable
fun SettingsRoute(
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onEditServer: (Long) -> Unit,
onBack: () -> Unit,
dismissals: Flow<Unit> = emptyFlow(),
viewModel: SettingsViewModel = koinViewModel(),
) {
val sources by viewModel.sources.collectAsStateWithLifecycle()
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val keepScreenOn by viewModel.keepScreenOn.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(
sources = sources,
activeSourceId = activeSourceId,
wifiQuality = wifiQuality,
mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency,
syncMode = syncMode,
syncInterval = syncIntervalMinutes,
downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
keepScreenOn = keepScreenOn,
offline = offline,
verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource,
onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onSyncModeChange = viewModel::setSyncMode,
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onKeepScreenOnChange = viewModel::setKeepScreenOn,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
onOpenLicenses = onOpenLicenses,
onShareLogs = {
scope.launch {
val result =
runCatching {
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
if (zip != null) Log.share(context, zip)
zip
}
val message =
when {
result.isFailure -> R.string.settings_about_share_logs_failed
result.getOrNull() == null -> R.string.settings_about_share_logs_empty
else -> null
}
message?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
},
onBack = onBack,
dismissals = dismissals,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
sources: List<Source>,
activeSourceId: Long?,
wifiQuality: StreamQuality,
mobileQuality: StreamQuality,
syncConcurrency: Int,
downloadQuality: StreamQuality,
downloadOverMetered: Boolean,
scrobbling: Boolean,
modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
offline: Boolean = false,
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
replayGainPreamp: Float = 0f,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
onSyncModeChange: (SyncMode) -> Unit = {},
onSyncIntervalChange: (Int) -> Unit = {},
onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onKeepScreenOnChange: (Boolean) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onShareLogs: () -> Unit,
onBack: () -> Unit,
dismissals: Flow<Unit> = emptyFlow(),
) {
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
DismissOnRequest(dismissals) { dialog = null }
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
val uriHandler = LocalUriHandler.current
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.navigation_tabs_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
},
) { padding ->
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item { SectionHeader(stringResource(R.string.settings_servers_name)) }
items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index]
ListItem(
headlineContent = { Text(source.name) },
supportingContent = {
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
leadingContent = {
RadioButton(
selected = source.id == activeSourceId,
onClick = { onSelectSource(source.id) },
)
},
trailingContent = {
IconButton(onClick = { onEditServer(source.id) }) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit_source_description, source.name))
}
},
modifier =
Modifier.clickable(
onClickLabel = stringResource(R.string.use_source_description, source.name),
) { onSelectSource(source.id) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = onAddServer) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_servers_actions_add))
}
}
}
item { SectionHeader(stringResource(R.string.settings_network_name)) }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_network_options_offline_mode)) },
supportingContent = {
Text(
if (offline) {
stringResource(R.string.settings_network_options_offline_mode_on)
} else {
stringResource(R.string.settings_network_options_offline_mode_off)
},
)
},
trailingContent = {
Switch(checked = offline, onCheckedChange = onOfflineChange)
},
modifier = Modifier.clickable { onOfflineChange(!offline) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_wifi_title)) },
supportingContent = { Text(qualityLabel(wifiQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_mobile_title)) },
supportingContent = { Text(qualityLabel(mobileQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_interval)) },
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_music_name)) }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_music_options_scrobble_title)) },
supportingContent = { Text(stringResource(R.string.settings_music_options_scrobble_description_on)) },
trailingContent = {
Switch(
checked = scrobbling,
onCheckedChange = onScrobblingChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
},
modifier = Modifier.clickable { onScrobblingChange(!scrobbling) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
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 {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_title)) },
supportingContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_description)) },
trailingContent = {
Switch(
checked = keepScreenOn,
onCheckedChange = onKeepScreenOnChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
},
modifier = Modifier.clickable { onKeepScreenOnChange(!keepScreenOn) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.download_quality)) },
supportingContent = { Text(qualityLabel(downloadQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.DownloadQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.download_over_mobile)) },
supportingContent = {
Text(
if (downloadOverMetered) {
stringResource(R.string.download_network_wifi_and_mobile)
} else {
stringResource(R.string.download_network_wifi_only)
},
)
},
trailingContent = {
Switch(
checked = downloadOverMetered,
onCheckedChange = onDownloadOverMeteredChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
},
modifier = Modifier.clickable { onDownloadOverMeteredChange(!downloadOverMetered) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.manage_downloads)) },
leadingContent = { Icon(Icons.Rounded.Download, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenDownloads),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_troubleshooting_name)) }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_about_actions_share_logs)) },
leadingContent = { Icon(Icons.Rounded.Share, contentDescription = null) },
modifier = Modifier.clickable(onClick = onShareLogs),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_about_verbose_logging)) },
supportingContent = { Text(stringResource(R.string.settings_about_verbose_logging_description)) },
trailingContent = {
Switch(
checked = verboseLogging,
onCheckedChange = onVerboseLoggingChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
},
modifier = Modifier.clickable { onVerboseLoggingChange(!verboseLogging) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_about_name)) }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.app_name)) },
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
if (BuildConfig.COMMIT_HASH.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Text(
BuildConfig.COMMIT_HASH,
color = MaterialTheme.colorScheme.outline,
)
}
}
},
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_about_actions_licenses)) },
leadingContent = { Icon(Icons.Rounded.Description, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenLicenses),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_about_actions_project_homepage)) },
supportingContent = { Text(PROJECT_HOMEPAGE) },
leadingContent = { Icon(Icons.Rounded.Language, contentDescription = null) },
modifier = Modifier.clickable { runCatching { uriHandler.openUri(PROJECT_HOMEPAGE) } },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_about_actions_support)) },
supportingContent = { Text(SUPPORT_URL) },
leadingContent = {
Icon(
Icons.Rounded.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier.clickable { runCatching { uriHandler.openUri(SUPPORT_URL) } },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { Spacer(Modifier.height(fill)) }
}
}
when (dialog) {
SettingsDialog.WifiQuality -> {
QualityDialog(
title = stringResource(R.string.settings_network_options_max_bitrate_wifi_title),
quality = wifiQuality,
onSelect = onWifiQualityChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.MobileQuality -> {
QualityDialog(
title = stringResource(R.string.settings_network_options_max_bitrate_mobile_title),
quality = mobileQuality,
onSelect = onMobileQualityChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.DownloadQuality -> {
QualityDialog(
title = stringResource(R.string.download_quality),
quality = downloadQuality,
onSelect = onDownloadQualityChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.SyncConcurrency -> {
ConcurrencyDialog(
selected = syncConcurrency,
onSelect = onSyncConcurrencyChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.SyncInterval -> {
SyncIntervalDialog(
selected = syncInterval,
onSelect = onSyncIntervalChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.SyncMode -> {
SyncModeDialog(
selected = syncMode,
onSelect = onSyncModeChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.ReplayGain -> {
ReplayGainModeDialog(
selected = replayGainMode,
onSelect = onReplayGainModeChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.ReplayGainPreamp -> {
ReplayGainPreampDialog(
selected = replayGainPreamp,
onSelect = onReplayGainPreampChange,
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
}
}
@Composable
private fun qualityLabel(quality: StreamQuality): String {
val resources = LocalResources.current
val format = quality.format ?: stringResource(R.string.stream_quality_server_default)
return stringResource(R.string.download_status_summary, format, bitrateLabel(resources, quality.maxBitrate))
}
private fun bitrateLabel(
resources: Resources,
kbps: Int,
): String =
if (kbps == 0) {
resources.getString(R.string.settings_network_values_unlimited_kbps)
} else {
resources.getString(R.string.settings_network_values_kbps, kbps.toString())
}
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
private fun concurrencyLabel(
resources: Resources,
value: Int,
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
private fun syncIntervalLabel(
resources: Resources,
minutes: Int,
): String =
if (minutes >= 60 && minutes % 60 == 0) {
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
} else {
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
}
@Composable
private fun SyncIntervalDialog(
selected: Int,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableIntStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.sync_interval)) },
text = {
ChoiceGroup(
header = stringResource(R.string.sync_interval_prompt),
options = syncIntervalOptions.map { it to syncIntervalLabel(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 ConcurrencyDialog(
selected: Int,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableIntStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.sync_concurrency)) },
text = {
ChoiceGroup(
header = stringResource(R.string.sync_concurrency_parallel),
options = syncConcurrencyOptions.map { it to concurrencyLabel(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)) }
},
)
}
private val syncModeOptions = SyncMode.entries
private fun syncModeLabel(
resources: Resources,
mode: SyncMode,
): String =
when (mode) {
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
}
@Composable
private fun SyncModeDialog(
selected: SyncMode,
onSelect: (SyncMode) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
text = {
ChoiceGroup(
header = stringResource(R.string.sync_mode_prompt),
options = syncModeOptions.map { it to syncModeLabel(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 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)) }
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplayGainPreampDialog(
selected: Float,
onSelect: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(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,
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Remove,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
)
}
Slider(
value = draft,
onValueChange = { draft = snapReplayGainPreampDb(it) },
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
steps = REPLAY_GAIN_PREAMP_STEPS,
// Compose Material 3 draws one track tick per step, and at 59 steps they
// smear together, so the ticks are suppressed and the snapping kept.
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Add,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
)
}
}
Text(
replayGainPreampLabel(resources, draft),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = { draft = 0f },
enabled = draft != 0f,
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
}
},
)
}
@Composable
private fun QualityDialog(
title: String,
quality: StreamQuality,
onSelect: (StreamQuality) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(quality) { mutableStateOf(quality) }
val scrollState = rememberScrollState()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Box {
Column(modifier = Modifier.verticalScroll(scrollState)) {
ChoiceGroup(
header = stringResource(R.string.maximum_bitrate),
options = bitrateOptions.map { it to bitrateLabel(resources, it) },
selected = draft.maxBitrate,
onSelect = { draft = draft.copy(maxBitrate = it) },
)
Spacer(Modifier.height(16.dp))
ChoiceGroup(
header = stringResource(R.string.settings_network_options_stream_format),
options =
streamFormats.map {
it to
(it ?: resources.getString(R.string.settings_network_options_stream_format_server_default))
},
selected = draft.format,
onSelect = { draft = draft.copy(format = it) },
)
}
if (scrollState.canScrollForward) {
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(32.dp)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainerHigh),
),
),
)
}
}
},
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 <T> ChoiceGroup(
header: String,
options: List<Pair<T, String>>,
selected: T,
onSelect: (T) -> Unit,
) {
Column {
Text(header, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
options.forEach { (value, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onSelect(value) },
) {
RadioButton(selected = value == selected, onClick = { onSelect(value) })
Spacer(Modifier.width(8.dp))
Text(label)
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}