feat: settings page
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user