diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 175cebd5..dbf4bd53 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,7 +9,7 @@
-
+
+
+
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt
index f3734066..ad28dabf 100644
--- a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt
+++ b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt
@@ -17,12 +17,14 @@ import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import dagger.hilt.android.AndroidEntryPoint
import dev.brahmkshatriya.echo.common.MusicExtension
+import dev.brahmkshatriya.echo.common.clients.CloseableClient
import dev.brahmkshatriya.echo.common.models.Streamable
import dev.brahmkshatriya.echo.extensions.ExtensionLoader
import dev.brahmkshatriya.echo.playback.Current
import dev.brahmkshatriya.echo.playback.PlayerCallback
import dev.brahmkshatriya.echo.playback.ResumptionUtils
import dev.brahmkshatriya.echo.playback.listeners.AudioFocusListener
+import dev.brahmkshatriya.echo.playback.listeners.ControllerListener
import dev.brahmkshatriya.echo.playback.listeners.PlayerEventListener
import dev.brahmkshatriya.echo.playback.listeners.Radio
import dev.brahmkshatriya.echo.playback.listeners.TrackingListener
@@ -40,6 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@AndroidEntryPoint
+@UnstableApi
class PlayerService : MediaLibraryService() {
private var mediaSession: MediaLibrarySession? = null
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
@@ -56,6 +59,9 @@ class PlayerService : MediaLibraryService() {
@Inject
lateinit var stateFlow: MutableStateFlow
+ @Inject
+ lateinit var closeableFlow: MutableStateFlow?>
+
@Inject
lateinit var settings: SharedPreferences
@@ -72,6 +78,8 @@ class PlayerService : MediaLibraryService() {
@Inject
lateinit var fftAudioProcessor: FFTAudioProcessor
+ lateinit var controllerListener: ControllerListener
+
private val scope = CoroutineScope(Dispatchers.Main)
@OptIn(UnstableApi::class)
@@ -98,6 +106,7 @@ class PlayerService : MediaLibraryService() {
.setWakeMode(C.WAKE_MODE_NETWORK)
.setSkipSilenceEnabled(settings.getBoolean(SKIP_SILENCE, true))
.setAudioAttributes(audioAttributes, true)
+ .setDeviceVolumeControlEnabled(true)
.build()
.also {
it.trackSelectionParameters = it.trackSelectionParameters
@@ -115,6 +124,7 @@ class PlayerService : MediaLibraryService() {
val extListFlow = extensionLoader.extensions
val extFlow = extensionLoader.current
val trackerList = extensionLoader.trackers
+ val controllerList = extensionLoader.controllers
val exoPlayer = createExoplayer(extListFlow)
exoPlayer.prepare()
@@ -149,6 +159,14 @@ class PlayerService : MediaLibraryService() {
exoPlayer.addListener(
TrackingListener(exoPlayer, scope, extListFlow, trackerList, throwFlow)
)
+ controllerListener = ControllerListener(
+ exoPlayer,
+ this,
+ scope,
+ controllerList,
+ throwFlow
+ )
+ exoPlayer.addListener(controllerListener)
settings.registerOnSharedPreferenceChangeListener { prefs, key ->
when (key) {
SKIP_SILENCE -> exoPlayer.skipSilenceEnabled = prefs.getBoolean(key, true)
@@ -168,6 +186,14 @@ class PlayerService : MediaLibraryService() {
release()
mediaSession = null
}
+ closeableFlow.value?.forEach {
+ try {
+ it.close()
+ } catch (e: Exception) {
+ throwFlow.tryEmit(e)
+ }
+ }
+ if (::controllerListener.isInitialized) controllerListener.onDestroy()
super.onDestroy()
}
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt
index 651a687d..2a24b5db 100644
--- a/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt
+++ b/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt
@@ -10,12 +10,15 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.brahmkshatriya.echo.EchoDatabase
+import dev.brahmkshatriya.echo.common.ControllerExtension
import dev.brahmkshatriya.echo.common.LyricsExtension
import dev.brahmkshatriya.echo.common.MusicExtension
import dev.brahmkshatriya.echo.common.TrackerExtension
+import dev.brahmkshatriya.echo.common.clients.CloseableClient
import dev.brahmkshatriya.echo.db.models.UserEntity
import dev.brahmkshatriya.echo.extensions.ExtensionLoader
import dev.brahmkshatriya.echo.offline.OfflineExtension
+import dev.brahmkshatriya.echo.viewmodels.SnackBar
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Singleton
@@ -50,11 +53,20 @@ class ExtensionModule {
@Singleton
fun provideTrackerListFlow() = MutableStateFlow?>(null)
+ @Provides
+ @Singleton
+ fun provideControllerListFlow() = MutableStateFlow?>(null)
+
+ @Provides
+ @Singleton
+ fun provideCloseableClientListFlow() = MutableStateFlow?>(null)
+
@Provides
@Singleton
fun provideExtensionLoader(
context: Application,
throwableFlow: MutableSharedFlow,
+ mutableMessageFlow: MutableSharedFlow,
database: EchoDatabase,
settings: SharedPreferences,
refresher: MutableSharedFlow,
@@ -62,8 +74,10 @@ class ExtensionModule {
offlineExtension: OfflineExtension,
extensionListFlow: MutableStateFlow?>,
trackerListFlow: MutableStateFlow?>,
+ controllerListFlow: MutableStateFlow?>,
lyricsListFlow: MutableStateFlow?>,
extensionFlow: MutableStateFlow,
+ closeableClientListFlow: MutableStateFlow?>,
) = run {
val extensionDao = database.extensionDao()
val userDao = database.userDao()
@@ -71,6 +85,7 @@ class ExtensionModule {
context,
offlineExtension,
throwableFlow,
+ mutableMessageFlow,
extensionDao,
userDao,
settings,
@@ -78,8 +93,10 @@ class ExtensionModule {
userFlow,
extensionListFlow,
trackerListFlow,
+ controllerListFlow,
lyricsListFlow,
extensionFlow,
+ closeableClientListFlow
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ControllerExtensionService.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ControllerExtensionService.kt
new file mode 100644
index 00000000..ff236ddb
--- /dev/null
+++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ControllerExtensionService.kt
@@ -0,0 +1,120 @@
+package dev.brahmkshatriya.echo.extensions
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Intent
+import dev.brahmkshatriya.echo.R
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.common.Player
+import dev.brahmkshatriya.echo.common.clients.ControllerClient.RepeatMode
+
+@UnstableApi
+class ControllerExtensionService : Service() {
+ private val binder = LocalBinder()
+ private var player: Player? = null
+
+ inner class LocalBinder : Binder() {
+ fun getService(): ControllerExtensionService = this@ControllerExtensionService
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onBind(intent: Intent): IBinder {
+ return binder
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ startForeground(NOTIFICATION_ID, createNotification())
+ return START_STICKY
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ getString(R.string.media_playback_controller),
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.media_playback_controller_description)
+ setSound(null, null)
+ }
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun createNotification(): Notification {
+ return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(getString(R.string.media_playback_controller))
+ .setContentText(getString(R.string.media_playback_controller_running))
+ .setSmallIcon(android.R.drawable.ic_media_play)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .build()
+ }
+
+ fun setPlayer(exoPlayer: Player) {
+ player = exoPlayer
+ }
+
+ fun play() {
+ player?.play()
+ }
+
+ fun pause() {
+ player?.pause()
+ }
+
+ fun seekToNext() {
+ player?.seekToNextMediaItem()
+ }
+
+ fun seekToPrevious() {
+ player?.seekToPreviousMediaItem()
+ }
+
+ fun seekTo(position: Long) {
+ player?.seekTo(position)
+ }
+
+ fun seekToMediaItem(index: Int) {
+ player?.seekTo(index, 0)
+ }
+
+ fun moveMediaItem(fromIndex: Int, toIndex: Int) {
+ player?.moveMediaItem(fromIndex, toIndex)
+ }
+
+ fun removeMediaItem(index: Int) {
+ player?.removeMediaItem(index)
+ }
+
+ fun setShuffleMode(enabled: Boolean) {
+ player?.shuffleModeEnabled = enabled
+ }
+
+ fun setRepeatMode(repeatMode: RepeatMode) {
+ player?.repeatMode = repeatMode.ordinal
+ }
+
+ override fun onDestroy() {
+ stopForeground(STOP_FOREGROUND_DETACH)
+ player = null
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val NOTIFICATION_ID = 1
+ private const val NOTIFICATION_CHANNEL_ID = "media_playback_channel"
+ const val ACTION_START_SERVICE = "action.START_SERVICE"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ControllerServiceHelper.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ControllerServiceHelper.kt
new file mode 100644
index 00000000..715cee3d
--- /dev/null
+++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ControllerServiceHelper.kt
@@ -0,0 +1,74 @@
+package dev.brahmkshatriya.echo.extensions
+
+import android.app.Service
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Build
+import android.os.IBinder
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import dev.brahmkshatriya.echo.common.clients.ControllerClient.RepeatMode
+
+
+@UnstableApi
+class ControllerServiceHelper(private val parentService: Service) {
+ private var mediaService: ControllerExtensionService? = null
+ private var isServiceBound = false
+ private var player: Player? = null
+
+ private val serviceConnection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ val binder = service as ControllerExtensionService.LocalBinder
+ mediaService = binder.getService()
+ isServiceBound = true
+ player?.let { mediaService?.setPlayer(it) }
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ mediaService = null
+ isServiceBound = false
+ }
+ }
+
+ fun startService(player: Player) {
+ this.player = player
+
+ val intent = Intent(parentService, ControllerExtensionService::class.java).apply {
+ action = ControllerExtensionService.ACTION_START_SERVICE
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ parentService.startForegroundService(intent)
+ } else {
+ parentService.startService(intent)
+ }
+
+ parentService.bindService(
+ Intent(parentService, ControllerExtensionService::class.java),
+ serviceConnection,
+ Context.BIND_AUTO_CREATE
+ )
+ }
+
+ fun stopService() {
+ if (isServiceBound) {
+ parentService.unbindService(serviceConnection)
+ isServiceBound = false
+ }
+ parentService.stopService(Intent(parentService, ControllerExtensionService::class.java))
+ player = null
+ }
+ fun play() = mediaService?.play()
+ fun pause() = mediaService?.pause()
+ fun seekToNext() = mediaService?.seekToNext()
+ fun seekToPrevious() = mediaService?.seekToPrevious()
+ fun seekTo(position: Long) = mediaService?.seekTo(position)
+ fun seekToMediaItem(index: Int) = mediaService?.seekToMediaItem(index)
+ fun moveMediaItem(fromIndex: Int, toIndex: Int) =
+ mediaService?.moveMediaItem(fromIndex, toIndex)
+ fun removeMediaItem(index: Int) = mediaService?.removeMediaItem(index)
+ fun setShuffleMode(enabled: Boolean) = mediaService?.setShuffleMode(enabled)
+ fun setRepeatMode(repeatMode: RepeatMode) = mediaService?.setRepeatMode(repeatMode)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt
index db7524bc..ebb1774d 100644
--- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt
+++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt
@@ -2,14 +2,18 @@ package dev.brahmkshatriya.echo.extensions
import android.content.Context
import android.content.SharedPreferences
+import dev.brahmkshatriya.echo.common.ControllerExtension
import dev.brahmkshatriya.echo.common.Extension
import dev.brahmkshatriya.echo.common.LyricsExtension
import dev.brahmkshatriya.echo.common.MusicExtension
import dev.brahmkshatriya.echo.common.TrackerExtension
+import dev.brahmkshatriya.echo.common.clients.CloseableClient
import dev.brahmkshatriya.echo.common.clients.ExtensionClient
import dev.brahmkshatriya.echo.common.clients.LoginClient
+import dev.brahmkshatriya.echo.common.clients.MessagePostClient
import dev.brahmkshatriya.echo.common.helpers.ExtensionType
import dev.brahmkshatriya.echo.common.models.Metadata
+import dev.brahmkshatriya.echo.common.providers.ControllerClientsProvider
import dev.brahmkshatriya.echo.common.providers.LyricsClientsProvider
import dev.brahmkshatriya.echo.common.providers.MusicClientsProvider
import dev.brahmkshatriya.echo.common.providers.TrackerClientsProvider
@@ -22,6 +26,7 @@ import dev.brahmkshatriya.echo.extensions.plugger.PackageChangeListener
import dev.brahmkshatriya.echo.offline.BuiltInExtensionRepo
import dev.brahmkshatriya.echo.offline.OfflineExtension
import dev.brahmkshatriya.echo.utils.catchWith
+import dev.brahmkshatriya.echo.viewmodels.SnackBar
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -45,6 +50,7 @@ class ExtensionLoader(
context: Context,
offlineExtension: OfflineExtension,
private val throwableFlow: MutableSharedFlow,
+ private val mutableMessageFlow: MutableSharedFlow,
private val extensionDao: ExtensionDao,
private val userDao: UserDao,
private val settings: SharedPreferences,
@@ -52,8 +58,10 @@ class ExtensionLoader(
private val userFlow: MutableSharedFlow,
private val extensionListFlow: MutableStateFlow?>,
private val trackerListFlow: MutableStateFlow?>,
+ private val controllerListFlow: MutableStateFlow?>,
private val lyricsListFlow: MutableStateFlow?>,
private val extensionFlow: MutableStateFlow,
+ private val closeableFLow: MutableStateFlow?>,
) {
private val scope = MainScope() + CoroutineName("ExtensionLoader")
private val listener = PackageChangeListener(context)
@@ -62,9 +70,11 @@ class ExtensionLoader(
private val musicExtensionRepo = MusicExtensionRepo(context, listener, fileListener, builtIn)
private val trackerExtensionRepo = TrackerExtensionRepo(context, listener, fileListener)
+ private val controllerExtensionRepo = ControllerExtensionRepo(context, listener, fileListener)
private val lyricsExtensionRepo = LyricsExtensionRepo(context, listener, fileListener)
val trackers = trackerListFlow
+ val controllers = controllerListFlow
val extensions = extensionListFlow
val current = extensionFlow
val currentWithUser = MutableStateFlow>(null to null)
@@ -109,10 +119,11 @@ class ExtensionLoader(
//Inject other extensions
launch {
val combined = merge(
- extensionFlow.map { listOfNotNull(it) }, trackerListFlow, lyricsListFlow
+ extensionFlow.map { listOfNotNull(it) }, trackerListFlow, lyricsListFlow, controllerListFlow
)
combined.collect { list ->
val trackerExtensions = trackerListFlow.value.orEmpty()
+ val controllerExtensions = controllerListFlow.value.orEmpty()
val lyricsExtensions = lyricsListFlow.value.orEmpty()
val musicExtensions = extensionListFlow.value.orEmpty()
list?.forEach { extension ->
@@ -121,6 +132,11 @@ class ExtensionLoader(
setTrackerExtensions(it)
}
}
+ extension.get(throwableFlow) {
+ inject(extension.name, requiredControllerClients, controllerExtensions) {
+ setControllerExtensions(it)
+ }
+ }
extension.get(throwableFlow) {
inject(extension.name, requiredLyricsClients, lyricsExtensions) {
setLyricsExtensions(it)
@@ -162,6 +178,7 @@ class ExtensionLoader(
private suspend fun getAllPlugins(scope: CoroutineScope) {
val trackers = MutableStateFlow(null)
+ val controllers = MutableStateFlow(null)
val lyrics = MutableStateFlow(null)
val music = MutableStateFlow(null)
scope.launch {
@@ -174,6 +191,16 @@ class ExtensionLoader(
trackers.emit(Unit)
}
}
+ scope.launch {
+ controllerExtensionRepo.getPlugins { list ->
+ val controllerExtensions = list.map { (metadata, client) ->
+ ControllerExtension(metadata, client)
+ }
+ controllerListFlow.value = controllerExtensions
+ controllerExtensions.setExtensions()
+ controllers.emit(Unit)
+ }
+ }
scope.launch {
lyricsExtensionRepo.getPlugins { list ->
val lyricsExtensions = list.map { (metadata, client) ->
@@ -186,6 +213,7 @@ class ExtensionLoader(
}
lyrics.first { it != null }
trackers.first { it != null }
+ controllers.first { it != null }
scope.launch {
musicExtensionRepo.getPlugins { list ->
@@ -196,7 +224,7 @@ class ExtensionLoader(
val id = settings.getString(LAST_EXTENSION_KEY, null)
val extension = extensions.find { it.metadata.id == id } ?: extensions.firstOrNull()
setupMusicExtension(
- scope, settings, extensionFlow, userDao, userFlow, throwableFlow, extension
+ scope, settings, extensionFlow, userDao, userFlow, throwableFlow, mutableMessageFlow, closeableFLow, extension
)
refresher.emit(false)
music.emit(Unit)
@@ -228,7 +256,7 @@ class ExtensionLoader(
private suspend fun List>.setExtensions() = coroutineScope {
map {
async {
- setExtension(userDao, userFlow, throwableFlow, it)
+ setExtension(userDao, userFlow, throwableFlow, mutableMessageFlow, closeableFLow, it)
}
}.awaitAll()
}
@@ -243,6 +271,8 @@ class ExtensionLoader(
const val LAST_EXTENSION_KEY = "last_extension"
private const val TIMEOUT = 5000L
+ private val messageScope = MainScope() + CoroutineName("MessageHandler")
+
fun ExtensionType.priorityKey() = "priority_$this"
fun setupMusicExtension(
@@ -252,12 +282,14 @@ class ExtensionLoader(
userDao: UserDao,
userFlow: MutableSharedFlow,
throwableFlow: MutableSharedFlow,
+ mutableMessageFlow: MutableSharedFlow,
+ closeableFlow: MutableStateFlow?>,
extension: MusicExtension?
) {
settings.edit().putString(LAST_EXTENSION_KEY, extension?.id).apply()
extension?.takeIf { it.metadata.enabled } ?: return
scope.launch {
- setExtension(userDao, userFlow, throwableFlow, extension)
+ setExtension(userDao, userFlow, throwableFlow, mutableMessageFlow, closeableFlow, extension)
extensionFlow.value = extension
}
}
@@ -266,8 +298,17 @@ class ExtensionLoader(
userDao: UserDao,
userFlow: MutableSharedFlow,
throwableFlow: MutableSharedFlow,
+ mutableMessageFlow: MutableSharedFlow,
+ closeableFlow: MutableStateFlow?>,
extension: Extension<*>,
) = withContext(Dispatchers.IO) {
+ extension.takeIf { it.metadata.enabled } ?: return@withContext
+ extension.get(throwableFlow){
+ registerMessagePostClient(this, extension.name, mutableMessageFlow)
+ }
+ extension.get(throwableFlow) {
+ closeableFlow.value = closeableFlow.value.orEmpty() + this
+ }
extension.run(throwableFlow) {
withTimeout(TIMEOUT) { onExtensionSelected() }
}
@@ -286,5 +327,21 @@ class ExtensionLoader(
}
if (success != null) flow.emit(user)
}
+
+ private fun registerMessagePostClient(
+ client: MessagePostClient,
+ name: String,
+ mutableMessageFlow: MutableSharedFlow
+ ) {
+ client.setMessageHandler { message ->
+ messageScope.launch(Dispatchers.Main) {
+ mutableMessageFlow.emit(
+ SnackBar.Message(
+ "$name: $message",
+ )
+ )
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt
index a134690a..cf4b14c1 100644
--- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt
+++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt
@@ -1,6 +1,7 @@
package dev.brahmkshatriya.echo.extensions
import android.content.Context
+import dev.brahmkshatriya.echo.common.clients.ControllerClient
import dev.brahmkshatriya.echo.common.clients.ExtensionClient
import dev.brahmkshatriya.echo.common.clients.LyricsClient
import dev.brahmkshatriya.echo.common.clients.TrackerClient
@@ -89,6 +90,15 @@ class TrackerExtensionRepo(
override val type = ExtensionType.TRACKER
}
+class ControllerExtensionRepo(
+ context: Context,
+ listener: PackageChangeListener,
+ fileChangeListener: FileChangeListener,
+ vararg repo: LazyPluginRepo
+) : ExtensionRepo(context, listener, fileChangeListener, *repo) {
+ override val type = ExtensionType.CONTROLLER
+}
+
class LyricsExtensionRepo(
context: Context,
listener: PackageChangeListener,
diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/ControllerListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/ControllerListener.kt
new file mode 100644
index 00000000..8c97d92e
--- /dev/null
+++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/ControllerListener.kt
@@ -0,0 +1,304 @@
+package dev.brahmkshatriya.echo.playback.listeners
+
+import android.app.Service
+import android.content.Context
+import android.media.AudioManager
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.Timeline
+import androidx.media3.common.Tracks
+import androidx.media3.common.util.UnstableApi
+import dev.brahmkshatriya.echo.common.ControllerExtension
+import dev.brahmkshatriya.echo.common.clients.ControllerClient
+import dev.brahmkshatriya.echo.common.clients.ControllerClient.PlayerState
+import dev.brahmkshatriya.echo.common.clients.ControllerClient.RepeatMode
+import dev.brahmkshatriya.echo.common.models.Track
+import dev.brahmkshatriya.echo.extensions.ControllerServiceHelper
+import dev.brahmkshatriya.echo.extensions.get
+import dev.brahmkshatriya.echo.playback.MediaItemUtils.track
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@UnstableApi
+class ControllerListener(
+ player: Player,
+ service: Service,
+ private val scope: CoroutineScope,
+ private val controllerExtensions: MutableStateFlow?>,
+ private val throwableFlow: MutableSharedFlow
+) : PlayerListener(player) {
+ private var audioManager: AudioManager =
+ service.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ private val serviceHelper = ControllerServiceHelper(service)
+ private var needsService: Boolean = false
+
+ init {
+ scope.launch {
+ controllerExtensions.collect { extensions ->
+ extensions?.filter { it.metadata.enabled }?.forEach {
+ launch {
+ registerController(it)
+ }
+ }
+ }
+ }
+ }
+
+ fun onDestroy() {
+ notifyControllers {
+ onPlaybackStateChanged(false, 0, null)
+ }
+ serviceHelper.stopService()
+ }
+
+ private suspend fun registerController(extension: ControllerExtension) {
+ extension.get(throwableFlow) {
+ if (runsDuringPause) {
+ if (!needsService) {
+ serviceHelper.startService(player)
+ }
+ needsService = true
+ }
+ onPlayRequest = {
+ tryOnMain(Player.COMMAND_PLAY_PAUSE) {
+ if (needsService) {
+ serviceHelper.play()
+ } else {
+ player.play()
+ }
+ }
+ }
+ onPauseRequest = {
+ tryOnMain(Player.COMMAND_PLAY_PAUSE) {
+ if (needsService) {
+ serviceHelper.pause()
+ } else {
+ player.pause()
+ }
+ }
+ }
+ onNextRequest = {
+ tryOnMain(Player.COMMAND_SEEK_TO_NEXT) {
+ if (needsService) {
+ serviceHelper.seekToNext()
+ } else {
+ player.seekToNextMediaItem()
+ }
+ }
+ }
+ onPreviousRequest = {
+ tryOnMain(Player.COMMAND_SEEK_TO_PREVIOUS) {
+ if (needsService) {
+ serviceHelper.seekToPrevious()
+ } else {
+ player.seekToPreviousMediaItem()
+ }
+ }
+ }
+ onSeekRequest = { position ->
+ tryOnMain(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) {
+ if (needsService) {
+ serviceHelper.seekTo(position)
+ } else {
+ player.seekTo(position)
+ }
+ }
+ }
+ onSeekToMediaItemRequest = { index ->
+ tryOnMain(Player.COMMAND_SEEK_TO_MEDIA_ITEM) {
+ if (needsService) {
+ serviceHelper.seekToMediaItem(index)
+ } else {
+ player.seekTo(index, 0)
+ }
+ }
+ }
+ onMovePlaylistItemRequest = { fromIndex, toIndex ->
+ tryOnMain(Player.COMMAND_CHANGE_MEDIA_ITEMS) {
+ if (needsService) {
+ serviceHelper.moveMediaItem(fromIndex, toIndex)
+ } else {
+ player.moveMediaItem(fromIndex, toIndex)
+ }
+ }
+ }
+ onRemovePlaylistItemRequest = { index ->
+ tryOnMain(Player.COMMAND_CHANGE_MEDIA_ITEMS) {
+ if (needsService) {
+ serviceHelper.removeMediaItem(index)
+ } else {
+ player.removeMediaItem(index)
+ }
+ }
+ }
+ onShuffleModeRequest = { enabled ->
+ tryOnMain(Player.COMMAND_SET_SHUFFLE_MODE) {
+ if (needsService) {
+ serviceHelper.setShuffleMode(enabled)
+ } else {
+ player.shuffleModeEnabled = enabled
+ }
+ }
+ }
+ onRepeatModeRequest = { repeatMode ->
+ tryOnMain(Player.COMMAND_SET_REPEAT_MODE) {
+ if (needsService) {
+ serviceHelper.setRepeatMode(repeatMode)
+ } else {
+ player.repeatMode = repeatMode.ordinal
+ }
+ }
+ }
+ onVolumeRequest = { volume ->
+ tryOnMain(-1) { // not an exoplayer command
+ val denormalized =
+ volume * audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+ audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, denormalized.toInt(), 0)
+ }
+ }
+ onRequestState = {
+ tryOnMain(-1) {
+ val playlist = getPlaylist()
+ val currentTrack = player.currentMediaItem?.track
+ PlayerState(
+ player.isPlaying,
+ currentTrack,
+ player.currentPosition,
+ playlist,
+ playlist.indexOf(currentTrack),
+ player.shuffleModeEnabled,
+ RepeatMode.entries[player.repeatMode],
+ getVolume()
+ )
+ } ?: PlayerState()
+ }
+ }
+ }
+
+ private suspend fun ControllerClient.tryOnMain(
+ command: Int,
+ block: suspend ControllerClient.() -> T?
+ ): T? {
+ return withContext(Dispatchers.Main.immediate) {
+ try {
+ if (command == -1 || player.isCommandAvailable(command)) {
+ block()
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ throwableFlow.emit(e)
+ null
+ }
+ }
+ }
+
+ private fun notifyControllers(block: suspend ControllerClient.() -> Unit) {
+ val controllers = controllerExtensions.value?.filter { it.metadata.enabled } ?: emptyList()
+ scope.launch {
+ controllers.forEach {
+ launch {
+ it.get(throwableFlow) { block() }
+ }
+ }
+ }
+ }
+
+ private fun getPlaylist(): List