Merge "Polish domain and repository for use in UI" into main
diff --git a/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt b/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
new file mode 100644
index 0000000..cda6b8b
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.media.session
+
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.os.UserHandle
+import androidx.concurrent.futures.DirectExecutor
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+
+/** [Flow] for [MediaSessionManager.OnActiveSessionsChangedListener]. */
+val MediaSessionManager.activeMediaChanges: Flow<Collection<MediaController>?>
+    get() =
+        callbackFlow {
+                val listener =
+                    MediaSessionManager.OnActiveSessionsChangedListener { launch { send(it) } }
+                addOnActiveSessionsChangedListener(
+                    null,
+                    UserHandle.of(UserHandle.myUserId()),
+                    DirectExecutor.INSTANCE,
+                    listener,
+                )
+                awaitClose { removeOnActiveSessionsChangedListener(listener) }
+            }
+            .buffer(capacity = Channel.CONFLATED)
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerExt.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerExt.kt
new file mode 100644
index 0000000..1f037c0
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerExt.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.volume.data.repository
+
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Bundle
+import android.os.Handler
+import kotlinx.coroutines.channels.ProducerScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+
+/** [MediaController.Callback] flow representation. */
+fun MediaController.stateChanges(handler: Handler): Flow<MediaControllerChange> {
+    return callbackFlow {
+        val callback = MediaControllerCallbackProducer(this)
+        registerCallback(callback, handler)
+        awaitClose { unregisterCallback(callback) }
+    }
+}
+
+/** Models particular change event received by [MediaController.Callback]. */
+sealed interface MediaControllerChange {
+
+    data object SessionDestroyed : MediaControllerChange
+
+    data class SessionEvent(val event: String, val extras: Bundle?) : MediaControllerChange
+
+    data class PlaybackStateChanged(val state: PlaybackState?) : MediaControllerChange
+
+    data class MetadataChanged(val metadata: MediaMetadata?) : MediaControllerChange
+
+    data class QueueChanged(val queue: MutableList<MediaSession.QueueItem>?) :
+        MediaControllerChange
+
+    data class QueueTitleChanged(val title: CharSequence?) : MediaControllerChange
+
+    data class ExtrasChanged(val extras: Bundle?) : MediaControllerChange
+
+    data class AudioInfoChanged(val info: MediaController.PlaybackInfo?) : MediaControllerChange
+}
+
+private class MediaControllerCallbackProducer(
+    private val producingScope: ProducerScope<MediaControllerChange>
+) : MediaController.Callback() {
+
+    override fun onSessionDestroyed() {
+        send(MediaControllerChange.SessionDestroyed)
+    }
+
+    override fun onSessionEvent(event: String, extras: Bundle?) {
+        send(MediaControllerChange.SessionEvent(event, extras))
+    }
+
+    override fun onPlaybackStateChanged(state: PlaybackState?) {
+        send(MediaControllerChange.PlaybackStateChanged(state))
+    }
+
+    override fun onMetadataChanged(metadata: MediaMetadata?) {
+        send(MediaControllerChange.MetadataChanged(metadata))
+    }
+
+    override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
+        send(MediaControllerChange.QueueChanged(queue))
+    }
+
+    override fun onQueueTitleChanged(title: CharSequence?) {
+        send(MediaControllerChange.QueueTitleChanged(title))
+    }
+
+    override fun onExtrasChanged(extras: Bundle?) {
+        send(MediaControllerChange.ExtrasChanged(extras))
+    }
+
+    override fun onAudioInfoChanged(info: MediaController.PlaybackInfo?) {
+        send(MediaControllerChange.AudioInfoChanged(info))
+    }
+
+    private fun send(change: MediaControllerChange) {
+        producingScope.launch { producingScope.send(change) }
+    }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
index ab8c6b8..6925c71 100644
--- a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
@@ -16,21 +16,23 @@
 
 package com.android.settingslib.volume.data.repository
 
+import android.content.Intent
 import android.media.AudioManager
 import android.media.session.MediaController
 import android.media.session.MediaSessionManager
 import android.media.session.PlaybackState
 import com.android.settingslib.bluetooth.LocalBluetoothManager
 import com.android.settingslib.bluetooth.headsetAudioModeChanges
+import com.android.settingslib.media.session.activeMediaChanges
 import com.android.settingslib.volume.shared.AudioManagerIntentsReceiver
 import kotlin.coroutines.CoroutineContext
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.stateIn
 
@@ -38,7 +40,7 @@
 interface MediaControllerRepository {
 
     /** Current [MediaController]. Null is emitted when there is no active [MediaController]. */
-    val activeMediaController: StateFlow<MediaController?>
+    val activeLocalMediaController: StateFlow<MediaController?>
 }
 
 class MediaControllerRepositoryImpl(
@@ -53,26 +55,28 @@
         audioManagerIntentsReceiver.intents.filter {
             AudioManager.STREAM_DEVICES_CHANGED_ACTION == it.action
         }
-    override val activeMediaController: StateFlow<MediaController?> =
-        buildList {
-                localBluetoothManager?.headsetAudioModeChanges?.let { add(it) }
-                add(devicesChanges)
+
+    override val activeLocalMediaController: StateFlow<MediaController?> =
+        combine(
+                mediaSessionManager.activeMediaChanges.onStart {
+                    emit(mediaSessionManager.getActiveSessions(null))
+                },
+                localBluetoothManager?.headsetAudioModeChanges?.onStart { emit(Unit) }
+                    ?: flowOf(null),
+                devicesChanges.onStart { emit(Intent()) },
+            ) { controllers, _, _ ->
+                controllers?.let(::findLocalMediaController)
             }
-            .merge()
-            .onStart { emit(Unit) }
-            .map { getActiveLocalMediaController() }
             .flowOn(backgroundContext)
             .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null)
 
-    private fun getActiveLocalMediaController(): MediaController? {
+    private fun findLocalMediaController(
+        controllers: Collection<MediaController>,
+    ): MediaController? {
         var localController: MediaController? = null
         val remoteMediaSessionLists: MutableList<String> = ArrayList()
-        for (controller in mediaSessionManager.getActiveSessions(null)) {
+        for (controller in controllers) {
             val playbackInfo: MediaController.PlaybackInfo = controller.playbackInfo ?: continue
-            val playbackState = controller.playbackState ?: continue
-            if (inactivePlaybackStates.contains(playbackState.state)) {
-                continue
-            }
             when (playbackInfo.playbackType) {
                 MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE -> {
                     if (localController?.packageName.equals(controller.packageName)) {
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
index 430d733..7bd43d2 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
+++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
@@ -116,7 +116,7 @@
                     )
                 )
             var mediaController: MediaController? = null
-            underTest.activeMediaController
+            underTest.activeLocalMediaController
                 .onEach { mediaController = it }
                 .launchIn(backgroundScope)
             runCurrent()
@@ -141,7 +141,7 @@
                     )
                 )
             var mediaController: MediaController? = null
-            underTest.activeMediaController
+            underTest.activeLocalMediaController
                 .onEach { mediaController = it }
                 .launchIn(backgroundScope)
             runCurrent()
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt
index 43d5453..236aee2 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt
@@ -32,7 +32,7 @@
     @Binds
     @IntoMap
     @StringKey(VolumePanelComponents.BOTTOM_BAR)
-    fun bindMediaVolumeSliderComponent(component: BottomBarComponent): VolumePanelUiComponent
+    fun bindVolumePanelUiComponent(component: BottomBarComponent): VolumePanelUiComponent
 
     @Binds
     @IntoMap
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/domain/interactor/AudioModeInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioModeInteractorTest.kt
similarity index 88%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/domain/interactor/AudioModeInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioModeInteractorTest.kt
index 4dbf865..fe34361 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/domain/interactor/AudioModeInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioModeInteractorTest.kt
@@ -14,13 +14,14 @@
  * limitations under the License.
  */
 
-package com.android.settingslib.volume.domain.interactor
+package com.android.systemui.volume.domain.interactor
 
 import android.media.AudioManager
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.settingslib.BaseTest
-import com.android.settingslib.volume.data.repository.FakeAudioRepository
+import com.android.settingslib.volume.domain.interactor.AudioModeInteractor
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.volume.data.repository.FakeAudioRepository
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.launchIn
@@ -34,7 +35,7 @@
 @OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
-class AudioModeInteractorTest : BaseTest() {
+class AudioModeInteractorTest : SysuiTestCase() {
 
     private val testScope = TestScope()
     private val fakeAudioRepository = FakeAudioRepository()
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteriaTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteriaTest.kt
new file mode 100644
index 0000000..ec37925
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteriaTest.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.domain
+
+import android.media.AudioManager
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.testing.TestableLooper.RunWithLooper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.testKosmos
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.audioModeInteractor
+import com.android.systemui.volume.audioRepository
+import com.android.systemui.volume.localMediaRepository
+import com.android.systemui.volume.mediaController
+import com.android.systemui.volume.mediaControllerRepository
+import com.android.systemui.volume.mediaOutputInteractor
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@RunWithLooper(setAsMainLooper = true)
+class MediaOutputAvailabilityCriteriaTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+
+    private lateinit var underTest: MediaOutputAvailabilityCriteria
+
+    @Before
+    fun setup() {
+        with(kosmos) {
+            whenever(mediaController.packageName).thenReturn("test.pkg")
+            whenever(mediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
+            whenever(mediaController.playbackState).thenReturn(PlaybackState.Builder().build())
+
+            mediaControllerRepository.setActiveLocalMediaController(mediaController)
+
+            underTest = MediaOutputAvailabilityCriteria(mediaOutputInteractor, audioModeInteractor)
+        }
+    }
+
+    @Test
+    fun notInCallAndHasDevices_isAvailable_true() {
+        with(kosmos) {
+            testScope.runTest {
+                audioRepository.setMode(AudioManager.MODE_NORMAL)
+                localMediaRepository.updateMediaDevices(listOf(mock {}))
+
+                val isAvailable by collectLastValue(underTest.isAvailable())
+                runCurrent()
+
+                assertThat(isAvailable).isTrue()
+            }
+        }
+    }
+    @Test
+    fun inCallAndHasDevices_isAvailable_false() {
+        with(kosmos) {
+            testScope.runTest {
+                audioRepository.setMode(AudioManager.MODE_IN_CALL)
+                localMediaRepository.updateMediaDevices(listOf(mock {}))
+
+                val isAvailable by collectLastValue(underTest.isAvailable())
+                runCurrent()
+
+                assertThat(isAvailable).isFalse()
+            }
+        }
+    }
+
+    @Test
+    fun notInCallAndHasDevices_isAvailable_false() {
+        with(kosmos) {
+            testScope.runTest {
+                audioRepository.setMode(AudioManager.MODE_NORMAL)
+                localMediaRepository.updateMediaDevices(emptyList())
+
+                val isAvailable by collectLastValue(underTest.isAvailable())
+                runCurrent()
+
+                assertThat(isAvailable).isFalse()
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt
index 25d89fa..02be0c1 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt
@@ -35,10 +35,10 @@
 import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection
 import javax.inject.Inject
 
-/**
- * Factory to create [MediaOutputDialog] objects.
- */
-open class MediaOutputDialogFactory @Inject constructor(
+/** Factory to create [MediaOutputDialog] objects. */
+open class MediaOutputDialogFactory
+@Inject
+constructor(
     private val context: Context,
     private val mediaSessionManager: MediaSessionManager,
     private val lbm: LocalBluetoothManager?,
@@ -55,46 +55,93 @@
     private val userTracker: UserTracker
 ) {
     companion object {
-        private const val INTERACTION_JANK_TAG = "media_output"
+        const val INTERACTION_JANK_TAG = "media_output"
         var mediaOutputDialog: MediaOutputDialog? = null
     }
 
     /** Creates a [MediaOutputDialog] for the given package. */
     open fun create(packageName: String, aboveStatusBar: Boolean, view: View? = null) {
-        create(packageName, aboveStatusBar, view, includePlaybackAndAppMetadata = true)
+        createWithController(
+            packageName,
+            aboveStatusBar,
+            controller =
+                view?.let {
+                    DialogTransitionAnimator.Controller.fromView(
+                        it,
+                        DialogCuj(
+                            InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
+                            INTERACTION_JANK_TAG
+                        )
+                    )
+                },
+        )
     }
 
-    open fun createDialogForSystemRouting() {
-        create(packageName = null, aboveStatusBar = false, includePlaybackAndAppMetadata = false)
+    /** Creates a [MediaOutputDialog] for the given package. */
+    open fun createWithController(
+        packageName: String,
+        aboveStatusBar: Boolean,
+        controller: DialogTransitionAnimator.Controller?,
+    ) {
+        create(
+            packageName,
+            aboveStatusBar,
+            dialogTransitionAnimatorController = controller,
+            includePlaybackAndAppMetadata = true
+        )
+    }
+
+    open fun createDialogForSystemRouting(controller: DialogTransitionAnimator.Controller? = null) {
+        create(
+            packageName = null,
+            aboveStatusBar = false,
+            dialogTransitionAnimatorController = null,
+            includePlaybackAndAppMetadata = false
+        )
     }
 
     private fun create(
-            packageName: String?,
-            aboveStatusBar: Boolean,
-            view: View? = null,
-            includePlaybackAndAppMetadata: Boolean = true
+        packageName: String?,
+        aboveStatusBar: Boolean,
+        dialogTransitionAnimatorController: DialogTransitionAnimator.Controller?,
+        includePlaybackAndAppMetadata: Boolean = true
     ) {
         // Dismiss the previous dialog, if any.
         mediaOutputDialog?.dismiss()
 
-        val controller = MediaOutputController(
-            context, packageName,
-            mediaSessionManager, lbm, starter, notifCollection,
-            dialogTransitionAnimator, nearbyMediaDevicesManager, audioManager,
-            powerExemptionManager, keyGuardManager, featureFlags, userTracker)
+        val controller =
+            MediaOutputController(
+                context,
+                packageName,
+                mediaSessionManager,
+                lbm,
+                starter,
+                notifCollection,
+                dialogTransitionAnimator,
+                nearbyMediaDevicesManager,
+                audioManager,
+                powerExemptionManager,
+                keyGuardManager,
+                featureFlags,
+                userTracker
+            )
         val dialog =
-            MediaOutputDialog(context, aboveStatusBar, broadcastSender, controller,
-                    dialogTransitionAnimator, uiEventLogger, includePlaybackAndAppMetadata)
+            MediaOutputDialog(
+                context,
+                aboveStatusBar,
+                broadcastSender,
+                controller,
+                dialogTransitionAnimator,
+                uiEventLogger,
+                includePlaybackAndAppMetadata
+            )
         mediaOutputDialog = dialog
 
         // Show the dialog.
-        if (view != null) {
-            dialogTransitionAnimator.showFromView(
-                dialog, view,
-                cuj = DialogCuj(
-                    InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
-                    INTERACTION_JANK_TAG
-                )
+        if (dialogTransitionAnimatorController != null) {
+            dialogTransitionAnimator.show(
+                dialog,
+                dialogTransitionAnimatorController,
             )
         } else {
             dialog.show()
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
index ab76d45..9f99e97 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
@@ -24,6 +24,9 @@
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactoryImpl
+import dagger.Binds
 import dagger.Module
 import dagger.Provides
 import kotlin.coroutines.CoroutineContext
@@ -32,6 +35,11 @@
 @Module
 interface MediaDevicesModule {
 
+    @Binds
+    fun bindLocalMediaRepositoryFactory(
+        impl: LocalMediaRepositoryFactoryImpl
+    ): LocalMediaRepositoryFactory
+
     companion object {
 
         @Provides
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
index 0a1ee24..1f52260 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
@@ -26,7 +26,12 @@
 import kotlin.coroutines.CoroutineContext
 import kotlinx.coroutines.CoroutineScope
 
-class LocalMediaRepositoryFactory
+interface LocalMediaRepositoryFactory {
+
+    fun create(packageName: String?): LocalMediaRepository
+}
+
+class LocalMediaRepositoryFactoryImpl
 @Inject
 constructor(
     private val intentsReceiver: AudioManagerIntentsReceiver,
@@ -34,9 +39,9 @@
     private val localMediaManagerFactory: LocalMediaManagerFactory,
     @Application private val coroutineScope: CoroutineScope,
     @Background private val backgroundCoroutineContext: CoroutineContext,
-) {
+) : LocalMediaRepositoryFactory {
 
-    fun create(packageName: String?): LocalMediaRepository =
+    override fun create(packageName: String?): LocalMediaRepository =
         LocalMediaRepositoryImpl(
             intentsReceiver,
             localMediaManagerFactory.create(packageName),
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteria.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteria.kt
new file mode 100644
index 0000000..020ec64
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteria.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.domain
+
+import com.android.settingslib.volume.domain.interactor.AudioModeInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
+import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
+import com.android.systemui.volume.panel.domain.ComponentAvailabilityCriteria
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+
+/** Determines if the Media Output Volume Panel component is available. */
+@VolumePanelScope
+class MediaOutputAvailabilityCriteria
+@Inject
+constructor(
+    private val mediaOutputInteractor: MediaOutputInteractor,
+    private val audioModeInteractor: AudioModeInteractor,
+) : ComponentAvailabilityCriteria {
+
+    override fun isAvailable(): Flow<Boolean> {
+        return combine(mediaOutputInteractor.mediaDevices, audioModeInteractor.isOngoingCall) {
+            devices,
+            isOngoingCall ->
+            !isOngoingCall && devices.isNotEmpty()
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
new file mode 100644
index 0000000..170b32c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.domain.interactor
+
+import android.content.Intent
+import android.provider.Settings
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.systemui.animation.DialogCuj
+import com.android.systemui.animation.DialogTransitionAnimator
+import com.android.systemui.animation.Expandable
+import com.android.systemui.media.dialog.MediaOutputDialogFactory
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
+import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
+import javax.inject.Inject
+
+/** User actions interactor for Media Output Volume Panel component. */
+@VolumePanelScope
+class MediaOutputActionsInteractor
+@Inject
+constructor(
+    private val mediaOutputDialogFactory: MediaOutputDialogFactory,
+    private val activityStarter: ActivityStarter,
+) {
+
+    fun onDeviceClick(expandable: Expandable) {
+        activityStarter.startActivity(
+            Intent(Settings.ACTION_BLUETOOTH_SETTINGS),
+            true,
+            expandable.activityTransitionController(),
+        )
+    }
+
+    fun onBarClick(session: MediaDeviceSession, expandable: Expandable) {
+        when (session) {
+            is MediaDeviceSession.Active -> {
+                mediaOutputDialogFactory.createWithController(
+                    session.packageName,
+                    false,
+                    expandable.dialogController()
+                )
+            }
+            is MediaDeviceSession.Inactive -> {
+                mediaOutputDialogFactory.createDialogForSystemRouting(expandable.dialogController())
+            }
+            else -> {
+                /* do nothing */
+            }
+        }
+    }
+
+    private fun Expandable.dialogController(): DialogTransitionAnimator.Controller? {
+        return dialogTransitionController(
+            cuj =
+                DialogCuj(
+                    InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
+                    MediaOutputDialogFactory.INTERACTION_JANK_TAG
+                )
+        )
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
index 6c456f9..7126b23 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
@@ -17,10 +17,14 @@
 package com.android.systemui.volume.panel.component.mediaoutput.domain.interactor
 
 import android.content.pm.PackageManager
+import android.media.session.MediaController
+import android.os.Handler
 import android.util.Log
 import com.android.settingslib.media.MediaDevice
 import com.android.settingslib.volume.data.repository.LocalMediaRepository
+import com.android.settingslib.volume.data.repository.MediaControllerChange
 import com.android.settingslib.volume.data.repository.MediaControllerRepository
+import com.android.settingslib.volume.data.repository.stateChanges
 import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
 import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
@@ -30,14 +34,20 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.withContext
 
+/** Provides observable models about the current media session state. */
 @OptIn(ExperimentalCoroutinesApi::class)
 @VolumePanelScope
 class MediaOutputInteractor
@@ -47,32 +57,43 @@
     private val packageManager: PackageManager,
     @VolumePanelScope private val coroutineScope: CoroutineScope,
     @Background private val backgroundCoroutineContext: CoroutineContext,
+    @Background private val backgroundHandler: Handler,
     mediaControllerRepository: MediaControllerRepository
 ) {
 
-    val mediaDeviceSession: Flow<MediaDeviceSession> =
-        mediaControllerRepository.activeMediaController.mapNotNull { mediaController ->
-            if (mediaController == null) {
-                MediaDeviceSession.Inactive
-            } else {
+    /** Current [MediaDeviceSession]. Emits when the session playback changes. */
+    val mediaDeviceSession: StateFlow<MediaDeviceSession> =
+        mediaControllerRepository.activeLocalMediaController
+            .flatMapLatest { it?.mediaDeviceSession() ?: flowOf(MediaDeviceSession.Inactive) }
+            .flowOn(backgroundCoroutineContext)
+            .stateIn(coroutineScope, SharingStarted.Eagerly, MediaDeviceSession.Inactive)
+
+    private fun MediaController.mediaDeviceSession(): Flow<MediaDeviceSession> {
+        return stateChanges(backgroundHandler)
+            .filter { it is MediaControllerChange.PlaybackStateChanged }
+            .map {
                 MediaDeviceSession.Active(
-                    appLabel = getApplicationLabel(mediaController.packageName)
-                            ?: return@mapNotNull null,
-                    packageName = mediaController.packageName,
-                    sessionToken = mediaController.sessionToken,
+                    appLabel = getApplicationLabel(packageName)
+                            ?: return@map MediaDeviceSession.Inactive,
+                    packageName = packageName,
+                    sessionToken = sessionToken,
+                    playbackState = playbackState,
                 )
             }
-        }
-    private val localMediaRepository: Flow<LocalMediaRepository> =
+    }
+
+    private val localMediaRepository: SharedFlow<LocalMediaRepository> =
         mediaDeviceSession
             .map { (it as? MediaDeviceSession.Active)?.packageName }
             .distinctUntilChanged()
             .map { localMediaRepositoryFactory.create(it) }
-            .shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 1)
+            .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
 
+    /** Currently connected [MediaDevice]. */
     val currentConnectedDevice: Flow<MediaDevice?> =
         localMediaRepository.flatMapLatest { it.currentConnectedDevice }
 
+    /** A list of available [MediaDevice]s. */
     val mediaDevices: Flow<Collection<MediaDevice>> =
         localMediaRepository.flatMapLatest { it.mediaDevices }
 
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
index f250308..71df8e5 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
@@ -17,6 +17,7 @@
 package com.android.systemui.volume.panel.component.mediaoutput.domain.model
 
 import android.media.session.MediaSession
+import android.media.session.PlaybackState
 
 /** Represents media playing on the connected device. */
 sealed interface MediaDeviceSession {
@@ -26,6 +27,7 @@
         val appLabel: CharSequence,
         val packageName: String,
         val sessionToken: MediaSession.Token,
+        val playbackState: PlaybackState?,
     ) : MediaDeviceSession
 
     /** Media is not playing. */
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/MediaKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/MediaKosmos.kt
new file mode 100644
index 0000000..e1b1966
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/MediaKosmos.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.media.dialog.MediaOutputDialogFactory
+import com.android.systemui.util.mockito.mock
+
+var Kosmos.mediaOutputDialogFactory: MediaOutputDialogFactory by Kosmos.Fixture { mock {} }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
new file mode 100644
index 0000000..3f20df3
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume
+
+import android.content.packageManager
+import android.content.pm.ApplicationInfo
+import android.media.session.MediaController
+import android.os.Handler
+import android.testing.TestableLooper
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.media.mediaOutputDialogFactory
+import com.android.systemui.plugins.activityStarter
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.data.repository.FakeLocalMediaRepository
+import com.android.systemui.volume.data.repository.FakeMediaControllerRepository
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.FakeLocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputActionsInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
+
+var Kosmos.mediaController: MediaController by Kosmos.Fixture { mock {} }
+
+val Kosmos.localMediaRepository by Kosmos.Fixture { FakeLocalMediaRepository() }
+val Kosmos.localMediaRepositoryFactory: LocalMediaRepositoryFactory by
+    Kosmos.Fixture { FakeLocalMediaRepositoryFactory { localMediaRepository } }
+
+val Kosmos.mediaOutputActionsInteractor by
+    Kosmos.Fixture { MediaOutputActionsInteractor(mediaOutputDialogFactory, activityStarter) }
+val Kosmos.mediaControllerRepository by Kosmos.Fixture { FakeMediaControllerRepository() }
+val Kosmos.mediaOutputInteractor by
+    Kosmos.Fixture {
+        MediaOutputInteractor(
+            localMediaRepositoryFactory,
+            packageManager.apply {
+                val appInfo: ApplicationInfo = mock {
+                    whenever(loadLabel(any())).thenReturn("test_label")
+                }
+                whenever(getApplicationInfo(any(), any<Int>())).thenReturn(appInfo)
+            },
+            testScope.backgroundScope,
+            testScope.testScheduler,
+            Handler(TestableLooper.get(testCase).looper),
+            mediaControllerRepository,
+        )
+    }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/VolumeKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/VolumeKosmos.kt
new file mode 100644
index 0000000..5e1f85c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/VolumeKosmos.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume
+
+import com.android.settingslib.volume.domain.interactor.AudioModeInteractor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.volume.data.repository.FakeAudioRepository
+
+val Kosmos.audioRepository by Kosmos.Fixture { FakeAudioRepository() }
+val Kosmos.audioModeInteractor by Kosmos.Fixture { AudioModeInteractor(audioRepository) }
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeAudioRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioRepository.kt
similarity index 96%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeAudioRepository.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioRepository.kt
index dddf8e82..fed3e17 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeAudioRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioRepository.kt
@@ -14,9 +14,10 @@
  * limitations under the License.
  */
 
-package com.android.settingslib.volume.data.repository
+package com.android.systemui.volume.data.repository
 
 import android.media.AudioDeviceInfo
+import com.android.settingslib.volume.data.repository.AudioRepository
 import com.android.settingslib.volume.shared.model.AudioStream
 import com.android.settingslib.volume.shared.model.AudioStreamModel
 import com.android.settingslib.volume.shared.model.RingerMode
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeLocalMediaRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
similarity index 71%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeLocalMediaRepository.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
index 642b72c..7835fc8 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeLocalMediaRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
@@ -1,23 +1,24 @@
 /*
  * Copyright (C) 2024 The Android Open Source Project
  *
- *  Licensed under the Apache License, Version 2.0 (the "License");
- *  you may not use this file except in compliance with the License.
- *  You may obtain a copy of the License at
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
  *
- *       http://www.apache.org/licenses/LICENSE-2.0
+ *      http://www.apache.org/licenses/LICENSE-2.0
  *
- *  Unless required by applicable law or agreed to in writing, software
- *  distributed under the License is distributed on an "AS IS" BASIS,
- *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- *  See the License for the specific language governing permissions and
- *  limitations under the License.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
  */
 
-package com.android.settingslib.volume.data.repository
+package com.android.systemui.volume.data.repository
 
 import com.android.settingslib.media.MediaDevice
 import com.android.settingslib.volume.data.model.RoutingSession
+import com.android.settingslib.volume.data.repository.LocalMediaRepository
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
new file mode 100644
index 0000000..6d52e52
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.data.repository
+
+import android.media.session.MediaController
+import com.android.settingslib.volume.data.repository.MediaControllerRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakeMediaControllerRepository : MediaControllerRepository {
+
+    private val mutableActiveLocalMediaController = MutableStateFlow<MediaController?>(null)
+    override val activeLocalMediaController: StateFlow<MediaController?> =
+        mutableActiveLocalMediaController.asStateFlow()
+
+    fun setActiveLocalMediaController(controller: MediaController?) {
+        mutableActiveLocalMediaController.value = controller
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/FakeLocalMediaRepositoryFactory.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/FakeLocalMediaRepositoryFactory.kt
new file mode 100644
index 0000000..1b3480c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/FakeLocalMediaRepositoryFactory.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.panel.component.mediaoutput.data.repository
+
+import com.android.settingslib.volume.data.repository.LocalMediaRepository
+
+class FakeLocalMediaRepositoryFactory(
+    val provider: (packageName: String?) -> LocalMediaRepository
+) : LocalMediaRepositoryFactory {
+
+    override fun create(packageName: String?): LocalMediaRepository = provider(packageName)
+}