diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt b/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt index 845ff1f..614aad4 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.view.KeyEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -15,6 +16,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat +import ca.ksamad.musicremote.playback.VolumeKeyDispatcher import ca.ksamad.musicremote.ui.MusicRemoteApp class MainActivity : ComponentActivity() { @@ -22,6 +24,12 @@ class MainActivity : ComponentActivity() { private val requestNotificationPermission = 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?) { super.onCreate(savedInstanceState) 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 foreground service still runs and cast volume still works, but the // now-playing notification / QS controls won't show. diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt b/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt index d301202..06a20e7 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt @@ -46,4 +46,9 @@ class SettingsRepository(private val context: Context) { prefs[Keys.PORT] = settings.port } } + + /** Forget all persisted settings, returning to first-run defaults. */ + suspend fun clear() { + context.dataStore.edit { it.clear() } + } } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt index 376c1af..a480d7b 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt @@ -96,6 +96,12 @@ class MpdConnectionManager(context: Context) { 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 pause() = fire { pause(true) } fun stop() = fire { stop() } @@ -107,6 +113,14 @@ class MpdConnectionManager(context: Context) { fun setRepeat(on: Boolean) = fire { setRepeat(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. */ fun nudgeVolume(up: Boolean) { val base = pendingVolume ?: status.value?.volume ?: return diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/VolumeKeyDispatcher.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/VolumeKeyDispatcher.kt new file mode 100644 index 0000000..f327e68 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/VolumeKeyDispatcher.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt index f3a1f5a..7240165 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -33,11 +34,23 @@ import ca.ksamad.musicremote.data.ConnectionSettings @Composable fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { 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) { is AppScreen.Loading -> LoadingScreen() 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 }) + } } } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt index 72e5dfc..ebc4af2 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Repeat 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.SkipPrevious import androidx.compose.material.icons.filled.VolumeUp @@ -50,7 +51,7 @@ import kotlinx.coroutines.delay * including changes made from other clients. */ @Composable -fun NowPlayingScreen(vm: PlayerViewModel) { +fun NowPlayingScreen(vm: PlayerViewModel, onOpenSettings: () -> Unit) { val status by vm.status.collectAsStateWithLifecycle() val song by vm.currentSong.collectAsStateWithLifecycle() val serverHost by vm.serverHost.collectAsStateWithLifecycle() @@ -61,7 +62,12 @@ fun NowPlayingScreen(vm: PlayerViewModel) { .padding(24.dp), 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 -------------------------------------------------- Text( diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt index 2503354..a879dd6 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt @@ -37,6 +37,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) val status = manager.status val currentSong = manager.currentSong val serverHost = manager.serverHost + val settings = manager.settings val screen: StateFlow = combine( manager.connectionState, @@ -58,6 +59,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun connect(host: String, port: Int) = manager.connect(host, port) fun disconnect() = manager.disconnect() + fun resetSettings() = manager.resetSettings() fun togglePlayPause() = manager.togglePlayPause() fun next() = manager.next() fun previous() = manager.previous() diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt new file mode 100644 index 0000000..2d1c3dd --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt @@ -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") } + }, + ) + } +} diff --git a/docs/TODO.md b/docs/TODO.md index f03b049..81aa564 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 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 - get back to the connect form. Add a real settings route. -- Include a **Reset** action that clears DataStore (add a `clear()` to - `SettingsRepository`). -- Will likely want simple navigation (Navigation-Compose, or a screen enum in - `PlayerViewModel`/`AppScreen`). -- Room to grow: password field, connection timeout, theme, keep-screen-on. +- `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()` + clears + disconnects. +- Navigation is lightweight state (`showSettings` in `MusicRemoteApp`, only over + the player screen; a `LaunchedEffect` drops it when leaving the player) — no + Navigation-Compose dependency yet. +- Room to grow: password field, connection timeout, theme, keep-screen-on — and + if screens multiply, revisit adopting Navigation-Compose. ## [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`) + `FOREGROUND_SERVICE[_MEDIA_PLAYBACK]`. - In-app **`CastIndicator`** ("Controlling ") 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 emit `VOLUME_UP/DOWN` — hardware-dependent). Album art in the notification/session