feat: settings page
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AppScreen> = 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()
|
||||
|
||||
@@ -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
@@ -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 <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
|
||||
emit `VOLUME_UP/DOWN` — hardware-dependent). Album art in the notification/session
|
||||
|
||||
Reference in New Issue
Block a user