feat: MVP of MediaSessionCompat Android 13+ integration

This commit is contained in:
2026-07-27 00:16:46 -04:00
parent 2cf02ddcea
commit 1029586c6d
12 changed files with 538 additions and 82 deletions
+1
View File
@@ -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.
+15
View File
@@ -3,8 +3,14 @@
<!-- Needed to open a TCP socket to the MPD server over the network. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Foreground media service that hosts the MediaSession + now-playing notification. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Post the media notification on Android 13+. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MusicRemoteApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
@@ -17,6 +23,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".playback.PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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)
}
}
@@ -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
}
}
@@ -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<String?>(null)
val serverHost: StateFlow<String?> = _serverHost
/** Persisted settings (defaulted) for seeding the connect form. */
val settings: StateFlow<ConnectionSettings> = 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<Boolean> = _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
}
}
@@ -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))
}
}
}
@@ -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.
@@ -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<AppScreen> = 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)
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z" />
</vector>
+13 -2
View File
@@ -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="
+28 -8
View File
@@ -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 <host>" 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,
0100). 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 <host>") 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`).
+2
View File
@@ -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]