diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5f553e4..fad96b0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -107,6 +107,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.media)
// The Compose BOM aligns every Compose artifact to one tested version set,
// so the individual Compose deps below are declared without versions.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1b6b93d..064e6b1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,8 +3,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt b/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt
index 98bc3c3..845ff1f 100644
--- a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt
+++ b/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt
@@ -1,21 +1,31 @@
package ca.ksamad.musicremote
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
+import androidx.core.content.ContextCompat
import ca.ksamad.musicremote.ui.MusicRemoteApp
class MainActivity : ComponentActivity() {
+
+ private val requestNotificationPermission =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ maybeRequestNotificationPermission()
setContent {
MusicRemoteTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
@@ -31,4 +41,14 @@ class MainActivity : ComponentActivity() {
}
}
}
+
+ // 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.
+ private fun maybeRequestNotificationPermission() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+ val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
}
diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt b/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt
new file mode 100644
index 0000000..e6dc6bf
--- /dev/null
+++ b/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt
@@ -0,0 +1,20 @@
+package ca.ksamad.musicremote
+
+import android.app.Application
+import ca.ksamad.musicremote.playback.MpdConnectionManager
+
+/**
+ * Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any
+ * single Activity and can be shared with the foreground [ca.ksamad.musicremote.playback.PlaybackService].
+ */
+class MusicRemoteApplication : Application() {
+
+ val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) }
+
+ override fun onCreate() {
+ super.onCreate()
+ // Create eagerly so startup auto-connect (and the media service) kick in
+ // as soon as the process starts, not only when the UI first observes it.
+ manager
+ }
+}
diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt
new file mode 100644
index 0000000..376c1af
--- /dev/null
+++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt
@@ -0,0 +1,125 @@
+package ca.ksamad.musicremote.playback
+
+import android.content.Context
+import ca.ksamad.musicremote.data.ConnectionSettings
+import ca.ksamad.musicremote.data.SettingsRepository
+import ca.ksamad.musicremote.mpd.MpdClient
+import ca.ksamad.musicremote.mpd.MpdConnectionState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/**
+ * App-scoped owner of the single [MpdClient]. Living at the [Application] level
+ * (not in a ViewModel) means the connection survives Activity recreation and
+ * keeps running while [PlaybackService] holds the app in the foreground — which
+ * is what makes the OS media session / notification / cast volume work when the
+ * user has left the UI.
+ *
+ * Both the UI (via `PlayerViewModel`) and [PlaybackService] talk to this same
+ * instance: the UI observes the flows and issues commands; the service mirrors
+ * the flows into a `MediaSessionCompat` and routes media-button / volume-key
+ * callbacks back here.
+ */
+class MpdConnectionManager(context: Context) {
+
+ private val appContext = context.applicationContext
+ private val client = MpdClient()
+ private val settingsRepo = SettingsRepository(appContext)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ val connectionState = client.connectionState
+ val status = client.status
+ val currentSong = client.currentSong
+
+ private val _serverHost = MutableStateFlow(null)
+ val serverHost: StateFlow = _serverHost
+
+ /** Persisted settings (defaulted) for seeding the connect form. */
+ val settings: StateFlow = settingsRepo.settings
+ .stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
+
+ // True until the startup auto-connect decision has been made.
+ private val _bootstrapping = MutableStateFlow(true)
+ val bootstrapping: StateFlow = _bootstrapping
+
+ // Optimistic volume target so rapid nudges accumulate instead of each reading
+ // the same stale server value; cleared once the server confirms it.
+ @Volatile
+ private var pendingVolume: Int? = null
+
+ init {
+ // Auto-connect on startup if a server was saved.
+ scope.launch {
+ val saved = settingsRepo.settingsOrNull.first()
+ if (saved != null) {
+ connect(saved.host, saved.port)
+ connectionState.first { it !is MpdConnectionState.Disconnected }
+ }
+ _bootstrapping.value = false
+ }
+ scope.launch {
+ status.collect { s ->
+ if (s?.volume != null && s.volume == pendingVolume) pendingVolume = null
+ }
+ }
+ // Bring the foreground media service up while connected, down otherwise.
+ scope.launch {
+ connectionState.collect { st ->
+ when (st) {
+ is MpdConnectionState.Connected -> PlaybackService.start(appContext)
+ is MpdConnectionState.Disconnected,
+ is MpdConnectionState.Error -> PlaybackService.stop(appContext)
+ is MpdConnectionState.Connecting -> Unit
+ }
+ }
+ }
+ }
+
+ fun connect(host: String, port: Int) {
+ val trimmed = host.trim()
+ _serverHost.value = trimmed
+ scope.launch {
+ settingsRepo.save(ConnectionSettings(trimmed, port))
+ runCatching { client.connect(trimmed, port) }
+ }
+ }
+
+ fun disconnect() {
+ _serverHost.value = null
+ fire { disconnect() }
+ }
+
+ fun resume() = fire { pause(false) }
+ fun pause() = fire { pause(true) }
+ fun stop() = fire { stop() }
+ fun next() = fire { next() }
+ fun previous() = fire { previous() }
+ fun togglePlayPause() = fire { togglePause() }
+ fun seekTo(seconds: Double) = fire { seekCurrent(seconds) }
+ fun setVolume(volume: Int) = fire { setVolume(volume) }
+ fun setRepeat(on: Boolean) = fire { setRepeat(on) }
+ fun setRandom(on: Boolean) = fire { setRandom(on) }
+
+ /** Relative volume change (from a hardware key / VolumeProvider), accumulating. */
+ fun nudgeVolume(up: Boolean) {
+ val base = pendingVolume ?: status.value?.volume ?: return
+ val next = (base + if (up) VOLUME_STEP else -VOLUME_STEP).coerceIn(0, 100)
+ pendingVolume = next
+ setVolume(next)
+ }
+
+ private inline fun fire(crossinline action: suspend MpdClient.() -> Unit) {
+ scope.launch { runCatching { client.action() } }
+ }
+
+ private companion object {
+ const val VOLUME_STEP = 5
+ }
+}
diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt
new file mode 100644
index 0000000..3999b81
--- /dev/null
+++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt
@@ -0,0 +1,253 @@
+package ca.ksamad.musicremote.playback
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.media.VolumeProviderCompat
+import androidx.media.app.NotificationCompat.MediaStyle
+import androidx.media.session.MediaButtonReceiver
+import ca.ksamad.musicremote.MainActivity
+import ca.ksamad.musicremote.MusicRemoteApplication
+import ca.ksamad.musicremote.R
+import ca.ksamad.musicremote.mpd.MpdConnectionState
+import ca.ksamad.musicremote.mpd.model.MpdSong
+import ca.ksamad.musicremote.mpd.model.MpdStatus
+import ca.ksamad.musicremote.mpd.model.PlayerState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+/**
+ * Foreground service that mirrors the shared [MpdConnectionManager] into an OS
+ * [MediaSessionCompat]: it publishes now-playing metadata + playback state
+ * (driving the media notification, lock screen, and Quick Settings player),
+ * exposes transport controls, and — via [setPlaybackToRemote] with a
+ * [VolumeProviderCompat] — makes the hardware volume keys control the *server's*
+ * volume system-wide, cast-style, even when the app is in the background.
+ *
+ * Started/stopped by [MpdConnectionManager] as the connection comes and goes.
+ */
+class PlaybackService : Service() {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val manager get() = (application as MusicRemoteApplication).manager
+
+ private lateinit var session: MediaSessionCompat
+ private lateinit var volumeProvider: VolumeProviderCompat
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+
+ session = MediaSessionCompat(this, "MusicRemote").apply {
+ setCallback(mediaCallback)
+ isActive = true
+ }
+
+ // Remote (cast-style) volume: absolute 0..100, initialised from the server.
+ volumeProvider = object : VolumeProviderCompat(
+ VOLUME_CONTROL_ABSOLUTE,
+ MAX_VOLUME,
+ manager.status.value?.volume ?: 0,
+ ) {
+ override fun onSetVolumeTo(volume: Int) {
+ manager.setVolume(volume)
+ currentVolume = volume
+ }
+
+ override fun onAdjustVolume(direction: Int) {
+ if (direction != 0) manager.nudgeVolume(up = direction > 0)
+ }
+ }
+ session.setPlaybackToRemote(volumeProvider)
+
+ createChannel()
+ observeState()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // Deliver hardware / notification media-button presses to the session.
+ MediaButtonReceiver.handleIntent(session, intent)
+ startForeground(NOTIFICATION_ID, buildNotification())
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ session.isActive = false
+ session.release()
+ super.onDestroy()
+ }
+
+ private val mediaCallback = object : MediaSessionCompat.Callback() {
+ override fun onPlay() = manager.resume()
+ override fun onPause() = manager.pause()
+ override fun onStop() = manager.stop()
+ override fun onSkipToNext() = manager.next()
+ override fun onSkipToPrevious() = manager.previous()
+ override fun onSeekTo(pos: Long) = manager.seekTo(pos / 1000.0)
+ }
+
+ private fun observeState() {
+ scope.launch {
+ combine(manager.status, manager.currentSong) { s, song -> s to song }
+ .collect { (status, song) ->
+ session.setMetadata(buildMetadata(song, status))
+ session.setPlaybackState(buildPlaybackState(status))
+ volumeProvider.currentVolume = status?.volume ?: 0
+ postNotification()
+ }
+ }
+ scope.launch {
+ manager.connectionState.collect { st ->
+ if (st is MpdConnectionState.Disconnected || st is MpdConnectionState.Error) {
+ stopForegroundCompat()
+ stopSelf()
+ }
+ }
+ }
+ }
+
+ private fun buildMetadata(song: MpdSong?, status: MpdStatus?): MediaMetadataCompat =
+ MediaMetadataCompat.Builder().apply {
+ putString(MediaMetadataCompat.METADATA_KEY_TITLE, song?.title ?: song?.uri ?: "")
+ putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song?.artist ?: "")
+ putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song?.album ?: "")
+ val durationMs = ((song?.duration ?: status?.duration ?: 0.0) * 1000).toLong()
+ putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs)
+ }.build()
+
+ private fun buildPlaybackState(status: MpdStatus?): PlaybackStateCompat {
+ val state = when (status?.state) {
+ PlayerState.PLAY -> PlaybackStateCompat.STATE_PLAYING
+ PlayerState.PAUSE -> PlaybackStateCompat.STATE_PAUSED
+ else -> PlaybackStateCompat.STATE_STOPPED
+ }
+ val positionMs = ((status?.elapsed ?: 0.0) * 1000).toLong()
+ val speed = if (status?.state == PlayerState.PLAY) 1f else 0f
+ return PlaybackStateCompat.Builder()
+ .setActions(
+ PlaybackStateCompat.ACTION_PLAY or
+ PlaybackStateCompat.ACTION_PAUSE or
+ PlaybackStateCompat.ACTION_PLAY_PAUSE or
+ PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
+ PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
+ PlaybackStateCompat.ACTION_SEEK_TO or
+ PlaybackStateCompat.ACTION_STOP,
+ )
+ .setState(state, positionMs, speed)
+ .build()
+ }
+
+ private fun buildNotification(): android.app.Notification {
+ val song = manager.currentSong.value
+ val playing = manager.status.value?.state == PlayerState.PLAY
+
+ val contentIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ val playPause = if (playing) {
+ NotificationCompat.Action(
+ android.R.drawable.ic_media_pause,
+ "Pause",
+ MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE),
+ )
+ } else {
+ NotificationCompat.Action(
+ android.R.drawable.ic_media_play,
+ "Play",
+ MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE),
+ )
+ }
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_music_note)
+ .setContentTitle(song?.title ?: song?.uri ?: "Music Remote")
+ .setContentText(song?.artist ?: "")
+ .setSubText(song?.album)
+ .setContentIntent(contentIntent)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setOngoing(playing)
+ .addAction(
+ android.R.drawable.ic_media_previous,
+ "Previous",
+ MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS),
+ )
+ .addAction(playPause)
+ .addAction(
+ android.R.drawable.ic_media_next,
+ "Next",
+ MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT),
+ )
+ .setStyle(
+ MediaStyle()
+ .setMediaSession(session.sessionToken)
+ .setShowActionsInCompactView(0, 1, 2),
+ )
+ .build()
+ }
+
+ private fun postNotification() {
+ // No-ops silently if POST_NOTIFICATIONS isn't granted; the service still runs.
+ NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, buildNotification())
+ }
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Playback",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Now-playing controls"
+ setShowBadge(false)
+ }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun stopForegroundCompat() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ stopForeground(true)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "playback"
+ private const val NOTIFICATION_ID = 1
+ private const val MAX_VOLUME = 100
+
+ fun start(context: Context) {
+ ContextCompat.startForegroundService(
+ context,
+ Intent(context, PlaybackService::class.java),
+ )
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, PlaybackService::class.java))
+ }
+ }
+}
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 14d536f..72e5dfc 100644
--- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt
+++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Repeat
@@ -52,6 +53,7 @@ import kotlinx.coroutines.delay
fun NowPlayingScreen(vm: PlayerViewModel) {
val status by vm.status.collectAsStateWithLifecycle()
val song by vm.currentSong.collectAsStateWithLifecycle()
+ val serverHost by vm.serverHost.collectAsStateWithLifecycle()
Column(
modifier = Modifier
@@ -122,6 +124,7 @@ fun NowPlayingScreen(vm: PlayerViewModel) {
Spacer(Modifier.height(24.dp))
+ CastIndicator(host = serverHost)
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
Spacer(Modifier.height(16.dp))
@@ -206,6 +209,32 @@ private fun SeekBar(status: MpdStatus?, onSeek: (Double) -> Unit) {
}
}
+/** "Casting" affordance: signals that the volume below controls the server, not the device. */
+@Composable
+private fun CastIndicator(host: String?) {
+ if (host == null) return
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ Icons.Filled.Cast,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = "Controlling $host",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 6.dp),
+ )
+ }
+}
+
@Composable
private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
// Local thumb position seeded from the server; committed on release.
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 ac38ec2..2503354 100644
--- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt
+++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt
@@ -3,17 +3,13 @@ package ca.ksamad.musicremote.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import ca.ksamad.musicremote.MusicRemoteApplication
import ca.ksamad.musicremote.data.ConnectionSettings
-import ca.ksamad.musicremote.data.SettingsRepository
-import ca.ksamad.musicremote.mpd.MpdClient
import ca.ksamad.musicremote.mpd.MpdConnectionState
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
/** Which top-level screen to show. Derived from connection + persisted settings. */
sealed interface AppScreen {
@@ -28,36 +24,24 @@ sealed interface AppScreen {
}
/**
- * Holds the single [MpdClient] for the app and exposes its state to Compose.
- *
- * On startup it reads the persisted [ConnectionSettings]; if a server was saved
- * it auto-connects straight into playback, so a returning user is never
- * prompted. The connect form only appears with no saved server, after an
- * explicit disconnect, or when a connection fails.
+ * Thin UI-facing layer over the app-scoped
+ * [ca.ksamad.musicremote.playback.MpdConnectionManager]: it exposes the manager's
+ * flows to Compose and derives the top-level [AppScreen]. The connection itself
+ * lives in the manager (shared with the foreground service), so it survives this
+ * ViewModel being cleared on Activity recreation.
*/
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
- private val client = MpdClient()
- private val settingsRepo = SettingsRepository(application)
+ private val manager = (application as MusicRemoteApplication).manager
- val status = client.status
- val currentSong = client.currentSong
-
- /** Persisted settings (defaulted) for seeding the connect form. */
- private val settings = settingsRepo.settings.stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = ConnectionSettings.DEFAULT,
- )
-
- // True until the startup auto-connect decision has been made, so we show a
- // splash instead of briefly flashing the connect form on cold start.
- private val bootstrapping = MutableStateFlow(true)
+ val status = manager.status
+ val currentSong = manager.currentSong
+ val serverHost = manager.serverHost
val screen: StateFlow = combine(
- client.connectionState,
- bootstrapping,
- settings,
+ manager.connectionState,
+ manager.bootstrapping,
+ manager.settings,
) { connection, booting, saved ->
when {
booting -> AppScreen.Loading
@@ -72,47 +56,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
initialValue = AppScreen.Loading,
)
- init {
- viewModelScope.launch {
- val saved = settingsRepo.settingsOrNull.first()
- if (saved != null) {
- connect(saved.host, saved.port)
- // Wait until the client actually leaves Disconnected before we
- // stop bootstrapping, so the form never flashes over the splash.
- client.connectionState.first { it !is MpdConnectionState.Disconnected }
- }
- bootstrapping.value = false
- }
- }
-
- fun connect(host: String, port: Int) {
- val trimmed = host.trim()
- viewModelScope.launch {
- // Remember what we last connected to, so it's there next launch.
- settingsRepo.save(ConnectionSettings(trimmed, port))
- // connect() already routes failures into connectionState; swallow the
- // rethrow so a bad host doesn't crash the app.
- runCatching { client.connect(trimmed, port) }
- }
- }
-
- fun disconnect() {
- viewModelScope.launch { client.disconnect() }
- }
-
- fun togglePlayPause() = fireAndForget { togglePause() }
- fun next() = fireAndForget { next() }
- fun previous() = fireAndForget { previous() }
- fun seekTo(seconds: Double) = fireAndForget { seekCurrent(seconds) }
- fun setVolume(volume: Int) = fireAndForget { setVolume(volume) }
- fun setRepeat(on: Boolean) = fireAndForget { setRepeat(on) }
- fun setRandom(on: Boolean) = fireAndForget { setRandom(on) }
-
- private inline fun fireAndForget(crossinline action: suspend MpdClient.() -> Unit) {
- viewModelScope.launch { runCatching { client.action() } }
- }
-
- override fun onCleared() {
- client.shutdown()
- }
+ fun connect(host: String, port: Int) = manager.connect(host, port)
+ fun disconnect() = manager.disconnect()
+ fun togglePlayPause() = manager.togglePlayPause()
+ fun next() = manager.next()
+ fun previous() = manager.previous()
+ fun seekTo(seconds: Double) = manager.seekTo(seconds)
+ fun setVolume(volume: Int) = manager.setVolume(volume)
+ fun setRepeat(on: Boolean) = manager.setRepeat(on)
+ fun setRandom(on: Boolean) = manager.setRandom(on)
}
diff --git a/app/src/main/res/drawable/ic_music_note.xml b/app/src/main/res/drawable/ic_music_note.xml
new file mode 100644
index 0000000..f591c07
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music_note.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/deps.json b/deps.json
index a0979ed..4c83503 100644
--- a/deps.json
+++ b/deps.json
@@ -39,6 +39,10 @@
"androidx/annotation#annotation/1.1.0": {
"pom": "sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4="
},
+ "androidx/annotation#annotation/1.2.0": {
+ "module": "sha256-Lvyrge+RshG6zSBuqs2ZWlH2M6Lpa1eo/AAUTF+cVrM=",
+ "pom": "sha256-Yvttyid37+COcHfWuHLWkRBhnff8Icmab1QGZJnMA4M="
+ },
"androidx/annotation#annotation/1.6.0": {
"module": "sha256-YUa2E4ZDsqwFkN9QndUauup2nHn9dgLrIXFo/lr3jNI=",
"pom": "sha256-3YgrTBlFrRMSHs32UeumZKmyJrVduE+nVTkt5eENHls="
@@ -82,8 +86,8 @@
"module": "sha256-2lTeYVYYuTzZ7tpcmTWVW314bpnUNsM7A3l8SD6fTJk=",
"pom": "sha256-ViZmWwY8AzrLuVAxL3Q0pCE+koUwh2fGxR6UAHqVKU4="
},
- "androidx/collection#collection/1.0.0": {
- "pom": "sha256-p5E6UnWtaOVV0mEuvowUw2exU+FMpIoYcqZImQIOVO8="
+ "androidx/collection#collection/1.1.0": {
+ "pom": "sha256-Z+kGbKSs/cbjzFCCk8MboDmAV/8Rjk9wseGBPJo0VtE="
},
"androidx/collection#collection/1.4.0": {
"module": "sha256-L9O1I+gnbAJUxBe2bY5/7MWwuXWvXReK+n9bgTgSz6s=",
@@ -545,6 +549,13 @@
"androidx/lifecycle/lifecycle-viewmodel-savedstate/2.8.7/lifecycle-viewmodel-savedstate-2.8.7": {
"aar": "sha256-ZG8qQtKISwIwj7GYNwieJmmDX+IcpqW2BmbV65TQgd0="
},
+ "androidx/media#media/1.7.0": {
+ "module": "sha256-mW9kYoSi2YGJnNrDKCJJ4OHyCrgoOKCHXcZr0mTSSWE=",
+ "pom": "sha256-aWZWt+Pr3u8hbtsti7qg85UkiGTC/DSxmfvCTzJmuOE="
+ },
+ "androidx/media/media/1.7.0/media-1.7.0": {
+ "aar": "sha256-gaGZ7ofG09Wfs199vscbPx1QcShhEJm2rDpLHFoL8fk="
+ },
"androidx/profileinstaller#profileinstaller/1.3.1": {
"module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=",
"pom": "sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g="
diff --git a/docs/TODO.md b/docs/TODO.md
index 4d36144..f03b049 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -34,16 +34,31 @@ A dedicated settings screen where the user can view every setting and reset it.
`PlayerViewModel`/`AppScreen`).
- Room to grow: password field, connection timeout, theme, keep-screen-on.
-## [ ] 3. Cast-style volume control
+## [x] 3. Cast-style volume + OS media integration
-When the MPD server is playing, present the volume control as a **"casting"
-style** remote-volume control — the way MALP (and Google Cast) do — making it
-visually clear you're controlling the *server's* output, not the phone's.
+Done — full MALP-style **OS integration**, not just in-app. Architecture:
-- Functionally we already send `setvol` to the server (`MpdClient.setVolume`);
- this is mostly a UX/affordance change: a cast icon, "Casting to " label,
- distinct styling for remote vs local volume.
-- Reference behaviour: MALP.
+- **`MpdConnectionManager`** (app-scoped, held by `MusicRemoteApplication`) now
+ owns the single `MpdClient`, so the connection survives Activity recreation and
+ runs while the service is up. `PlayerViewModel` is a thin delegate over it.
+- **`PlaybackService`** — a `mediaPlayback` foreground service hosting a
+ `MediaSessionCompat`:
+ - **Cast-style volume**: `setPlaybackToRemote(VolumeProviderCompat)` (absolute,
+ 0–100). The OS routes hardware volume keys to the *server* volume
+ **system-wide, even when the app is backgrounded** (verified: 50→40 via
+ injected keys from the launcher), and shows the remote-volume UI. Replaced
+ the earlier in-app `onKeyDown` hack. Rapid presses accumulate via
+ `pendingVolume`.
+ - **Now-playing notification / QS / lock-screen** via a `MediaStyle`
+ notification bound to the session, with prev/play-pause/next actions
+ (`MediaButtonReceiver`) and metadata/position mirrored from the flows.
+ - Needs `POST_NOTIFICATIONS` (requested in `MainActivity`) +
+ `FOREGROUND_SERVICE[_MEDIA_PLAYBACK]`.
+- In-app **`CastIndicator`** ("Controlling ") still shown above the slider.
+
+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
+is pending item #5.
## [ ] 4. Library browse — albums
@@ -85,3 +100,8 @@ and falls back to the connect screen. MALP does not do this.
**auto-reconnect transparently** (re-open connections, re-issue `idle`, resync
state) and keep showing the player. Consider a keepalive ping and, for
backgrounded playback control, a foreground service / partial wakelock.
+- PARTIALLY ADDRESSED by item #3: the `mediaPlayback` foreground service now
+ keeps the process/connection alive in the background, which should stop Doze /
+ wifi power-save from tearing the sockets down (the most likely cause). Still
+ worth verifying over a long idle, and adding transparent auto-reconnect +
+ keepalive ping as defense-in-depth (e.g. against MPD `connection_timeout`).
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 48a1285..585fb6e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,6 +10,7 @@ activityCompose = "1.9.3"
composeBom = "2024.10.01" # Compose Bill-of-Materials: pins all Compose lib versions together
coroutines = "1.9.0" # kotlinx-coroutines (async I/O + Flow for the MPD client)
datastore = "1.1.1" # Jetpack DataStore (persisting connection settings)
+media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume
junit = "4.13.2"
[libraries]
@@ -27,6 +28,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]