Loading packages/SystemUI/multivalentTests/src/com/android/systemui/media/remedia/data/repository/MediaRepositoryTest.kt +21 −67 Original line number Diff line number Diff line Loading @@ -17,8 +17,8 @@ package com.android.systemui.media.remedia.data.repository import android.content.packageManager import android.media.session.MediaController import android.media.session.MediaSession import androidx.test.annotation.UiThreadTest import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.InstanceId Loading @@ -26,10 +26,8 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.common.shared.model.Icon import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope import com.android.systemui.lifecycle.activateIn import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.remedia.data.model.MediaDataModel import com.android.systemui.media.remedia.shared.model.MediaColorScheme import com.android.systemui.res.R import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat Loading @@ -37,7 +35,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyString Loading @@ -46,6 +43,7 @@ import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) @UiThreadTest class MediaRepositoryTest : SysuiTestCase() { private val drawable = context.getDrawable(R.drawable.ic_music_note)!! Loading @@ -59,11 +57,6 @@ class MediaRepositoryTest : SysuiTestCase() { private val underTest: MediaRepositoryImpl = kosmos.mediaRepository @Before fun setUp() { underTest.activateIn(testScope) } @Test fun addCurrentUserMediaEntry_activeThenInactivate() = testScope.runTest { Loading Loading @@ -158,14 +151,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), remoteData.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData.toDataModel(underTest.currentMedia[0]), remoteData.toDataModel(underTest.currentMedia[1]), ) .inOrder() } Loading @@ -184,14 +171,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData1.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingData2.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData1.toDataModel(underTest.currentMedia[0]), playingData2.toDataModel(underTest.currentMedia[1]), ) .inOrder() Loading @@ -204,14 +185,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData1.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingData2.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData1.toDataModel(underTest.currentMedia[0]), playingData2.toDataModel(underTest.currentMedia[1]), ) .inOrder() Loading @@ -221,14 +196,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData2.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingData1.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData2.toDataModel(underTest.currentMedia[0]), playingData1.toDataModel(underTest.currentMedia[1]), ) .inOrder() } Loading Loading @@ -263,26 +232,11 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(5) assertThat(underTest.currentMedia) .containsExactly( playingAndLocalData.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingAndRemoteData.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), stoppedAndRemoteData.toDataModel( underTest.currentMedia[2].controller, underTest.currentMedia[2].colorScheme, ), stoppedAndLocalData.toDataModel( underTest.currentMedia[3].controller, underTest.currentMedia[3].colorScheme, ), canResumeData.toDataModel( underTest.currentMedia[4].controller, underTest.currentMedia[4].colorScheme, ), playingAndLocalData.toDataModel(underTest.currentMedia[0]), playingAndRemoteData.toDataModel(underTest.currentMedia[1]), stoppedAndRemoteData.toDataModel(underTest.currentMedia[2]), stoppedAndLocalData.toDataModel(underTest.currentMedia[3]), canResumeData.toDataModel(underTest.currentMedia[4]), ) .inOrder() } Loading Loading @@ -310,10 +264,7 @@ class MediaRepositoryTest : SysuiTestCase() { ) } private fun MediaData.toDataModel( mediaController: MediaController, colorScheme: MediaColorScheme?, ): MediaDataModel { private fun MediaData.toDataModel(mediaModel: MediaDataModel): MediaDataModel { return MediaDataModel( instanceId = instanceId, appUid = appUid, Loading @@ -323,12 +274,15 @@ class MediaRepositoryTest : SysuiTestCase() { background = null, title = song.toString(), subtitle = artist.toString(), colorScheme = colorScheme, colorScheme = mediaModel.colorScheme, notificationActions = actions, playbackStateActions = semanticActions, outputDevice = device, clickIntent = clickIntent, controller = mediaController, state = mediaModel.state, durationMs = mediaModel.durationMs, positionMs = mediaModel.positionMs, canBeScrubbed = mediaModel.canBeScrubbed, canBeDismissed = isClearable, isActive = active, isResume = resumption, Loading packages/SystemUI/src/com/android/systemui/media/remedia/data/model/MediaDataModel.kt +5 −2 Original line number Diff line number Diff line Loading @@ -17,13 +17,13 @@ package com.android.systemui.media.remedia.data.model import android.app.PendingIntent import android.media.session.MediaController import com.android.internal.logging.InstanceId import com.android.systemui.common.shared.model.Icon import com.android.systemui.media.controls.shared.model.MediaButton import com.android.systemui.media.controls.shared.model.MediaDeviceData import com.android.systemui.media.controls.shared.model.MediaNotificationAction import com.android.systemui.media.remedia.shared.model.MediaColorScheme import com.android.systemui.media.remedia.shared.model.MediaSessionState /** Data model representing a media data. */ data class MediaDataModel( Loading @@ -50,7 +50,10 @@ data class MediaDataModel( val outputDevice: MediaDeviceData?, /** Action to perform when the media player is tapped. */ val clickIntent: PendingIntent?, val controller: MediaController, val state: MediaSessionState, val durationMs: Long, val positionMs: Long, val canBeScrubbed: Boolean, val canBeDismissed: Boolean, /** * An active player represents a current media session that has not timed out or been swiped Loading packages/SystemUI/src/com/android/systemui/media/remedia/data/repository/MediaRepository.kt +194 −35 Original line number Diff line number Diff line Loading @@ -20,9 +20,12 @@ import android.app.WallpaperColors import android.content.Context import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.media.MediaMetadata import android.media.session.MediaController import android.media.session.PlaybackState import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.Color import com.android.internal.logging.InstanceId import com.android.systemui.common.shared.model.ContentDescription Loading @@ -30,12 +33,12 @@ import com.android.systemui.common.shared.model.Icon import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.lifecycle.Activatable import com.android.systemui.lifecycle.Hydrator import com.android.systemui.media.NotificationMediaManager import com.android.systemui.media.controls.data.model.MediaSortKeyModel import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.remedia.data.model.MediaDataModel import com.android.systemui.media.remedia.shared.model.MediaColorScheme import com.android.systemui.media.remedia.shared.model.MediaSessionState import com.android.systemui.monet.ColorScheme import com.android.systemui.monet.Style import com.android.systemui.res.R Loading @@ -44,12 +47,14 @@ import java.util.TreeMap import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** A repository that holds the state of current media on the device. */ interface MediaRepository : Activatable { interface MediaRepository { /** Current sorted media sessions. */ val currentMedia: List<MediaDataModel> Loading @@ -70,14 +75,16 @@ constructor( private val systemClock: SystemClock, ) : MediaRepository, MediaPipelineRepository() { private val hydrator = Hydrator(traceName = "MediaRepository.hydrator") private val mutableCurrentMedia: MutableStateFlow<List<MediaDataModel>> = MutableStateFlow(mutableListOf()) override val currentMedia by hydrator.hydratedStateOf(traceName = "currentMedia", source = mutableCurrentMedia) override val currentMedia: SnapshotStateList<MediaDataModel> = mutableStateListOf() private var sortedMedia = TreeMap<MediaSortKeyModel, MediaDataModel>(comparator) // To store active controllers and their callbacks private val activeControllers = mutableMapOf<InstanceId, MediaController>() private val mediaCallbacks = mutableMapOf<InstanceId, MediaController.Callback>() // To store active polling jobs private val positionPollers = mutableMapOf<InstanceId, Job>() override fun addCurrentUserMediaEntry(data: MediaData): Boolean { return super.addCurrentUserMediaEntry(data).also { addToSortedMedia(data) } } Loading @@ -101,19 +108,19 @@ constructor( } override fun seek(sessionKey: InstanceId, to: Long) { mutableCurrentMedia.value .first { sessionKey == it.instanceId } .controller .transportControls .seekTo(to) activeControllers[sessionKey]?.let { controller -> controller.transportControls.seekTo(to) currentMedia .find { it.instanceId == sessionKey } ?.let { latestModel -> updateMediaModelInState(latestModel) { it.copy(positionMs = to) } } } override fun reorderMedia() { mutableCurrentMedia.value = sortedMedia.values.toList() } override suspend fun activate(): Nothing { hydrator.activate() override fun reorderMedia() { currentMedia.clear() currentMedia.addAll(sortedMedia.values.toList()) } private fun addToSortedMedia(data: MediaData) { Loading @@ -139,12 +146,23 @@ constructor( ) applicationScope.launch { val mediaModel = toDataModel(currentModel) val controller = if ( currentModel != null && activeControllers[currentModel.instanceId]?.sessionToken == token ) { activeControllers[currentModel.instanceId] } else { // Clear controller state if changed for the same media session. currentModel?.instanceId?.let { clearControllerState(it) } token?.let { MediaController(applicationContext, it) } } val mediaModel = toDataModel(controller) sortedMap[sortKey] = mediaModel controller?.let { setupController(mediaModel, it) } var isNewToCurrentMedia = true val currentList = mutableListOf<MediaDataModel>().apply { addAll(mutableCurrentMedia.value) } val currentList = mutableListOf<MediaDataModel>().apply { addAll(currentMedia) } currentList.forEachIndexed { index, mediaDataModel -> if (mediaDataModel.instanceId == data.instanceId) { // When loading an update for an existing media control. Loading @@ -155,10 +173,11 @@ constructor( } } } currentMedia.clear() if (isNewToCurrentMedia && active) { mutableCurrentMedia.value = sortedMap.values.toList() currentMedia.addAll(sortedMap.values.toList()) } else { mutableCurrentMedia.value = currentList currentMedia.addAll(currentList) } sortedMedia = sortedMap Loading @@ -168,20 +187,21 @@ constructor( } private fun removeFromSortedMedia(data: MediaData) { mutableCurrentMedia.value = mutableCurrentMedia.value.filter { model -> data.instanceId != model.instanceId } currentMedia.removeIf { model -> data.instanceId == model.instanceId } sortedMedia = TreeMap(sortedMedia.filter { (keyModel, _) -> keyModel.instanceId != data.instanceId }) clearControllerState(data.instanceId) } private suspend fun MediaData.toDataModel(currentModel: MediaDataModel?): MediaDataModel { private suspend fun MediaData.toDataModel(controller: MediaController?): MediaDataModel { return withContext(backgroundDispatcher) { val controller = if (currentModel != null && currentModel.controller.sessionToken == token) { currentModel.controller } else { MediaController(applicationContext, token!!) } val metadata = controller?.metadata val currentPlaybackState = controller?.playbackState val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L val position = currentPlaybackState?.position ?: 0L val state = currentPlaybackState?.state ?: PlaybackState.STATE_NONE val icon = appIcon?.loadDrawable(applicationContext) val background = artwork?.loadDrawable(applicationContext) MediaDataModel( Loading @@ -200,7 +220,16 @@ constructor( playbackStateActions = semanticActions, outputDevice = device, clickIntent = clickIntent, controller = controller, state = when { NotificationMediaManager.isPlayingState(state) -> MediaSessionState.Playing NotificationMediaManager.isConnectingState(state) -> MediaSessionState.Buffering else -> MediaSessionState.Paused }, durationMs = duration, positionMs = position, canBeScrubbed = state != PlaybackState.STATE_NONE && duration > 0L, canBeDismissed = isClearable, isActive = active, isResume = resumption, Loading Loading @@ -287,7 +316,137 @@ constructor( } } private fun setupController(dataModel: MediaDataModel, controller: MediaController) { activeControllers[dataModel.instanceId] = controller val callback = object : MediaController.Callback() { override fun onPlaybackStateChanged(state: PlaybackState?) { if (state == null || PlaybackState.STATE_NONE.equals(state)) { clearControllerState(dataModel.instanceId) } else { updatePollingState(dataModel.instanceId, state) } } override fun onMetadataChanged(metadata: MediaMetadata?) { val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L updateMediaModelInState(dataModel) { model -> val canBeScrubbed = controller.playbackState?.state != PlaybackState.STATE_NONE && duration > 0L model.copy(canBeScrubbed = canBeScrubbed, durationMs = duration) } } override fun onSessionDestroyed() { clearControllerState(dataModel.instanceId) } } controller.registerCallback(callback) mediaCallbacks[dataModel.instanceId] = callback // Initial polling setup. controller.playbackState?.let { updatePollingState(dataModel.instanceId, it, requireUpdate = false) } } private fun updatePollingState( instanceId: InstanceId, playbackState: PlaybackState, requireUpdate: Boolean = true, ) { val controller = activeControllers[instanceId] ?: return val isInMotion = NotificationMediaManager.isPlayingState(playbackState.state) if (isInMotion) { if (positionPollers[instanceId]?.isActive != true) { // Cancel previous if any. positionPollers[instanceId]?.cancel() positionPollers[instanceId] = applicationScope.launch(backgroundDispatcher) { while (isActive) { val currentController = activeControllers[instanceId] val latestPlaybackState = currentController?.playbackState checkPlaybackPosition(instanceId, latestPlaybackState) delay(POSITION_UPDATE_INTERVAL_MILLIS) } positionPollers.remove(instanceId) } } } else if (requireUpdate) { positionPollers[instanceId]?.cancel() positionPollers.remove(instanceId) checkPlaybackPosition(instanceId, controller.playbackState) } } private fun PlaybackState.computeActualPosition(mediaDurationMs: Long): Long { var currentPosition = position if (NotificationMediaManager.isPlayingState(state)) { val currentTime = systemClock.elapsedRealtime() if (lastPositionUpdateTime > 0) { var estimatedPosition = (playbackSpeed * (currentTime - lastPositionUpdateTime)).toLong() + position if (mediaDurationMs in 0..<estimatedPosition) { estimatedPosition = mediaDurationMs } else if (estimatedPosition < 0) { estimatedPosition = 0 } currentPosition = estimatedPosition } } return currentPosition } private fun checkPlaybackPosition(instanceId: InstanceId, playbackState: PlaybackState?) { currentMedia .find { it.instanceId == instanceId } ?.let { latestModel -> val newPosition = playbackState?.computeActualPosition(latestModel.durationMs) updateMediaModelInState(latestModel) { if (newPosition != null && newPosition <= latestModel.durationMs) { it.copy(positionMs = newPosition) } else { it } } } } private fun clearControllerState(instanceId: InstanceId) { positionPollers[instanceId]?.cancel() positionPollers.remove(instanceId) mediaCallbacks[instanceId]?.let { activeControllers[instanceId]?.unregisterCallback(it) } activeControllers.remove(instanceId) mediaCallbacks.remove(instanceId) } private fun updateMediaModelInState( oldModel: MediaDataModel, updateBlock: (MediaDataModel) -> MediaDataModel, ) { val newModel = updateBlock(oldModel) if (oldModel != newModel) { sortedMedia.keys .find { it.instanceId == newModel.instanceId } ?.let { val sortedMap = TreeMap<MediaSortKeyModel, MediaDataModel>(comparator) sortedMap.putAll( sortedMedia.filter { (keyModel, _) -> keyModel.instanceId != newModel.instanceId } ) sortedMap[it] = newModel sortedMedia = sortedMap } currentMedia[currentMedia.indexOf(oldModel)] = newModel } } companion object { private const val TAG = "MediaRepository" private const val POSITION_UPDATE_INTERVAL_MILLIS = 500L } } packages/SystemUI/src/com/android/systemui/media/remedia/domain/interactor/MediaInteractor.kt +5 −10 Original line number Diff line number Diff line Loading @@ -27,7 +27,6 @@ import com.android.systemui.common.shared.model.ContentDescription import com.android.systemui.common.shared.model.Icon import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.lifecycle.ExclusiveActivatable import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor import com.android.systemui.media.controls.domain.pipeline.getNotificationActions import com.android.systemui.media.controls.shared.model.MediaAction Loading Loading @@ -70,7 +69,7 @@ constructor( val repository: MediaRepository, val mediaDataProcessor: MediaDataProcessor, private val activityStarter: ActivityStarter, ) : MediaInteractor, ExclusiveActivatable() { ) : MediaInteractor { override val sessions: List<MediaSessionModel> get() = repository.currentMedia.map { toMediaSessionModel(it) } Loading Loading @@ -123,16 +122,16 @@ constructor( get() = dataModel.canBeDismissed override val canBeScrubbed: Boolean get() = TODO("Not yet implemented") get() = dataModel.canBeScrubbed override val state: MediaSessionState get() = TODO("Not yet implemented") get() = dataModel.state override val positionMs: Long get() = TODO("Not yet implemented") get() = dataModel.positionMs override val durationMs: Long get() = TODO("Not yet implemented") get() = dataModel.durationMs override val outputDevice: MediaOutputDeviceModel get() = Loading Loading @@ -198,10 +197,6 @@ constructor( } } override suspend fun onActivated(): Nothing { repository.activate() } private fun MediaAction.getMediaActionModel(): MediaActionModel { return icon?.let { drawable -> MediaActionModel.Action( Loading Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/media/remedia/data/repository/MediaRepositoryTest.kt +21 −67 Original line number Diff line number Diff line Loading @@ -17,8 +17,8 @@ package com.android.systemui.media.remedia.data.repository import android.content.packageManager import android.media.session.MediaController import android.media.session.MediaSession import androidx.test.annotation.UiThreadTest import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.InstanceId Loading @@ -26,10 +26,8 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.common.shared.model.Icon import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope import com.android.systemui.lifecycle.activateIn import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.remedia.data.model.MediaDataModel import com.android.systemui.media.remedia.shared.model.MediaColorScheme import com.android.systemui.res.R import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat Loading @@ -37,7 +35,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyString Loading @@ -46,6 +43,7 @@ import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) @UiThreadTest class MediaRepositoryTest : SysuiTestCase() { private val drawable = context.getDrawable(R.drawable.ic_music_note)!! Loading @@ -59,11 +57,6 @@ class MediaRepositoryTest : SysuiTestCase() { private val underTest: MediaRepositoryImpl = kosmos.mediaRepository @Before fun setUp() { underTest.activateIn(testScope) } @Test fun addCurrentUserMediaEntry_activeThenInactivate() = testScope.runTest { Loading Loading @@ -158,14 +151,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), remoteData.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData.toDataModel(underTest.currentMedia[0]), remoteData.toDataModel(underTest.currentMedia[1]), ) .inOrder() } Loading @@ -184,14 +171,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData1.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingData2.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData1.toDataModel(underTest.currentMedia[0]), playingData2.toDataModel(underTest.currentMedia[1]), ) .inOrder() Loading @@ -204,14 +185,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData1.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingData2.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData1.toDataModel(underTest.currentMedia[0]), playingData2.toDataModel(underTest.currentMedia[1]), ) .inOrder() Loading @@ -221,14 +196,8 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(2) assertThat(underTest.currentMedia) .containsExactly( playingData2.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingData1.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), playingData2.toDataModel(underTest.currentMedia[0]), playingData1.toDataModel(underTest.currentMedia[1]), ) .inOrder() } Loading Loading @@ -263,26 +232,11 @@ class MediaRepositoryTest : SysuiTestCase() { assertThat(underTest.currentMedia.size).isEqualTo(5) assertThat(underTest.currentMedia) .containsExactly( playingAndLocalData.toDataModel( underTest.currentMedia[0].controller, underTest.currentMedia[0].colorScheme, ), playingAndRemoteData.toDataModel( underTest.currentMedia[1].controller, underTest.currentMedia[1].colorScheme, ), stoppedAndRemoteData.toDataModel( underTest.currentMedia[2].controller, underTest.currentMedia[2].colorScheme, ), stoppedAndLocalData.toDataModel( underTest.currentMedia[3].controller, underTest.currentMedia[3].colorScheme, ), canResumeData.toDataModel( underTest.currentMedia[4].controller, underTest.currentMedia[4].colorScheme, ), playingAndLocalData.toDataModel(underTest.currentMedia[0]), playingAndRemoteData.toDataModel(underTest.currentMedia[1]), stoppedAndRemoteData.toDataModel(underTest.currentMedia[2]), stoppedAndLocalData.toDataModel(underTest.currentMedia[3]), canResumeData.toDataModel(underTest.currentMedia[4]), ) .inOrder() } Loading Loading @@ -310,10 +264,7 @@ class MediaRepositoryTest : SysuiTestCase() { ) } private fun MediaData.toDataModel( mediaController: MediaController, colorScheme: MediaColorScheme?, ): MediaDataModel { private fun MediaData.toDataModel(mediaModel: MediaDataModel): MediaDataModel { return MediaDataModel( instanceId = instanceId, appUid = appUid, Loading @@ -323,12 +274,15 @@ class MediaRepositoryTest : SysuiTestCase() { background = null, title = song.toString(), subtitle = artist.toString(), colorScheme = colorScheme, colorScheme = mediaModel.colorScheme, notificationActions = actions, playbackStateActions = semanticActions, outputDevice = device, clickIntent = clickIntent, controller = mediaController, state = mediaModel.state, durationMs = mediaModel.durationMs, positionMs = mediaModel.positionMs, canBeScrubbed = mediaModel.canBeScrubbed, canBeDismissed = isClearable, isActive = active, isResume = resumption, Loading
packages/SystemUI/src/com/android/systemui/media/remedia/data/model/MediaDataModel.kt +5 −2 Original line number Diff line number Diff line Loading @@ -17,13 +17,13 @@ package com.android.systemui.media.remedia.data.model import android.app.PendingIntent import android.media.session.MediaController import com.android.internal.logging.InstanceId import com.android.systemui.common.shared.model.Icon import com.android.systemui.media.controls.shared.model.MediaButton import com.android.systemui.media.controls.shared.model.MediaDeviceData import com.android.systemui.media.controls.shared.model.MediaNotificationAction import com.android.systemui.media.remedia.shared.model.MediaColorScheme import com.android.systemui.media.remedia.shared.model.MediaSessionState /** Data model representing a media data. */ data class MediaDataModel( Loading @@ -50,7 +50,10 @@ data class MediaDataModel( val outputDevice: MediaDeviceData?, /** Action to perform when the media player is tapped. */ val clickIntent: PendingIntent?, val controller: MediaController, val state: MediaSessionState, val durationMs: Long, val positionMs: Long, val canBeScrubbed: Boolean, val canBeDismissed: Boolean, /** * An active player represents a current media session that has not timed out or been swiped Loading
packages/SystemUI/src/com/android/systemui/media/remedia/data/repository/MediaRepository.kt +194 −35 Original line number Diff line number Diff line Loading @@ -20,9 +20,12 @@ import android.app.WallpaperColors import android.content.Context import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.media.MediaMetadata import android.media.session.MediaController import android.media.session.PlaybackState import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.Color import com.android.internal.logging.InstanceId import com.android.systemui.common.shared.model.ContentDescription Loading @@ -30,12 +33,12 @@ import com.android.systemui.common.shared.model.Icon import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.lifecycle.Activatable import com.android.systemui.lifecycle.Hydrator import com.android.systemui.media.NotificationMediaManager import com.android.systemui.media.controls.data.model.MediaSortKeyModel import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.remedia.data.model.MediaDataModel import com.android.systemui.media.remedia.shared.model.MediaColorScheme import com.android.systemui.media.remedia.shared.model.MediaSessionState import com.android.systemui.monet.ColorScheme import com.android.systemui.monet.Style import com.android.systemui.res.R Loading @@ -44,12 +47,14 @@ import java.util.TreeMap import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** A repository that holds the state of current media on the device. */ interface MediaRepository : Activatable { interface MediaRepository { /** Current sorted media sessions. */ val currentMedia: List<MediaDataModel> Loading @@ -70,14 +75,16 @@ constructor( private val systemClock: SystemClock, ) : MediaRepository, MediaPipelineRepository() { private val hydrator = Hydrator(traceName = "MediaRepository.hydrator") private val mutableCurrentMedia: MutableStateFlow<List<MediaDataModel>> = MutableStateFlow(mutableListOf()) override val currentMedia by hydrator.hydratedStateOf(traceName = "currentMedia", source = mutableCurrentMedia) override val currentMedia: SnapshotStateList<MediaDataModel> = mutableStateListOf() private var sortedMedia = TreeMap<MediaSortKeyModel, MediaDataModel>(comparator) // To store active controllers and their callbacks private val activeControllers = mutableMapOf<InstanceId, MediaController>() private val mediaCallbacks = mutableMapOf<InstanceId, MediaController.Callback>() // To store active polling jobs private val positionPollers = mutableMapOf<InstanceId, Job>() override fun addCurrentUserMediaEntry(data: MediaData): Boolean { return super.addCurrentUserMediaEntry(data).also { addToSortedMedia(data) } } Loading @@ -101,19 +108,19 @@ constructor( } override fun seek(sessionKey: InstanceId, to: Long) { mutableCurrentMedia.value .first { sessionKey == it.instanceId } .controller .transportControls .seekTo(to) activeControllers[sessionKey]?.let { controller -> controller.transportControls.seekTo(to) currentMedia .find { it.instanceId == sessionKey } ?.let { latestModel -> updateMediaModelInState(latestModel) { it.copy(positionMs = to) } } } override fun reorderMedia() { mutableCurrentMedia.value = sortedMedia.values.toList() } override suspend fun activate(): Nothing { hydrator.activate() override fun reorderMedia() { currentMedia.clear() currentMedia.addAll(sortedMedia.values.toList()) } private fun addToSortedMedia(data: MediaData) { Loading @@ -139,12 +146,23 @@ constructor( ) applicationScope.launch { val mediaModel = toDataModel(currentModel) val controller = if ( currentModel != null && activeControllers[currentModel.instanceId]?.sessionToken == token ) { activeControllers[currentModel.instanceId] } else { // Clear controller state if changed for the same media session. currentModel?.instanceId?.let { clearControllerState(it) } token?.let { MediaController(applicationContext, it) } } val mediaModel = toDataModel(controller) sortedMap[sortKey] = mediaModel controller?.let { setupController(mediaModel, it) } var isNewToCurrentMedia = true val currentList = mutableListOf<MediaDataModel>().apply { addAll(mutableCurrentMedia.value) } val currentList = mutableListOf<MediaDataModel>().apply { addAll(currentMedia) } currentList.forEachIndexed { index, mediaDataModel -> if (mediaDataModel.instanceId == data.instanceId) { // When loading an update for an existing media control. Loading @@ -155,10 +173,11 @@ constructor( } } } currentMedia.clear() if (isNewToCurrentMedia && active) { mutableCurrentMedia.value = sortedMap.values.toList() currentMedia.addAll(sortedMap.values.toList()) } else { mutableCurrentMedia.value = currentList currentMedia.addAll(currentList) } sortedMedia = sortedMap Loading @@ -168,20 +187,21 @@ constructor( } private fun removeFromSortedMedia(data: MediaData) { mutableCurrentMedia.value = mutableCurrentMedia.value.filter { model -> data.instanceId != model.instanceId } currentMedia.removeIf { model -> data.instanceId == model.instanceId } sortedMedia = TreeMap(sortedMedia.filter { (keyModel, _) -> keyModel.instanceId != data.instanceId }) clearControllerState(data.instanceId) } private suspend fun MediaData.toDataModel(currentModel: MediaDataModel?): MediaDataModel { private suspend fun MediaData.toDataModel(controller: MediaController?): MediaDataModel { return withContext(backgroundDispatcher) { val controller = if (currentModel != null && currentModel.controller.sessionToken == token) { currentModel.controller } else { MediaController(applicationContext, token!!) } val metadata = controller?.metadata val currentPlaybackState = controller?.playbackState val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L val position = currentPlaybackState?.position ?: 0L val state = currentPlaybackState?.state ?: PlaybackState.STATE_NONE val icon = appIcon?.loadDrawable(applicationContext) val background = artwork?.loadDrawable(applicationContext) MediaDataModel( Loading @@ -200,7 +220,16 @@ constructor( playbackStateActions = semanticActions, outputDevice = device, clickIntent = clickIntent, controller = controller, state = when { NotificationMediaManager.isPlayingState(state) -> MediaSessionState.Playing NotificationMediaManager.isConnectingState(state) -> MediaSessionState.Buffering else -> MediaSessionState.Paused }, durationMs = duration, positionMs = position, canBeScrubbed = state != PlaybackState.STATE_NONE && duration > 0L, canBeDismissed = isClearable, isActive = active, isResume = resumption, Loading Loading @@ -287,7 +316,137 @@ constructor( } } private fun setupController(dataModel: MediaDataModel, controller: MediaController) { activeControllers[dataModel.instanceId] = controller val callback = object : MediaController.Callback() { override fun onPlaybackStateChanged(state: PlaybackState?) { if (state == null || PlaybackState.STATE_NONE.equals(state)) { clearControllerState(dataModel.instanceId) } else { updatePollingState(dataModel.instanceId, state) } } override fun onMetadataChanged(metadata: MediaMetadata?) { val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L updateMediaModelInState(dataModel) { model -> val canBeScrubbed = controller.playbackState?.state != PlaybackState.STATE_NONE && duration > 0L model.copy(canBeScrubbed = canBeScrubbed, durationMs = duration) } } override fun onSessionDestroyed() { clearControllerState(dataModel.instanceId) } } controller.registerCallback(callback) mediaCallbacks[dataModel.instanceId] = callback // Initial polling setup. controller.playbackState?.let { updatePollingState(dataModel.instanceId, it, requireUpdate = false) } } private fun updatePollingState( instanceId: InstanceId, playbackState: PlaybackState, requireUpdate: Boolean = true, ) { val controller = activeControllers[instanceId] ?: return val isInMotion = NotificationMediaManager.isPlayingState(playbackState.state) if (isInMotion) { if (positionPollers[instanceId]?.isActive != true) { // Cancel previous if any. positionPollers[instanceId]?.cancel() positionPollers[instanceId] = applicationScope.launch(backgroundDispatcher) { while (isActive) { val currentController = activeControllers[instanceId] val latestPlaybackState = currentController?.playbackState checkPlaybackPosition(instanceId, latestPlaybackState) delay(POSITION_UPDATE_INTERVAL_MILLIS) } positionPollers.remove(instanceId) } } } else if (requireUpdate) { positionPollers[instanceId]?.cancel() positionPollers.remove(instanceId) checkPlaybackPosition(instanceId, controller.playbackState) } } private fun PlaybackState.computeActualPosition(mediaDurationMs: Long): Long { var currentPosition = position if (NotificationMediaManager.isPlayingState(state)) { val currentTime = systemClock.elapsedRealtime() if (lastPositionUpdateTime > 0) { var estimatedPosition = (playbackSpeed * (currentTime - lastPositionUpdateTime)).toLong() + position if (mediaDurationMs in 0..<estimatedPosition) { estimatedPosition = mediaDurationMs } else if (estimatedPosition < 0) { estimatedPosition = 0 } currentPosition = estimatedPosition } } return currentPosition } private fun checkPlaybackPosition(instanceId: InstanceId, playbackState: PlaybackState?) { currentMedia .find { it.instanceId == instanceId } ?.let { latestModel -> val newPosition = playbackState?.computeActualPosition(latestModel.durationMs) updateMediaModelInState(latestModel) { if (newPosition != null && newPosition <= latestModel.durationMs) { it.copy(positionMs = newPosition) } else { it } } } } private fun clearControllerState(instanceId: InstanceId) { positionPollers[instanceId]?.cancel() positionPollers.remove(instanceId) mediaCallbacks[instanceId]?.let { activeControllers[instanceId]?.unregisterCallback(it) } activeControllers.remove(instanceId) mediaCallbacks.remove(instanceId) } private fun updateMediaModelInState( oldModel: MediaDataModel, updateBlock: (MediaDataModel) -> MediaDataModel, ) { val newModel = updateBlock(oldModel) if (oldModel != newModel) { sortedMedia.keys .find { it.instanceId == newModel.instanceId } ?.let { val sortedMap = TreeMap<MediaSortKeyModel, MediaDataModel>(comparator) sortedMap.putAll( sortedMedia.filter { (keyModel, _) -> keyModel.instanceId != newModel.instanceId } ) sortedMap[it] = newModel sortedMedia = sortedMap } currentMedia[currentMedia.indexOf(oldModel)] = newModel } } companion object { private const val TAG = "MediaRepository" private const val POSITION_UPDATE_INTERVAL_MILLIS = 500L } }
packages/SystemUI/src/com/android/systemui/media/remedia/domain/interactor/MediaInteractor.kt +5 −10 Original line number Diff line number Diff line Loading @@ -27,7 +27,6 @@ import com.android.systemui.common.shared.model.ContentDescription import com.android.systemui.common.shared.model.Icon import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.lifecycle.ExclusiveActivatable import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor import com.android.systemui.media.controls.domain.pipeline.getNotificationActions import com.android.systemui.media.controls.shared.model.MediaAction Loading Loading @@ -70,7 +69,7 @@ constructor( val repository: MediaRepository, val mediaDataProcessor: MediaDataProcessor, private val activityStarter: ActivityStarter, ) : MediaInteractor, ExclusiveActivatable() { ) : MediaInteractor { override val sessions: List<MediaSessionModel> get() = repository.currentMedia.map { toMediaSessionModel(it) } Loading Loading @@ -123,16 +122,16 @@ constructor( get() = dataModel.canBeDismissed override val canBeScrubbed: Boolean get() = TODO("Not yet implemented") get() = dataModel.canBeScrubbed override val state: MediaSessionState get() = TODO("Not yet implemented") get() = dataModel.state override val positionMs: Long get() = TODO("Not yet implemented") get() = dataModel.positionMs override val durationMs: Long get() = TODO("Not yet implemented") get() = dataModel.durationMs override val outputDevice: MediaOutputDeviceModel get() = Loading Loading @@ -198,10 +197,6 @@ constructor( } } override suspend fun onActivated(): Nothing { repository.activate() } private fun MediaAction.getMediaActionModel(): MediaActionModel { return icon?.let { drawable -> MediaActionModel.Action( Loading