feat: MVP of MediaSessionCompat Android 13+ integration
This commit is contained in:
@@ -107,6 +107,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.media)
|
||||||
|
|
||||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||||
// so the individual Compose deps below are declared without versions.
|
// 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. -->
|
<!-- Needed to open a TCP socket to the MPD server over the network. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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
|
<application
|
||||||
|
android:name=".MusicRemoteApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
@@ -17,6 +23,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
package ca.ksamad.musicremote
|
package ca.ksamad.musicremote
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import ca.ksamad.musicremote.ui.MusicRemoteApp
|
import ca.ksamad.musicremote.ui.MusicRemoteApp
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val requestNotificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
maybeRequestNotificationPermission()
|
||||||
setContent {
|
setContent {
|
||||||
MusicRemoteTheme {
|
MusicRemoteTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
@@ -52,6 +53,7 @@ import kotlinx.coroutines.delay
|
|||||||
fun NowPlayingScreen(vm: PlayerViewModel) {
|
fun NowPlayingScreen(vm: PlayerViewModel) {
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
val song by vm.currentSong.collectAsStateWithLifecycle()
|
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||||
|
val serverHost by vm.serverHost.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -122,6 +124,7 @@ fun NowPlayingScreen(vm: PlayerViewModel) {
|
|||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
CastIndicator(host = serverHost)
|
||||||
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
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
|
@Composable
|
||||||
private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
|
private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
|
||||||
// Local thumb position seeded from the server; committed on release.
|
// Local thumb position seeded from the server; committed on release.
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ package ca.ksamad.musicremote.ui
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import ca.ksamad.musicremote.MusicRemoteApplication
|
||||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
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 ca.ksamad.musicremote.mpd.MpdConnectionState
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/** Which top-level screen to show. Derived from connection + persisted settings. */
|
/** Which top-level screen to show. Derived from connection + persisted settings. */
|
||||||
sealed interface AppScreen {
|
sealed interface AppScreen {
|
||||||
@@ -28,36 +24,24 @@ sealed interface AppScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the single [MpdClient] for the app and exposes its state to Compose.
|
* Thin UI-facing layer over the app-scoped
|
||||||
*
|
* [ca.ksamad.musicremote.playback.MpdConnectionManager]: it exposes the manager's
|
||||||
* On startup it reads the persisted [ConnectionSettings]; if a server was saved
|
* flows to Compose and derives the top-level [AppScreen]. The connection itself
|
||||||
* it auto-connects straight into playback, so a returning user is never
|
* lives in the manager (shared with the foreground service), so it survives this
|
||||||
* prompted. The connect form only appears with no saved server, after an
|
* ViewModel being cleared on Activity recreation.
|
||||||
* explicit disconnect, or when a connection fails.
|
|
||||||
*/
|
*/
|
||||||
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
|
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val client = MpdClient()
|
private val manager = (application as MusicRemoteApplication).manager
|
||||||
private val settingsRepo = SettingsRepository(application)
|
|
||||||
|
|
||||||
val status = client.status
|
val status = manager.status
|
||||||
val currentSong = client.currentSong
|
val currentSong = manager.currentSong
|
||||||
|
val serverHost = manager.serverHost
|
||||||
/** 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 screen: StateFlow<AppScreen> = combine(
|
val screen: StateFlow<AppScreen> = combine(
|
||||||
client.connectionState,
|
manager.connectionState,
|
||||||
bootstrapping,
|
manager.bootstrapping,
|
||||||
settings,
|
manager.settings,
|
||||||
) { connection, booting, saved ->
|
) { connection, booting, saved ->
|
||||||
when {
|
when {
|
||||||
booting -> AppScreen.Loading
|
booting -> AppScreen.Loading
|
||||||
@@ -72,47 +56,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
initialValue = AppScreen.Loading,
|
initialValue = AppScreen.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
fun connect(host: String, port: Int) = manager.connect(host, port)
|
||||||
viewModelScope.launch {
|
fun disconnect() = manager.disconnect()
|
||||||
val saved = settingsRepo.settingsOrNull.first()
|
fun togglePlayPause() = manager.togglePlayPause()
|
||||||
if (saved != null) {
|
fun next() = manager.next()
|
||||||
connect(saved.host, saved.port)
|
fun previous() = manager.previous()
|
||||||
// Wait until the client actually leaves Disconnected before we
|
fun seekTo(seconds: Double) = manager.seekTo(seconds)
|
||||||
// stop bootstrapping, so the form never flashes over the splash.
|
fun setVolume(volume: Int) = manager.setVolume(volume)
|
||||||
client.connectionState.first { it !is MpdConnectionState.Disconnected }
|
fun setRepeat(on: Boolean) = manager.setRepeat(on)
|
||||||
}
|
fun setRandom(on: Boolean) = manager.setRandom(on)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"androidx/annotation#annotation/1.1.0": {
|
||||||
"pom": "sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4="
|
"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": {
|
"androidx/annotation#annotation/1.6.0": {
|
||||||
"module": "sha256-YUa2E4ZDsqwFkN9QndUauup2nHn9dgLrIXFo/lr3jNI=",
|
"module": "sha256-YUa2E4ZDsqwFkN9QndUauup2nHn9dgLrIXFo/lr3jNI=",
|
||||||
"pom": "sha256-3YgrTBlFrRMSHs32UeumZKmyJrVduE+nVTkt5eENHls="
|
"pom": "sha256-3YgrTBlFrRMSHs32UeumZKmyJrVduE+nVTkt5eENHls="
|
||||||
@@ -82,8 +86,8 @@
|
|||||||
"module": "sha256-2lTeYVYYuTzZ7tpcmTWVW314bpnUNsM7A3l8SD6fTJk=",
|
"module": "sha256-2lTeYVYYuTzZ7tpcmTWVW314bpnUNsM7A3l8SD6fTJk=",
|
||||||
"pom": "sha256-ViZmWwY8AzrLuVAxL3Q0pCE+koUwh2fGxR6UAHqVKU4="
|
"pom": "sha256-ViZmWwY8AzrLuVAxL3Q0pCE+koUwh2fGxR6UAHqVKU4="
|
||||||
},
|
},
|
||||||
"androidx/collection#collection/1.0.0": {
|
"androidx/collection#collection/1.1.0": {
|
||||||
"pom": "sha256-p5E6UnWtaOVV0mEuvowUw2exU+FMpIoYcqZImQIOVO8="
|
"pom": "sha256-Z+kGbKSs/cbjzFCCk8MboDmAV/8Rjk9wseGBPJo0VtE="
|
||||||
},
|
},
|
||||||
"androidx/collection#collection/1.4.0": {
|
"androidx/collection#collection/1.4.0": {
|
||||||
"module": "sha256-L9O1I+gnbAJUxBe2bY5/7MWwuXWvXReK+n9bgTgSz6s=",
|
"module": "sha256-L9O1I+gnbAJUxBe2bY5/7MWwuXWvXReK+n9bgTgSz6s=",
|
||||||
@@ -545,6 +549,13 @@
|
|||||||
"androidx/lifecycle/lifecycle-viewmodel-savedstate/2.8.7/lifecycle-viewmodel-savedstate-2.8.7": {
|
"androidx/lifecycle/lifecycle-viewmodel-savedstate/2.8.7/lifecycle-viewmodel-savedstate-2.8.7": {
|
||||||
"aar": "sha256-ZG8qQtKISwIwj7GYNwieJmmDX+IcpqW2BmbV65TQgd0="
|
"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": {
|
"androidx/profileinstaller#profileinstaller/1.3.1": {
|
||||||
"module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=",
|
"module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=",
|
||||||
"pom": "sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g="
|
"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`).
|
`PlayerViewModel`/`AppScreen`).
|
||||||
- Room to grow: password field, connection timeout, theme, keep-screen-on.
|
- 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"
|
Done — full MALP-style **OS integration**, not just in-app. Architecture:
|
||||||
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.
|
|
||||||
|
|
||||||
- Functionally we already send `setvol` to the server (`MpdClient.setVolume`);
|
- **`MpdConnectionManager`** (app-scoped, held by `MusicRemoteApplication`) now
|
||||||
this is mostly a UX/affordance change: a cast icon, "Casting to <host>" label,
|
owns the single `MpdClient`, so the connection survives Activity recreation and
|
||||||
distinct styling for remote vs local volume.
|
runs while the service is up. `PlayerViewModel` is a thin delegate over it.
|
||||||
- Reference behaviour: MALP.
|
- **`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
|
## [ ] 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
|
**auto-reconnect transparently** (re-open connections, re-issue `idle`, resync
|
||||||
state) and keep showing the player. Consider a keepalive ping and, for
|
state) and keep showing the player. Consider a keepalive ping and, for
|
||||||
backgrounded playback control, a foreground service / partial wakelock.
|
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
|
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)
|
coroutines = "1.9.0" # kotlinx-coroutines (async I/O + Flow for the MPD client)
|
||||||
datastore = "1.1.1" # Jetpack DataStore (persisting connection settings)
|
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"
|
junit = "4.13.2"
|
||||||
|
|
||||||
[libraries]
|
[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" }
|
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" }
|
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-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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in New Issue
Block a user