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 { + val playlist = List(player.mediaItemCount) { index -> + player.getMediaItemAt(index).track + } + + return playlist + } + + private fun updatePlaylist() { + notifyControllers { + onPlaylistChanged(getPlaylist()) + } + } + + private fun getVolume(): Double { + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val volume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + return volume.toDouble() / maxVolume.toDouble() + } + + + override fun onRenderedFirstFrame() { + super.onRenderedFirstFrame() + notifyControllers { + onVolumeChanged(getVolume()) + } + } + + override fun onTrackStart(mediaItem: MediaItem) { + val isPlaying = player.isPlaying + val position = player.currentPosition + val track = mediaItem.track + + notifyControllers { + onPlaybackStateChanged(isPlaying, position, track) + } + } + + override fun onTracksChanged(tracks: Tracks) { + super.onTracksChanged(tracks) + updatePlaylist() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + super.onTimelineChanged(timeline, reason) + updatePlaylist() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + val position = player.currentPosition + val track = player.currentMediaItem?.track ?: return + + notifyControllers { + onPlaybackStateChanged(isPlaying, position, track) + } + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + val repeatMode = player.repeatMode + + notifyControllers { + onPlaybackModeChanged(shuffleModeEnabled, RepeatMode.entries[repeatMode]) + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + super.onRepeatModeChanged(repeatMode) + val shuffleModeEnabled = player.shuffleModeEnabled + + notifyControllers { + onPlaybackModeChanged(shuffleModeEnabled, RepeatMode.entries[repeatMode]) + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + notifyControllers { + onPositionChanged(newPosition.positionMs) + } + } + + override fun onDeviceVolumeChanged(volume: Int, muted: Boolean) { + super.onDeviceVolumeChanged(volume, muted) + notifyControllers { + onVolumeChanged(getVolume()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt index 431b8d01..d4a0fd52 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt @@ -88,6 +88,7 @@ class ExtensionInfoFragment : Fragment() { ExtensionType.MUSIC -> viewModel.extensionListFlow.getExtension(clientId) ExtensionType.TRACKER -> viewModel.trackerListFlow.getExtension(clientId) ExtensionType.LYRICS -> viewModel.lyricsListFlow.getExtension(clientId) + ExtensionType.CONTROLLER -> viewModel.controllerListFlow.getExtension(clientId) } if (extension == null) { @@ -127,6 +128,7 @@ class ExtensionInfoFragment : Fragment() { ExtensionType.MUSIC -> R.string.music ExtensionType.TRACKER -> R.string.tracker ExtensionType.LYRICS -> R.string.lyrics + ExtensionType.CONTROLLER -> R.string.controller } val typeString = getString(R.string.name_extension, getString(type)) binding.extensionDescription.text = "$typeString\n\n${metadata.description}\n\n$byAuthor" diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt index b077b1e3..c42e5c52 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt @@ -94,6 +94,7 @@ class ExtensionInstallerBottomSheet : BottomSheetDialogFragment() { ExtensionType.MUSIC -> R.string.music ExtensionType.TRACKER -> R.string.tracker ExtensionType.LYRICS -> R.string.lyrics + ExtensionType.CONTROLLER -> R.string.controller } val typeString = getString(R.string.name_extension, getString(type)) binding.extensionDescription.text = "$typeString\n\n${metadata.description}\n\n$byAuthor" diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionsListBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionsListBottomSheet.kt index abf0f49d..c6a1db49 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionsListBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionsListBottomSheet.kt @@ -55,6 +55,7 @@ class ExtensionsListBottomSheet : BottomSheetDialogFragment() { ExtensionType.LYRICS -> activityViewModels().value ExtensionType.MUSIC -> activityViewModels().value ExtensionType.TRACKER -> throw IllegalStateException("Tracker not supported") + ExtensionType.CONTROLLER -> throw IllegalStateException("Controller not supported") } val listener = object : OnButtonCheckedListener { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginFragment.kt index 277c310b..c2fdec7f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginFragment.kt @@ -112,6 +112,7 @@ class LoginFragment : Fragment() { ExtensionType.MUSIC -> loginViewModel.extensionList.getExtension(clientId) ExtensionType.TRACKER -> loginViewModel.trackerList.getExtension(clientId) ExtensionType.LYRICS -> loginViewModel.lyricsList.getExtension(clientId) + ExtensionType.CONTROLLER -> loginViewModel.controllerList.getExtension(clientId) } if (extension == null) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginViewModel.kt index eff1b0f1..f7458272 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/login/LoginViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.EchoDatabase import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.ControllerExtension import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.LyricsExtension import dev.brahmkshatriya.echo.common.MusicExtension @@ -30,6 +31,7 @@ class LoginViewModel @Inject constructor( val extensionList: MutableStateFlow?>, val trackerList: MutableStateFlow?>, val lyricsList: MutableStateFlow?>, + val controllerList: MutableStateFlow?>, private val context: Application, val messageFlow: MutableSharedFlow, database: EchoDatabase, diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt index 6379b952..8ee449b1 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt @@ -92,6 +92,7 @@ class ExtensionFragment : BaseSettingsFragment() { ExtensionType.MUSIC -> extensionListFlow.getExtension(extensionId) ExtensionType.TRACKER -> trackerListFlow.getExtension(extensionId) ExtensionType.LYRICS -> lyricsListFlow.getExtension(extensionId) + ExtensionType.CONTROLLER -> controllerListFlow.getExtension(extensionId) } viewModelScope.launch { client?.run(throwableFlow) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/prefs/MaterialTextInputPreference.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/prefs/MaterialTextInputPreference.kt index e2781c7a..8b83a5a3 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/prefs/MaterialTextInputPreference.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/prefs/MaterialTextInputPreference.kt @@ -35,6 +35,7 @@ class MaterialTextInputPreference(context: Context) : EditTextPreference(context val newText = editText?.text?.toString() if (callChangeListener(newText)) { text = newText + updateSummary() dialog.dismiss() } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt index bced1763..ff3cd725 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt @@ -14,10 +14,12 @@ import dev.brahmkshatriya.echo.EchoDatabase import dev.brahmkshatriya.echo.ExtensionOpenerActivity import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.installExtension import dev.brahmkshatriya.echo.R +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.SettingsChangeListenerClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType import dev.brahmkshatriya.echo.common.helpers.ImportType @@ -62,6 +64,8 @@ class ExtensionViewModel @Inject constructor( val extensionListFlow: MutableStateFlow?>, val trackerListFlow: MutableStateFlow?>, val lyricsListFlow: MutableStateFlow?>, + val controllerListFlow: MutableStateFlow?>, + private val closeableFlow: MutableStateFlow?>, val extensionFlow: MutableStateFlow, val settings: SharedPreferences, val database: EchoDatabase, @@ -84,7 +88,7 @@ class ExtensionViewModel @Inject constructor( private val userDao = database.userDao() fun setExtension(extension: MusicExtension?) { setupMusicExtension( - viewModelScope, settings, extensionFlow, userDao, userFlow, throwableFlow, extension + viewModelScope, settings, extensionFlow, userDao, userFlow, throwableFlow, messageFlow, closeableFlow, extension ) } @@ -130,6 +134,7 @@ class ExtensionViewModel @Inject constructor( ExtensionType.MUSIC -> extensionListFlow ExtensionType.TRACKER -> trackerListFlow ExtensionType.LYRICS -> lyricsListFlow + ExtensionType.CONTROLLER -> controllerListFlow } fun moveExtensionItem(type: ExtensionType, toPos: Int, fromPos: Int) { diff --git a/app/src/main/res/layout/fragment_manage_extensions.xml b/app/src/main/res/layout/fragment_manage_extensions.xml index eb694d00..d9dd4200 100644 --- a/app/src/main/res/layout/fragment_manage_extensions.xml +++ b/app/src/main/res/layout/fragment_manage_extensions.xml @@ -44,6 +44,11 @@ android:layout_height="wrap_content" android:text="@string/lyrics" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b661690..386ebb2c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,10 @@ Sources Backgrounds Source Selection + Controller + Media Playback Controller + Control media playback from controller extensions + Media playback controller is running Highest Medium diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt index 7e4656cc..f58ef22b 100644 --- a/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt @@ -1,5 +1,6 @@ package dev.brahmkshatriya.echo.common +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 @@ -29,4 +30,9 @@ data class TrackerExtension( data class LyricsExtension( override val metadata: Metadata, override val instance: Lazy>, -) : Extension(ExtensionType.LYRICS, metadata, instance) \ No newline at end of file +) : Extension(ExtensionType.LYRICS, metadata, instance) + +data class ControllerExtension( + override val metadata: Metadata, + override val instance: Lazy>, +) : Extension(ExtensionType.CONTROLLER, metadata, instance) \ No newline at end of file diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/clients/CloseableClient.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/clients/CloseableClient.kt new file mode 100644 index 00000000..903ff1ae --- /dev/null +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/clients/CloseableClient.kt @@ -0,0 +1,9 @@ +package dev.brahmkshatriya.echo.common.clients + +interface CloseableClient { + /** + * Called when the app and player are closed. + * Useful for cleaning up resources. + */ + fun close() +} \ No newline at end of file diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/clients/ControllerClient.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/clients/ControllerClient.kt new file mode 100644 index 00000000..8c20b9f9 --- /dev/null +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/clients/ControllerClient.kt @@ -0,0 +1,149 @@ +package dev.brahmkshatriya.echo.common.clients + +import dev.brahmkshatriya.echo.common.models.Track +import kotlinx.serialization.Serializable + +abstract class ControllerClient : ExtensionClient { + @Serializable + enum class RepeatMode { + OFF, + ONE, + ALL + } + + /** + * The current state of the player. + * @param isPlaying Whether the player is playing. + * @param currentTrack The current track in the player. + * @param currentPosition The current position of the track. + * @param playlist The current playlist. + * @param currentIndex The index of the current track in the playlist. -1 if the playlist is empty. + * @param shuffle Whether shuffle mode is enabled. + * @param repeatMode The repeat mode. + * @see RepeatMode + * @param volume The volume of the player normalized between 0 and 1. + */ + @Serializable + data class PlayerState( + val isPlaying: Boolean = false, + val currentTrack: Track? = null, + val currentPosition: Long = 0, + val playlist: List = emptyList(), + val currentIndex: Int = 0, + val shuffle: Boolean = false, + val repeatMode: RepeatMode = RepeatMode.OFF, + val volume: Double = 0.0 + ) + + /** + * Whether the the extension can perform actions when the player is paused. + * Only set this to false if you are sure that the extension does not need to perform any actions when the player is paused. + */ + abstract var runsDuringPause: Boolean + + // app -> controller + /** + * Called when the playback state changes. + * @param isPlaying Whether the player is playing. + * @param position The current position of the track. + * @param track The current track in the player. + */ + abstract suspend fun onPlaybackStateChanged(isPlaying: Boolean, position: Long, track: Track?) + + /** + * Called when the playlist changes. + * @param playlist The new playlist. + * The first track is not necessarily the current track. + */ + abstract suspend fun onPlaylistChanged(playlist: List) + + /** + * Called when the playback mode changes. + * @param isShuffle Whether shuffle mode is enabled. + * @param repeatMode The repeat mode. + * @see RepeatMode + */ + abstract suspend fun onPlaybackModeChanged(isShuffle: Boolean, repeatMode: RepeatMode) + + /** + * Called when the position of the track changes. + * @param position The new position of the track. + */ + abstract suspend fun onPositionChanged(position: Long) + + /** + * Called when the volume of the player changes. + * @param volume The new volume of the player. + */ + abstract suspend fun onVolumeChanged(volume: Double) + + // controller -> app + /** + * Called when the controller requests the current state of the player. + * @return The current state of the player. + */ + var onRequestState: (suspend () -> PlayerState)? = null + + /** + * Called when the controller requests to play the player. + */ + var onPlayRequest: (suspend () -> Unit)? = null + + /** + * Called when the controller requests to pause the player. + */ + var onPauseRequest: (suspend () -> Unit)? = null + + /** + * Called when the controller requests to play the next track. + */ + var onNextRequest: (suspend () -> Unit)? = null + + /** + * Called when the controller requests to play the previous track. + */ + var onPreviousRequest: (suspend () -> Unit)? = null + + /** + * Called when the controller requests to seek to a position in the track. + * passes the position in milliseconds. + */ + var onSeekRequest: (suspend (position: Long) -> Unit)? = null + + /** + * Called when the controller requests to seek to a track in the playlist. + * passes the index of the track. + */ + var onSeekToMediaItemRequest: (suspend (index: Int) -> Unit)? = null + + /** + * Called when the controller requests to move a track in the playlist. + * passes the index of the track to move and the new index. + */ + var onMovePlaylistItemRequest: (suspend (fromIndex: Int, toIndex: Int) -> Unit)? = null + + /** + * Called when the controller requests to remove a track from the playlist. + * passes the index of the track to remove. + */ + var onRemovePlaylistItemRequest: (suspend (index: Int) -> Unit)? = null + + /** + * Called when the controller requests to enable or disable shuffle mode. + * passes whether shuffle mode should be enabled. + */ + var onShuffleModeRequest: (suspend (enabled: Boolean) -> Unit)? = null + + /** + * Called when the controller requests to change the repeat mode. + * passes the new repeat mode. + * @see RepeatMode + */ + var onRepeatModeRequest: (suspend (repeatMode: RepeatMode) -> Unit)? = null + + /** + * Called when the controller requests to change the volume of the player. + * passes the new volume of the player. + */ + var onVolumeRequest: (suspend (volume: Double) -> Unit)? = null +} \ No newline at end of file diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/clients/MessagePostClient.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/clients/MessagePostClient.kt new file mode 100644 index 00000000..814e98d1 --- /dev/null +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/clients/MessagePostClient.kt @@ -0,0 +1,17 @@ +package dev.brahmkshatriya.echo.common.clients + +interface MessagePostClient { + /** + * Posts a message to the user. + * Call this when you want to display a notification to the user. + * + * @param message The message text to display + */ + fun postMessage(message: String) + + /** + * Internal setup method used by the main app. + * Extensions should not call this method. + */ + fun setMessageHandler(handler: (String) -> Unit) +} \ No newline at end of file diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/helpers/ExtensionType.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/helpers/ExtensionType.kt index 3275036a..c67859c9 100644 --- a/common/src/main/java/dev/brahmkshatriya/echo/common/helpers/ExtensionType.kt +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/helpers/ExtensionType.kt @@ -3,5 +3,6 @@ package dev.brahmkshatriya.echo.common.helpers enum class ExtensionType(val feature: String) { MUSIC("music"), TRACKER("tracker"), - LYRICS("lyrics") + LYRICS("lyrics"), + CONTROLLER("controller") } \ No newline at end of file diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/providers/ControllerClientsProvider.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/providers/ControllerClientsProvider.kt new file mode 100644 index 00000000..95535266 --- /dev/null +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/providers/ControllerClientsProvider.kt @@ -0,0 +1,8 @@ +package dev.brahmkshatriya.echo.common.providers + +import dev.brahmkshatriya.echo.common.ControllerExtension + +interface ControllerClientsProvider { + val requiredControllerClients: List + fun setControllerExtensions(controllerClients: List) +} \ No newline at end of file