From 1029586c6d786889b605fbea1d06ce85e2cb93c9 Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Sun, 26 Jul 2026 20:48:07 -0400 Subject: [PATCH] feat: MVP of MediaSessionCompat Android 13+ integration --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 15 ++ .../ca/ksamad/musicremote/MainActivity.kt | 20 ++ .../musicremote/MusicRemoteApplication.kt | 20 ++ .../playback/MpdConnectionManager.kt | 125 +++++++++ .../musicremote/playback/PlaybackService.kt | 253 ++++++++++++++++++ .../ksamad/musicremote/ui/NowPlayingScreen.kt | 29 ++ .../ksamad/musicremote/ui/PlayerViewModel.kt | 94 ++----- app/src/main/res/drawable/ic_music_note.xml | 10 + deps.json | 15 +- docs/TODO.md | 36 ++- gradle/libs.versions.toml | 2 + 12 files changed, 538 insertions(+), 82 deletions(-) create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt create mode 100644 app/src/main/res/drawable/ic_music_note.xml 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]