feat: minor polishing improvements.

This CL exposes a password setting for mpd servers that require it, and also fixes some swiping sensitivity issues.
This commit is contained in:
2026-07-30 23:15:53 -04:00
parent 665df77fc4
commit de13d3b36b
12 changed files with 86 additions and 9 deletions
+2
View File
@@ -12,6 +12,8 @@
<application <application
android:name=".EncoreApplication" android:name=".EncoreApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Encore"> android:theme="@style/Theme.Encore">
@@ -4,6 +4,8 @@ package ca.ksamad.encore.data
data class ConnectionSettings( data class ConnectionSettings(
val host: String, val host: String,
val port: Int, val port: Int,
/** Server password, or null when the server needs none. */
val password: String? = null,
) { ) {
companion object { companion object {
/** First-run defaults, used until the user connects at least once. */ /** First-run defaults, used until the user connects at least once. */
@@ -24,6 +24,7 @@ class SettingsRepository(private val context: Context) {
private object Keys { private object Keys {
val HOST = stringPreferencesKey("host") val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port") val PORT = intPreferencesKey("port")
val PASSWORD = stringPreferencesKey("password")
val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode") val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode")
} }
@@ -45,6 +46,7 @@ class SettingsRepository(private val context: Context) {
ConnectionSettings( ConnectionSettings(
host = host, host = host,
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port, port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
password = prefs[Keys.PASSWORD],
) )
} }
@@ -57,6 +59,10 @@ class SettingsRepository(private val context: Context) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[Keys.HOST] = settings.host prefs[Keys.HOST] = settings.host
prefs[Keys.PORT] = settings.port prefs[Keys.PORT] = settings.port
// Drop the key entirely for a password-less server so we never persist a
// stray empty string (and a later read comes back as null, not "").
val pw = settings.password
if (pw.isNullOrBlank()) prefs.remove(Keys.PASSWORD) else prefs[Keys.PASSWORD] = pw
} }
} }
@@ -81,7 +81,7 @@ class MpdConnectionManager(context: Context) {
scope.launch { scope.launch {
val saved = settingsRepo.settingsOrNull.first() val saved = settingsRepo.settingsOrNull.first()
if (saved != null) { if (saved != null) {
connect(saved.host, saved.port) connect(saved.host, saved.port, saved.password)
connectionState.first { it !is MpdConnectionState.Disconnected } connectionState.first { it !is MpdConnectionState.Disconnected }
} }
_bootstrapping.value = false _bootstrapping.value = false
@@ -109,12 +109,15 @@ class MpdConnectionManager(context: Context) {
fun connect( fun connect(
host: String, host: String,
port: Int, port: Int,
password: String? = null,
) { ) {
val trimmed = host.trim() val trimmed = host.trim()
// Treat a blank password as "no password" everywhere downstream.
val pw = password?.trim()?.ifBlank { null }
_serverHost.value = trimmed _serverHost.value = trimmed
scope.launch { scope.launch {
settingsRepo.save(ConnectionSettings(trimmed, port)) settingsRepo.save(ConnectionSettings(trimmed, port, pw))
runCatching { client.connect(trimmed, port) } runCatching { client.connect(trimmed, port, pw) }
} }
} }
@@ -38,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ca.ksamad.encore.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
@@ -182,6 +183,7 @@ private fun AlbumDetailHeader(
Text( Text(
album.name, album.name,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -190,6 +192,7 @@ private fun AlbumDetailHeader(
it, it,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -8,8 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -23,6 +28,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -158,6 +165,8 @@ private fun ConnectScreen(
// that stick (rememberSaveable also survives rotation/process death). // that stick (rememberSaveable also survives rotation/process death).
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) } var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) } var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
var password by rememberSaveable(saved.password) { mutableStateOf(saved.password ?: "") }
var showPassword by rememberSaveable { mutableStateOf(false) }
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
@@ -190,10 +199,32 @@ private fun ConnectScreen(
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password (optional)") },
singleLine = true,
visualTransformation =
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions =
androidx.compose.foundation.text.KeyboardOptions(
keyboardType = KeyboardType.Password
),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (showPassword) "Hide password" else "Show password",
)
}
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Button( Button(
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) }, onClick = { vm.connect(host, port.toIntOrNull() ?: 6600, password) },
enabled = host.isNotBlank(), enabled = host.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
@@ -26,7 +27,6 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -372,7 +372,7 @@ private fun VolumeControl(
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
Icons.Filled.VolumeUp, Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Volume", contentDescription = "Volume",
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
@@ -66,7 +66,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun connect( fun connect(
host: String, host: String,
port: Int, port: Int,
) = manager.connect(host, port) password: String? = null,
) = manager.connect(host, port, password)
fun disconnect() = manager.disconnect() fun disconnect() = manager.disconnect()
@@ -12,9 +12,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -256,7 +256,7 @@ private fun QueueRow(
}, },
trailingContent = trailingContent =
if (isCurrent) { if (isCurrent) {
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") } { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Now playing") }
} else { } else {
song.duration?.let { { Text(formatDuration(it)) } } song.duration?.let { { Text(formatDuration(it)) } }
}, },
@@ -22,6 +22,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** Fraction of a row's width a swipe must cross before the action fires (vs. the 56.dp default). */
private const val SWIPE_TRIGGER_FRACTION = 0.5f
/** /**
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue] * Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows * (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
@@ -39,6 +42,10 @@ fun QueueSwipeRow(
) { ) {
val state = val state =
rememberSwipeToDismissBoxState( rememberSwipeToDismissBoxState(
// Require the swipe to cross half the row's width before it counts, rather
// than the default fixed 56.dp — a small drag or flick was triggering the
// queue/play-next action too easily.
positionalThreshold = { totalDistance -> totalDistance * SWIPE_TRIGGER_FRACTION },
confirmValueChange = { target -> confirmValueChange = { target ->
when (target) { when (target) {
SwipeToDismissBoxValue.StartToEnd -> { SwipeToDismissBoxValue.StartToEnd -> {
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 11 and below (fullBackupContent). Exclude the connection-settings
DataStore so the saved server + password never leave the device in a backup.
-->
<full-backup-content>
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
</full-backup-content>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12+ (dataExtractionRules). Keep the connection-settings DataStore
(saved server + password) out of both cloud backups and device-to-device
transfers.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
</device-transfer>
</data-extraction-rules>