diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9decafc..d36326a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ diff --git a/app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt b/app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt index e16d026..e351efd 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt @@ -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. */ diff --git a/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt index b28a0f7..3f2856c 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt @@ -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 } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt index 15ece5c..40f13b1 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt @@ -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) } } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt index 8da2a57..21e8263 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt @@ -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, ) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt index 43969ee..1a51eb0 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt @@ -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(), ) { diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt index 9229272..260ad5c 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt @@ -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), ) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt index de69d8a..4b94961 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt @@ -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() diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt index 76246da..d1995f7 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt @@ -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)) } } }, diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt index 75b6a97..eb8449e 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt @@ -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 -> { diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fdf5f29 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..f389951 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,14 @@ + + + + + + + + + +