I am developing an Android app using Kotlin and Jetpack Compose. It's a sound mixer where multiple audio tracks (loops) play simultaneously.
I have AudioMixerRepository holding a map of several ExoPlayer instances (one for each sound) to play them at the same time. When the user selects a sound, it starts to play immediately in the app.
I manage the lifecycle of these tracks in my AudioMixerViewModel:
/** * When app goes to background and session is not active, pause all the tracks. */ override fun onStop(owner: LifecycleOwner) { super.onStop(owner) if (!uiState.value.isSessionActive) { audioMixerRepository.pauseAll() } } /** * When app goes to foreground, resume all the tracks. */ override fun onStart(owner: LifecycleOwner) { super.onStart(owner) if (!uiState.value.isSessionActive) { audioMixerRepository.resumeAll() } } /** * When viewModel is destroyed, release all the tracks. */ override fun onCleared() { super.onCleared() ProcessLifecycleOwner.get().lifecycle.removeObserver(this) if (!uiState.value.isSessionActive) { audioMixerRepository.releaseAll() } }
In case the user clicks the "Start" button, the session starts and the audio must be playing in background (that is working right now). In addition, a Media3 system notification must appear with the name and the image of the selected mix.
To achieve this and keep the session active in the background, I implemented a MediaSessionService. Since the actual audio mixing (multiple ExoPlayer instances) is managed by my repository, I am using a "dummy player" inside the service. This player loads a silent audio track solely to maintain the MediaSession state, trigger the Foreground Service (only when "Start" is clicked), and display the system notification. I use a ForwardingPlayer to intercept the play/pause actions and route them to my repository.
Here is my MediaSessionService:
class AudioSessionMediaService : MediaSessionService() { private var mediaSession: MediaSession? = null private var dummyPlayer: ExoPlayer? = null private val audioRepository: AudioMixerRepository by inject() @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() // 1. Create a dummy player. Its only purpose is to maintain the state of MediaSession dummyPlayer = ExoPlayer.Builder(this).build().apply { volume = 0f repeatMode = Player.REPEAT_MODE_ALL val rawResourceId = R.raw.waves1 val soundUri = Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) .path(rawResourceId.toString()).build() val mediaItem = MediaItem.Builder() .setUri(soundUri) .build() setMediaItem(mediaItem) prepare() } // 2. Intercept commands val forwardingPlayer = object : ForwardingPlayer(dummyPlayer!!) { override fun getAvailableCommands(): Player.Commands { return super.getAvailableCommands().buildUpon() .remove(COMMAND_SEEK_TO_NEXT) .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .remove(COMMAND_SEEK_TO_PREVIOUS) .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) .build() } override fun getDuration(): Long { return C.TIME_UNSET } override fun play() { super.play() audioRepository.fadeInAllSounds() } override fun pause() { super.pause() audioRepository.fadeOutAndPauseAllSounds() } override fun stop() { super.stop() audioRepository.fadeOutAndPauseAllSounds() } } val callback = object : MediaSession.Callback { override fun onAddMediaItems( mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList<MediaItem>, ): ListenableFuture<MutableList<MediaItem>> { val resolvedItems = mediaItems.map { item -> item.buildUpon() .setMediaId(item.mediaId) .setUri(item.localConfiguration?.uri) .setMediaMetadata(item.mediaMetadata) .build() }.toMutableList() return Futures.immediateFuture(resolvedItems) } } // 3. Create session mediaSession = MediaSession.Builder(this, forwardingPlayer) .setId("MultitrackAudioSession") .setCallback(callback) .build() } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession override fun onDestroy() { mediaSession?.run { player.release() release() mediaSession = null } super.onDestroy() } }
The Problem
I manage the connection to the service using an AudioSessionManager singleton. I initialize the MediaController asynchronously when the this singleton starts. When the user clicks "Start" in the UI, I use this MediaController to send a new MediaItem with the custom title and image of the selected mix:
class AudioSessionManager(private val context: Context) { private var mediaControllerFuture: ListenableFuture<MediaController>? = null private var mediaController: MediaController? = null init { val sessionToken = SessionToken( context, ComponentName(context, AudioSessionMediaService::class.java) ) mediaControllerFuture = MediaController.Builder(context, sessionToken).buildAsync() mediaControllerFuture?.addListener( { mediaController = mediaControllerFuture?.get() }, ContextCompat.getMainExecutor(context) ) } fun startSession(mixTitle: String, mixImageUri: Uri) { val rawResourceId = R.raw.waves2 val dummyUri = Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) .path(rawResourceId.toString()).build() val mediaItem = MediaItem.Builder() .setMediaId("mix-${System.currentTimeMillis()}") // Trying to force an update with a unique ID .setUri(dummyUri) .setMediaMetadata( MediaMetadata.Builder() .setTitle(mixTitle) .setArtworkUri(mixImageUri) .build() ) .build() mediaController?.let { controller -> if (controller.mediaItemCount > 0) { controller.replaceMediaItem(0, mediaItem) } else { controller.setMediaItem(mediaItem) } controller.prepare() controller.play() } } }
Right now, the notification appears when the singleton is initialized (the service connects) and the notification title and artwork do not update when i click "Start". The system seems to ignore my MediaMetadata or it falls back to whatever it extracts (or doesn't extract) from the silent raw audio file.
I know about MediaNotification.Provider, but I haven't implemented it because the official Android documentation states that custom notifications via the provider are ignored on API 33+.
My Questions:
How can I force the Media3 system notification on API 33+ to reflect the custom MediaMetadata (title and artwork) sent via the MediaController?
How can I prevent the notification from showing up on initialization, making it appear only when the user clicks the "Start" button?