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


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


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


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


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


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


    fun closeGuts() {
    fun closeGuts() {
        mediaPlayers.values.forEach {
        MediaPlayerData.players().forEach {
            it.closeGuts(true)
            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 Original line Diff line number Diff line
@@ -81,6 +81,10 @@ data class MediaData(
     * Action that should be performed to restart a non active session.
     * Action that should be performed to restart a non active session.
     */
     */
    var resumeAction: Runnable?,
    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
     * Indicates that this player is a resumption player (ie. It only shows a play actions which
     * will start the app and start playing).
     * will start the app and start playing).
+8 −3
Original line number Original line Diff line number Diff line
@@ -31,6 +31,7 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
import android.graphics.drawable.Icon
import android.media.MediaDescription
import android.media.MediaDescription
import android.media.MediaMetadata
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.MediaSession
import android.net.Uri
import android.net.Uri
import android.os.UserHandle
import android.os.UserHandle
@@ -328,7 +329,8 @@ class MediaDataManager(
    ) {
    ) {
        val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
        val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
                as MediaSession.Token?
                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
        // Foreground and Background colors computed from album art
        val notif: Notification = sbn.notification
        val notif: Notification = sbn.notification
@@ -420,6 +422,9 @@ class MediaDataManager(
            }
            }
        }
        }


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

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


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


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


+122 −0
Original line number Original line 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)
}