feat: settings page

This commit is contained in:
2026-07-27 00:16:46 -04:00
parent 1029586c6d
commit bc9cad78cb
9 changed files with 231 additions and 12 deletions
@@ -4,6 +4,7 @@ import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -15,6 +16,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ca.ksamad.musicremote.playback.VolumeKeyDispatcher
import ca.ksamad.musicremote.ui.MusicRemoteApp import ca.ksamad.musicremote.ui.MusicRemoteApp
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -22,6 +24,12 @@ class MainActivity : ComponentActivity() {
private val requestNotificationPermission = private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
// Intercept the hardware volume keys while we're focused so they drive the
// server volume without the system slider appearing (see VolumeKeyDispatcher).
private val volumeKeys by lazy {
VolumeKeyDispatcher((application as MusicRemoteApplication).manager)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@@ -42,6 +50,9 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
volumeKeys.dispatch(event) || super.dispatchKeyEvent(event)
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it // The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
// the foreground service still runs and cast volume still works, but the // the foreground service still runs and cast volume still works, but the
// now-playing notification / QS controls won't show. // now-playing notification / QS controls won't show.
@@ -46,4 +46,9 @@ class SettingsRepository(private val context: Context) {
prefs[Keys.PORT] = settings.port prefs[Keys.PORT] = settings.port
} }
} }
/** Forget all persisted settings, returning to first-run defaults. */
suspend fun clear() {
context.dataStore.edit { it.clear() }
}
} }
@@ -96,6 +96,12 @@ class MpdConnectionManager(context: Context) {
fire { disconnect() } fire { disconnect() }
} }
/** Forget saved settings and drop the connection (back to first-run state). */
fun resetSettings() {
scope.launch { settingsRepo.clear() }
disconnect()
}
fun resume() = fire { pause(false) } fun resume() = fire { pause(false) }
fun pause() = fire { pause(true) } fun pause() = fire { pause(true) }
fun stop() = fire { stop() } fun stop() = fire { stop() }
@@ -107,6 +113,14 @@ class MpdConnectionManager(context: Context) {
fun setRepeat(on: Boolean) = fire { setRepeat(on) } fun setRepeat(on: Boolean) = fire { setRepeat(on) }
fun setRandom(on: Boolean) = fire { setRandom(on) } fun setRandom(on: Boolean) = fire { setRandom(on) }
/**
* True when we own the volume: connected to a server that exposes a mixer.
* Used to decide whether hardware volume keys drive the *server* (silently,
* in-app) or fall through to the device's own local volume.
*/
val isControllingVolume: Boolean
get() = connectionState.value is MpdConnectionState.Connected && status.value?.volume != null
/** Relative volume change (from a hardware key / VolumeProvider), accumulating. */ /** Relative volume change (from a hardware key / VolumeProvider), accumulating. */
fun nudgeVolume(up: Boolean) { fun nudgeVolume(up: Boolean) {
val base = pendingVolume ?: status.value?.volume ?: return val base = pendingVolume ?: status.value?.volume ?: return
@@ -0,0 +1,41 @@
package ca.ksamad.musicremote.playback
import android.view.KeyEvent
/**
* Encapsulates hardware volume-key handling for the foreground Activity.
*
* While the app is focused, we want the volume keys to drive the *server*
* volume **silently** — without the system's volume slider popping up (the app
* already shows its own). The trick is to fully consume the key event in the
* Activity so it never reaches the OS volume handling that draws that slider.
*
* When the app is backgrounded the Activity isn't in the dispatch path at all,
* so the `MediaSession`'s `VolumeProvider` handles the keys instead — there the
* OS remote-volume UI showing up is the expected, cast-style behaviour.
*
* Only volume keys we actually act on are consumed: when we're not controlling
* the server (e.g. the connect screen), the event passes through so the device
* adjusts its own local volume normally.
*/
class VolumeKeyDispatcher(private val manager: MpdConnectionManager) {
/**
* Offer [event] to the volume handler. Returns `true` if it was a volume key
* we handled and consumed (caller should then *not* pass it on), `false` to
* let normal dispatch continue.
*/
fun dispatch(event: KeyEvent): Boolean {
val up = when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> true
KeyEvent.KEYCODE_VOLUME_DOWN -> false
else -> return false
}
if (!manager.isControllingVolume) return false
// Nudge on each key-down (auto-repeats included, so holding keeps
// changing); swallow the key-up too so no system UI flashes.
if (event.action == KeyEvent.ACTION_DOWN) manager.nudgeVolume(up)
return true
}
}
@@ -13,6 +13,7 @@ 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
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -33,11 +34,23 @@ import ca.ksamad.musicremote.data.ConnectionSettings
@Composable @Composable
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
val screen by vm.screen.collectAsStateWithLifecycle() val screen by vm.screen.collectAsStateWithLifecycle()
var showSettings by rememberSaveable { mutableStateOf(false) }
// Settings is only meaningful over the player; leaving it (e.g. after a
// reset/disconnect) drops us back to the normal screen flow.
LaunchedEffect(screen) {
if (screen !is AppScreen.Player) showSettings = false
}
when (val s = screen) { when (val s = screen) {
is AppScreen.Loading -> LoadingScreen() is AppScreen.Loading -> LoadingScreen()
is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error) is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error)
is AppScreen.Player -> NowPlayingScreen(vm) is AppScreen.Player ->
if (showSettings) {
SettingsScreen(vm, onBack = { showSettings = false })
} else {
NowPlayingScreen(vm, onOpenSettings = { showSettings = true })
}
} }
} }
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Settings
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.material.icons.filled.VolumeUp
@@ -50,7 +51,7 @@ import kotlinx.coroutines.delay
* including changes made from other clients. * including changes made from other clients.
*/ */
@Composable @Composable
fun NowPlayingScreen(vm: PlayerViewModel) { fun NowPlayingScreen(vm: PlayerViewModel, onOpenSettings: () -> Unit) {
val status by vm.status.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle()
val song by vm.currentSong.collectAsStateWithLifecycle() val song by vm.currentSong.collectAsStateWithLifecycle()
val serverHost by vm.serverHost.collectAsStateWithLifecycle() val serverHost by vm.serverHost.collectAsStateWithLifecycle()
@@ -61,7 +62,12 @@ fun NowPlayingScreen(vm: PlayerViewModel) {
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(Modifier.height(24.dp)) // --- Top bar: settings ----------------------------------------------
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}
// --- Track metadata -------------------------------------------------- // --- Track metadata --------------------------------------------------
Text( Text(
@@ -37,6 +37,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val status = manager.status val status = manager.status
val currentSong = manager.currentSong val currentSong = manager.currentSong
val serverHost = manager.serverHost val serverHost = manager.serverHost
val settings = manager.settings
val screen: StateFlow<AppScreen> = combine( val screen: StateFlow<AppScreen> = combine(
manager.connectionState, manager.connectionState,
@@ -58,6 +59,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun connect(host: String, port: Int) = manager.connect(host, port) fun connect(host: String, port: Int) = manager.connect(host, port)
fun disconnect() = manager.disconnect() fun disconnect() = manager.disconnect()
fun resetSettings() = manager.resetSettings()
fun togglePlayPause() = manager.togglePlayPause() fun togglePlayPause() = manager.togglePlayPause()
fun next() = manager.next() fun next() = manager.next()
fun previous() = manager.previous() fun previous() = manager.previous()
@@ -0,0 +1,119 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
* Settings: shows the current server and offers to switch servers or reset all
* saved settings. Reached from the player via the gear icon; [onBack] pops back.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
val settings by vm.settings.collectAsStateWithLifecycle()
var confirmReset by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 8.dp),
) {
Text(
"Server",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp, top = 12.dp, bottom = 4.dp),
)
ListItem(
headlineContent = { Text("Host") },
trailingContent = { Text(settings.host) },
)
ListItem(
headlineContent = { Text("Port") },
trailingContent = { Text(settings.port.toString()) },
)
Spacer(Modifier.height(24.dp))
OutlinedButton(
onClick = { onBack(); vm.disconnect() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text("Change server")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = { confirmReset = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text("Reset settings")
}
}
}
if (confirmReset) {
AlertDialog(
onDismissRequest = { confirmReset = false },
title = { Text("Reset settings?") },
text = { Text("This forgets the saved server and disconnects.") },
confirmButton = {
TextButton(onClick = {
confirmReset = false
onBack()
vm.resetSettings()
}) { Text("Reset") }
},
dismissButton = {
TextButton(onClick = { confirmReset = false }) { Text("Cancel") }
},
)
}
}
+17 -9
View File
@@ -22,17 +22,19 @@ plus `Repeat`/`Shuffle` leading icons on the chips) from
individual vector drawables (leaner but manual); the extended dep + R8 was the individual vector drawables (leaner but manual); the extended dep + R8 was the
best effort/quality trade. best effort/quality trade.
## [ ] 2. Settings menu ## [x] 2. Settings menu
A dedicated settings screen where the user can view every setting and reset it. Done. `SettingsScreen` (reached via a gear icon on the now-playing screen) shows
the current server (host/port) with **Change server** (disconnect → connect
form) and **Reset settings** (confirm dialog → clears DataStore + disconnects).
- Currently settings (host/port) are only editable by hitting **Disconnect** to - `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()`
get back to the connect form. Add a real settings route. clears + disconnects.
- Include a **Reset** action that clears DataStore (add a `clear()` to - Navigation is lightweight state (`showSettings` in `MusicRemoteApp`, only over
`SettingsRepository`). the player screen; a `LaunchedEffect` drops it when leaving the player) — no
- Will likely want simple navigation (Navigation-Compose, or a screen enum in Navigation-Compose dependency yet.
`PlayerViewModel`/`AppScreen`). - Room to grow: password field, connection timeout, theme, keep-screen-on — and
- Room to grow: password field, connection timeout, theme, keep-screen-on. if screens multiply, revisit adopting Navigation-Compose.
## [x] 3. Cast-style volume + OS media integration ## [x] 3. Cast-style volume + OS media integration
@@ -55,6 +57,12 @@ Done — full MALP-style **OS integration**, not just in-app. Architecture:
- Needs `POST_NOTIFICATIONS` (requested in `MainActivity`) + - Needs `POST_NOTIFICATIONS` (requested in `MainActivity`) +
`FOREGROUND_SERVICE[_MEDIA_PLAYBACK]`. `FOREGROUND_SERVICE[_MEDIA_PLAYBACK]`.
- In-app **`CastIndicator`** ("Controlling <host>") still shown above the slider. - In-app **`CastIndicator`** ("Controlling <host>") still shown above the slider.
- **Foreground key handling** (`VolumeKeyDispatcher`, delegated from
`MainActivity.dispatchKeyEvent`): while focused, volume keys are consumed and
drive the server silently — no system volume slider (the app's own slider is
the feedback). Backgrounded, the `VolumeProvider` takes over (system UI is fine
there). Gated by `MpdConnectionManager.isControllingVolume`; passes through to
local volume when not connected.
Notes: verified on the FiiO via injected key events (a rotary volume knob may not Notes: verified on the FiiO via injected key events (a rotary volume knob may not
emit `VOLUME_UP/DOWN` — hardware-dependent). Album art in the notification/session emit `VOLUME_UP/DOWN` — hardware-dependent). Album art in the notification/session