Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 642eae6b authored by Matt Pietal's avatar Matt Pietal
Browse files

Media - Add player sorting

Use various criteria to maintain a sorting order of media
players. Leverage TreeMap to maintain order upon player add/update.

Bug: 161002989
Bug: 160242133
Test: manual, using various player types
Change-Id: I07d0219523289fc8c5950d078bd0960bbdb1cc37
parent 4676898b
Loading
Loading
Loading
Loading
+77 −54
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.VisibleForTesting
import com.android.systemui.R
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.plugins.ActivityStarter
@@ -21,6 +22,7 @@ import com.android.systemui.util.Utils
import com.android.systemui.util.animation.UniqueObjectHostView
import com.android.systemui.util.animation.requiresRemeasuring
import com.android.systemui.util.concurrency.DelayableExecutor
import java.util.TreeMap
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@@ -102,9 +104,7 @@ class MediaCarouselController @Inject constructor(
    private val mediaCarousel: MediaScrollView
    private val mediaCarouselScrollHandler: MediaCarouselScrollHandler
    val mediaFrame: ViewGroup
    val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
    private lateinit var settingsButton: View
    private val mediaData: MutableMap<String, MediaData> = mutableMapOf()
    private val mediaContent: ViewGroup
    private val pageIndicator: PageIndicator
    private val visualStabilityCallback: VisualStabilityManager.Callback
@@ -122,7 +122,7 @@ class MediaCarouselController @Inject constructor(
        set(value) {
            if (field != value) {
                field = value
                for (player in mediaPlayers.values) {
                for (player in MediaPlayerData.players()) {
                    player.setListening(field)
                }
            }
@@ -167,20 +167,18 @@ class MediaCarouselController @Inject constructor(
                true /* persistent */)
        mediaManager.addListener(object : MediaDataManager.Listener {
            override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
                oldKey?.let { mediaData.remove(it) }
                if (!data.active && !Utils.useMediaResumption(context)) {
                    // This view is inactive, let's remove this! This happens e.g when dismissing /
                    // timing out a view. We still have the data around because resumption could
                    // be on, but we should save the resources and release this.
                    oldKey?.let { MediaPlayerData.removeMediaPlayer(it) }
                    onMediaDataRemoved(key)
                } else {
                    mediaData.put(key, data)
                    addOrUpdatePlayer(key, oldKey, data)
                }
            }

            override fun onMediaDataRemoved(key: String) {
                mediaData.remove(key)
                removePlayer(key)
            }
        })
@@ -223,67 +221,50 @@ class MediaCarouselController @Inject constructor(
    }

    private fun reorderAllPlayers() {
        for (mediaPlayer in mediaPlayers.values) {
            val view = mediaPlayer.view?.player
            if (mediaPlayer.isPlaying && mediaContent.indexOfChild(view) != 0) {
                mediaContent.removeView(view)
                mediaContent.addView(view, 0)
        mediaContent.removeAllViews()
        for (mediaPlayer in MediaPlayerData.players()) {
            mediaPlayer.view?.let {
                mediaContent.addView(it.player)
            }
        }
        mediaCarouselScrollHandler.onPlayersChanged()
    }

    private fun addOrUpdatePlayer(key: String, oldKey: String?, data: MediaData) {
        // If the key was changed, update entry
        val oldData = mediaPlayers[oldKey]
        if (oldData != null) {
            val oldData = mediaPlayers.remove(oldKey)
            mediaPlayers.put(key, oldData!!)?.let {
                Log.wtf(TAG, "new key $key already exists when migrating from $oldKey")
            }
        }
        var existingPlayer = mediaPlayers[key]
        val existingPlayer = MediaPlayerData.getMediaPlayer(key, oldKey)
        if (existingPlayer == null) {
            existingPlayer = mediaControlPanelFactory.get()
            existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context),
                    mediaContent))
            existingPlayer.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
            mediaPlayers[key] = existingPlayer
            var newPlayer = mediaControlPanelFactory.get()
            newPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context), mediaContent))
            newPlayer.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
            MediaPlayerData.addMediaPlayer(key, data, newPlayer)
            val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT)
            existingPlayer.view?.player?.setLayoutParams(lp)
            existingPlayer.bind(data)
            existingPlayer.setListening(currentlyExpanded)
            updatePlayerToState(existingPlayer, noAnimation = true)
            if (existingPlayer.isPlaying) {
                mediaContent.addView(existingPlayer.view?.player, 0)
            } else {
                mediaContent.addView(existingPlayer.view?.player)
            }
            newPlayer.view?.player?.setLayoutParams(lp)
            newPlayer.bind(data)
            newPlayer.setListening(currentlyExpanded)
            updatePlayerToState(newPlayer, noAnimation = true)
            reorderAllPlayers()
        } else {
            existingPlayer.bind(data)
            if (existingPlayer.isPlaying &&
                    mediaContent.indexOfChild(existingPlayer.view?.player) != 0) {
            MediaPlayerData.addMediaPlayer(key, data, existingPlayer)
            if (visualStabilityManager.isReorderingAllowed) {
                    mediaContent.removeView(existingPlayer.view?.player)
                    mediaContent.addView(existingPlayer.view?.player, 0)
                reorderAllPlayers()
            } else {
                needsReordering = true
            }
        }
        }
        updatePageIndicator()
        mediaCarouselScrollHandler.onPlayersChanged()
        mediaCarousel.requiresRemeasuring = true
        // Check postcondition: mediaContent should have the same number of children as there are
        // elements in mediaPlayers.
        if (mediaPlayers.size != mediaContent.childCount) {
        if (MediaPlayerData.players().size != mediaContent.childCount) {
            Log.wtf(TAG, "Size of players list and number of views in carousel are out of sync")
        }
    }

    private fun removePlayer(key: String) {
        val removed = mediaPlayers.remove(key)
        val removed = MediaPlayerData.removeMediaPlayer(key)
        removed?.apply {
            mediaCarouselScrollHandler.onPrePlayerRemoved(removed)
            mediaContent.removeView(removed.view?.player)
@@ -294,12 +275,7 @@ class MediaCarouselController @Inject constructor(
    }

    private fun recreatePlayers() {
        // Note that this will scramble the order of players. Actively playing sessions will, at
        // least, still be put in the front. If we want to maintain order, then more work is
        // needed.
        mediaData.forEach {
            key, data ->
            removePlayer(key)
        MediaPlayerData.mediaData().forEach { (key, data) ->
            addOrUpdatePlayer(key = key, oldKey = null, data = data)
        }
    }
@@ -337,7 +313,7 @@ class MediaCarouselController @Inject constructor(
            currentStartLocation = startLocation
            currentEndLocation = endLocation
            currentTransitionProgress = progress
            for (mediaPlayer in mediaPlayers.values) {
            for (mediaPlayer in MediaPlayerData.players()) {
                updatePlayerToState(mediaPlayer, immediately)
            }
            maybeResetSettingsCog()
@@ -386,7 +362,7 @@ class MediaCarouselController @Inject constructor(
    private fun updateCarouselDimensions() {
        var width = 0
        var height = 0
        for (mediaPlayer in mediaPlayers.values) {
        for (mediaPlayer in MediaPlayerData.players()) {
            val controller = mediaPlayer.mediaViewController
            // When transitioning the view to gone, the view gets smaller, but the translation
            // Doesn't, let's add the translation
@@ -448,7 +424,7 @@ class MediaCarouselController @Inject constructor(
            this.desiredLocation = desiredLocation
            this.desiredHostState = it
            currentlyExpanded = it.expansion > 0
            for (mediaPlayer in mediaPlayers.values) {
            for (mediaPlayer in MediaPlayerData.players()) {
                if (animate) {
                    mediaPlayer.mediaViewController.animatePendingStateChange(
                            duration = duration,
@@ -470,7 +446,7 @@ class MediaCarouselController @Inject constructor(
    }

    fun closeGuts() {
        mediaPlayers.values.forEach {
        MediaPlayerData.players().forEach {
            it.closeGuts(true)
        }
    }
@@ -497,3 +473,50 @@ class MediaCarouselController @Inject constructor(
        }
    }
}

@VisibleForTesting
internal object MediaPlayerData {
    private data class MediaSortKey(
        val data: MediaData,
        val updateTime: Long = 0,
        val isPlaying: Boolean = false
    )

    private val comparator =
        compareByDescending<MediaSortKey> { it.isPlaying }
        .thenByDescending { it.data.isLocalSession }
        .thenByDescending { !it.data.resumption }
        .thenByDescending { it.updateTime }

    private val mediaPlayers = TreeMap<MediaSortKey, MediaControlPanel>(comparator)
    private val mediaData: MutableMap<String, MediaSortKey> = mutableMapOf()

    fun addMediaPlayer(key: String, data: MediaData, player: MediaControlPanel) {
        removeMediaPlayer(key)
        val sortKey = MediaSortKey(data, System.currentTimeMillis(), player.isPlaying())
        mediaData.put(key, sortKey)
        mediaPlayers.put(sortKey, player)
    }

    fun getMediaPlayer(key: String, oldKey: String?): MediaControlPanel? {
        // If the key was changed, update entry
        oldKey?.let {
            if (it != key) {
                mediaData.remove(it)?.let { sortKey -> mediaData.put(key, sortKey) }
            }
        }
        return mediaData.get(key)?.let { mediaPlayers.get(it) }
    }

    fun removeMediaPlayer(key: String) = mediaData.remove(key)?.let { mediaPlayers.remove(it) }

    fun mediaData() = mediaData.entries.map { e -> Pair(e.key, e.value.data) }

    fun players() = mediaPlayers.values

    @VisibleForTesting
    fun clear() {
        mediaData.clear()
        mediaPlayers.clear()
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -81,6 +81,10 @@ data class MediaData(
     * Action that should be performed to restart a non active session.
     */
    var resumeAction: Runnable?,
    /**
     * Local or remote playback
     */
    var isLocalSession: Boolean = true,
    /**
     * Indicates that this player is a resumption player (ie. It only shows a play actions which
     * will start the app and start playing).
+8 −3
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
import android.media.MediaDescription
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.net.Uri
import android.os.UserHandle
@@ -328,7 +329,8 @@ class MediaDataManager(
    ) {
        val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
                as MediaSession.Token?
        val metadata = mediaControllerFactory.create(token).metadata
        val mediaController = mediaControllerFactory.create(token)
        val metadata = mediaController.metadata

        // Foreground and Background colors computed from album art
        val notif: Notification = sbn.notification
@@ -420,6 +422,9 @@ class MediaDataManager(
            }
        }

        val isLocalSession = mediaController.playbackInfo?.playbackType ==
            MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL ?: true

        foregroundExecutor.execute {
            val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
            val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
@@ -427,8 +432,8 @@ class MediaDataManager(
            onMediaDataLoaded(key, oldKey, MediaData(sbn.normalizedUserId, true, bgColor, app,
                    smallIconDrawable, artist, song, artWorkIcon, actionIcons,
                    actionsToShowCollapsed, sbn.packageName, token, notif.contentIntent, null,
                    active, resumeAction = resumeAction, notificationKey = key,
                    hasCheckedForResume = hasCheckedForResume))
                    active, resumeAction = resumeAction, isLocalSession = isLocalSession,
                    notificationKey = key, hasCheckedForResume = hasCheckedForResume))
        }
    }

+2 −2
Original line number Diff line number Diff line
@@ -83,8 +83,8 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
        mManager.addListener(mListener);

        mMediaData = new MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null,
                new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null, true, null, false,
                KEY, false);
                new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null, true, null, true,
                false, KEY, false);
        mDeviceData = new MediaDeviceData(true, null, DEVICE_NAME);
    }

+122 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 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 android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when` as whenever

@SmallTest
@RunWith(AndroidTestingRunner::class)
public class MediaPlayerDataTest : SysuiTestCase() {

    companion object {
        val LOCAL = true
        val RESUMPTION = true
    }

    @Before
    fun setup() {
        MediaPlayerData.clear()
    }

    @Test
    fun addPlayingThenRemote() {
        val playerIsPlaying = mock(MediaControlPanel::class.java)
        whenever(playerIsPlaying.isPlaying).thenReturn(true)
        val dataIsPlaying = createMediaData(LOCAL, !RESUMPTION)

        val playerIsRemote = mock(MediaControlPanel::class.java)
        whenever(playerIsRemote.isPlaying).thenReturn(false)
        val dataIsRemote = createMediaData(!LOCAL, !RESUMPTION)

        MediaPlayerData.addMediaPlayer("1", dataIsPlaying, playerIsPlaying)
        MediaPlayerData.addMediaPlayer("2", dataIsRemote, playerIsRemote)

        val players = MediaPlayerData.players()
        assertThat(players).hasSize(2)
        assertThat(players).containsExactly(playerIsPlaying, playerIsRemote).inOrder()
    }

    @Test
    fun switchPlayersPlaying() {
        val playerIsPlaying1 = mock(MediaControlPanel::class.java)
        whenever(playerIsPlaying1.isPlaying).thenReturn(true)
        val dataIsPlaying1 = createMediaData(LOCAL, !RESUMPTION)

        val playerIsPlaying2 = mock(MediaControlPanel::class.java)
        whenever(playerIsPlaying2.isPlaying).thenReturn(false)
        val dataIsPlaying2 = createMediaData(LOCAL, !RESUMPTION)

        MediaPlayerData.addMediaPlayer("1", dataIsPlaying1, playerIsPlaying1)
        MediaPlayerData.addMediaPlayer("2", dataIsPlaying2, playerIsPlaying2)

        whenever(playerIsPlaying1.isPlaying).thenReturn(false)
        whenever(playerIsPlaying2.isPlaying).thenReturn(true)

        MediaPlayerData.addMediaPlayer("1", dataIsPlaying1, playerIsPlaying1)
        MediaPlayerData.addMediaPlayer("2", dataIsPlaying2, playerIsPlaying2)

        val players = MediaPlayerData.players()
        assertThat(players).hasSize(2)
        assertThat(players).containsExactly(playerIsPlaying2, playerIsPlaying1).inOrder()
    }

    @Test
    fun fullOrderTest() {
        val playerIsPlaying = mock(MediaControlPanel::class.java)
        whenever(playerIsPlaying.isPlaying).thenReturn(true)
        val dataIsPlaying = createMediaData(LOCAL, !RESUMPTION)

        val playerIsPlayingAndRemote = mock(MediaControlPanel::class.java)
        whenever(playerIsPlayingAndRemote.isPlaying).thenReturn(true)
        val dataIsPlayingAndRemote = createMediaData(!LOCAL, !RESUMPTION)

        val playerIsStoppedAndLocal = mock(MediaControlPanel::class.java)
        whenever(playerIsStoppedAndLocal.isPlaying).thenReturn(false)
        val dataIsStoppedAndLocal = createMediaData(LOCAL, !RESUMPTION)

        val playerIsStoppedAndRemote = mock(MediaControlPanel::class.java)
        whenever(playerIsStoppedAndLocal.isPlaying).thenReturn(false)
        val dataIsStoppedAndRemote = createMediaData(!LOCAL, !RESUMPTION)

        val playerCanResume = mock(MediaControlPanel::class.java)
        whenever(playerCanResume.isPlaying).thenReturn(false)
        val dataCanResume = createMediaData(LOCAL, RESUMPTION)

        MediaPlayerData.addMediaPlayer("3", dataIsStoppedAndLocal, playerIsStoppedAndLocal)
        MediaPlayerData.addMediaPlayer("5", dataIsStoppedAndRemote, playerIsStoppedAndRemote)
        MediaPlayerData.addMediaPlayer("4", dataCanResume, playerCanResume)
        MediaPlayerData.addMediaPlayer("1", dataIsPlaying, playerIsPlaying)
        MediaPlayerData.addMediaPlayer("2", dataIsPlayingAndRemote, playerIsPlayingAndRemote)

        val players = MediaPlayerData.players()
        assertThat(players).hasSize(5)
        assertThat(players).containsExactly(playerIsPlaying, playerIsPlayingAndRemote,
            playerIsStoppedAndLocal, playerCanResume, playerIsStoppedAndRemote).inOrder()
    }

    private fun createMediaData(isLocalSession: Boolean, resumption: Boolean) =
        MediaData(0, false, 0, null, null, null, null, null, emptyList(), emptyList<Int>(), "",
            null, null, null, true, null, isLocalSession, resumption, null, false)
}