feat: MVP of MediaSessionCompat Android 13+ integration
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
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 <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`).
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user