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
android:name=".EncoreApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Encore">
@@ -4,6 +4,8 @@ package ca.ksamad.encore.data
data class ConnectionSettings(
val host: String,
val port: Int,
/** Server password, or null when the server needs none. */
val password: String? = null,
) {
companion object {
/** First-run defaults, used until the user connects at least once. */
@@ -24,6 +24,7 @@ class SettingsRepository(private val context: Context) {
private object Keys {
val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port")
val PASSWORD = stringPreferencesKey("password")
val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode")
}
@@ -45,6 +46,7 @@ class SettingsRepository(private val context: Context) {
ConnectionSettings(
host = host,
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 ->
prefs[Keys.HOST] = settings.host
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 {
val saved = settingsRepo.settingsOrNull.first()
if (saved != null) {
connect(saved.host, saved.port)
connect(saved.host, saved.port, saved.password)
connectionState.first { it !is MpdConnectionState.Disconnected }
}
_bootstrapping.value = false
@@ -109,12 +109,15 @@ class MpdConnectionManager(context: Context) {
fun connect(
host: String,
port: Int,
password: String? = null,
) {
val trimmed = host.trim()
// Treat a blank password as "no password" everywhere downstream.
val pw = password?.trim()?.ifBlank { null }
_serverHost.value = trimmed
scope.launch {
settingsRepo.save(ConnectionSettings(trimmed, port))
runCatching { client.connect(trimmed, port) }
settingsRepo.save(ConnectionSettings(trimmed, port, pw))
runCatching { client.connect(trimmed, port, pw) }
}
}
@@ -38,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ca.ksamad.encore.mpd.model.MpdAlbum
@@ -182,6 +183,7 @@ private fun AlbumDetailHeader(
Text(
album.name,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
@@ -190,6 +192,7 @@ private fun AlbumDetailHeader(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -8,8 +8,13 @@ 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.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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -23,6 +28,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -158,6 +165,8 @@ private fun ConnectScreen(
// that stick (rememberSaveable also survives rotation/process death).
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
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(
modifier = Modifier.fillMaxSize().padding(24.dp),
@@ -190,10 +199,32 @@ private fun ConnectScreen(
),
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))
Button(
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) },
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600, password) },
enabled = host.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.LibraryMusic
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.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@@ -372,7 +372,7 @@ private fun VolumeControl(
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.VolumeUp,
Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Volume",
modifier = Modifier.size(24.dp),
)
@@ -66,7 +66,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun connect(
host: String,
port: Int,
) = manager.connect(host, port)
password: String? = null,
) = manager.connect(host, port, password)
fun disconnect() = manager.disconnect()
@@ -12,9 +12,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.DeleteSweep
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -256,7 +256,7 @@ private fun QueueRow(
},
trailingContent =
if (isCurrent) {
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") }
{ Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Now playing") }
} else {
song.duration?.let { { Text(formatDuration(it)) } }
},
@@ -22,6 +22,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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]
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
@@ -39,6 +42,10 @@ fun QueueSwipeRow(
) {
val state =
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 ->
when (target) {
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>