Refactored the Media Player management

Previously all media instances had their own management
and it could easily happen that certain players
would get out of sync as a result. We now have
a unified architecture to listen to media notifications
and inflating a singular UI

Test: atest SystemUiTests
Bug: 154137987
Change-Id: I9f807e6431dd7cb54ca9b6d983379d770a281f31
diff --git a/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt b/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt
new file mode 100644
index 0000000..f60c012
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.view.View
+import com.android.internal.util.ContrastColorUtil
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.notification.stack.MediaHeaderView
+import com.android.systemui.statusbar.phone.KeyguardBypassController
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * A class that controls the media notifications on the lock screen, handles its visibility and
+ * is responsible for the embedding of he media experience.
+ */
+@Singleton
+class KeyguardMediaController @Inject constructor(
+    private val mediaHierarchyManager: MediaHierarchyManager,
+    private val notifMediaManager: NotificationMediaManager,
+    private val bypassController: KeyguardBypassController
+) {
+    private var view: MediaHeaderView? = null
+
+    init {
+        notifMediaManager.addCallback(object : NotificationMediaManager.MediaListener {
+            override fun onMediaDataLoaded(key: String, data: MediaData) {
+                updateVisibility()
+            }
+
+            override fun onMediaDataRemoved(key: String) {
+                updateVisibility()
+            }
+        })
+    }
+
+    /**
+     * Attach this controller to a media view, initializing its state
+     */
+    fun attach(mediaControlsView: MediaHeaderView) {
+        view = mediaControlsView
+        val hostView = mediaHierarchyManager.createMediaHost(
+                MediaHierarchyManager.LOCATION_LOCKSCREEN)
+        mediaControlsView.setMediaHost(hostView)
+        updateVisibility()
+    }
+
+    private fun updateVisibility() {
+        val shouldBeVisible = notifMediaManager.hasActiveMedia() && !bypassController.bypassEnabled
+        view?.visibility = if (shouldBeVisible) View.VISIBLE else View.GONE
+    }
+}
+
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index 9adfe7d..0297c90 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -16,6 +16,8 @@
 
 package com.android.systemui.media;
 
+import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle;
+
 import android.annotation.LayoutRes;
 import android.app.PendingIntent;
 import android.content.ComponentName;
@@ -28,9 +30,9 @@
 import android.content.res.ColorStateList;
 import android.graphics.Bitmap;
 import android.graphics.ImageDecoder;
+import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.GradientDrawable;
-import android.graphics.drawable.Icon;
 import android.graphics.drawable.RippleDrawable;
 import android.media.MediaDescription;
 import android.media.MediaMetadata;
@@ -50,10 +52,12 @@
 import android.widget.ImageButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
+import android.widget.SeekBar;
 import android.widget.TextView;
 
 import androidx.annotation.Nullable;
 import androidx.constraintlayout.motion.widget.MotionLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
 import androidx.core.graphics.drawable.RoundedBitmapDrawable;
 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
 
@@ -65,6 +69,9 @@
 import com.android.systemui.plugins.ActivityStarter;
 import com.android.systemui.qs.QSMediaBrowser;
 import com.android.systemui.util.Assert;
+import com.android.systemui.util.concurrency.DelayableExecutor;
+
+import org.jetbrains.annotations.NotNull;
 
 import java.io.IOException;
 import java.util.List;
@@ -76,6 +83,18 @@
 public class MediaControlPanel {
     private static final String TAG = "MediaControlPanel";
     @Nullable private final LocalMediaManager mLocalMediaManager;
+
+    // Button IDs for QS controls
+    static final int[] ACTION_IDS = {
+            R.id.action0,
+            R.id.action1,
+            R.id.action2,
+            R.id.action3,
+            R.id.action4
+    };
+
+    private final SeekBarViewModel mSeekBarViewModel;
+    private final SeekBarObserver mSeekBarObserver;
     private final Executor mForegroundExecutor;
     protected final Executor mBackgroundExecutor;
     private final ActivityStarter mActivityStarter;
@@ -92,8 +111,6 @@
     private boolean mIsRegistered = false;
     private String mKey;
 
-    private final int[] mActionIds;
-
     public static final String MEDIA_PREFERENCES = "media_control_prefs";
     public static final String MEDIA_PREFERENCE_KEY = "browser_components";
     private SharedPreferences mSharedPrefs;
@@ -101,15 +118,6 @@
     private boolean mIsRemotePlayback;
     private QSMediaBrowser mQSMediaBrowser;
 
-    // Button IDs used in notifications
-    protected static final int[] NOTIF_ACTION_IDS = {
-            com.android.internal.R.id.action0,
-            com.android.internal.R.id.action1,
-            com.android.internal.R.id.action2,
-            com.android.internal.R.id.action3,
-            com.android.internal.R.id.action4
-    };
-
     // URI fields to try loading album art from
     private static final String[] ART_URIS = {
             MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
@@ -183,12 +191,11 @@
      * @param activityStarter activity starter
      */
     public MediaControlPanel(Context context, ViewGroup parent,
-            @Nullable LocalMediaManager routeManager, @LayoutRes int layoutId, int[] actionIds,
-            Executor foregroundExecutor, Executor backgroundExecutor,
-            ActivityStarter activityStarter) {
+            @Nullable LocalMediaManager routeManager, Executor foregroundExecutor,
+            DelayableExecutor backgroundExecutor, ActivityStarter activityStarter) {
         mContext = context;
         LayoutInflater inflater = LayoutInflater.from(mContext);
-        mMediaNotifView = (MotionLayout) inflater.inflate(layoutId, parent, false);
+        mMediaNotifView = (MotionLayout) inflater.inflate(R.layout.qs_media_panel, parent, false);
         // TODO(b/150854549): removeOnAttachStateChangeListener when this doesn't inflate views
         // mStateListener shouldn't need to be unregistered since this object shares the same
         // lifecycle with the inflated view. It would be better, however, if this controller used an
@@ -196,21 +203,42 @@
         // mStateListener to be unregistered in detach.
         mMediaNotifView.addOnAttachStateChangeListener(mStateListener);
         mLocalMediaManager = routeManager;
-        mActionIds = actionIds;
         mForegroundExecutor = foregroundExecutor;
         mBackgroundExecutor = backgroundExecutor;
         mActivityStarter = activityStarter;
+        mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
+        mSeekBarObserver = new SeekBarObserver(getView());
+        // Can't use the viewAttachLifecycle of media player because remove/add is used to adjust
+        // priority of players. As soon as it is removed, the lifecycle will end and the seek bar
+        // will stop updating. So, use the lifecycle of the parent instead.
+        // TODO: this parent is also detached, need to fix that
+        mSeekBarViewModel.getProgress().observe(viewAttachLifecycle(parent), mSeekBarObserver);
+        SeekBar bar = getView().findViewById(R.id.media_progress_bar);
+        bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener());
+        bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener());
     }
 
     /**
      * Get the view used to display media controls
      * @return the view
      */
-    public View getView() {
+    public MotionLayout getView() {
         return mMediaNotifView;
     }
 
     /**
+     * Sets the listening state of the player.
+     *
+     * Should be set to true when the QS panel is open. Otherwise, false. This is a signal to avoid
+     * unnecessary work when the QS panel is closed.
+     *
+     * @param listening True when player should be active. Otherwise, false.
+     */
+    public void setListening(boolean listening) {
+        mSeekBarViewModel.setListening(listening);
+    }
+
+    /**
      * Get the context
      * @return context
      */
@@ -219,20 +247,12 @@
     }
 
     /**
-     * Update the media panel view for the given media session
-     * @param token
-     * @param iconDrawable
-     * @param largeIcon
-     * @param iconColor
-     * @param bgColor
-     * @param contentIntent
-     * @param appNameString
-     * @param key
+     * Bind this view based on the data given
      */
-    public void setMediaSession(MediaSession.Token token, Drawable iconDrawable, Icon largeIcon,
-            int iconColor, int bgColor, PendingIntent contentIntent, String appNameString,
-            String key) {
-        // Ensure that component names are updated if token has changed
+    public void bind(@NotNull MediaData data) {
+        mToken = data.getToken();
+        mForegroundColor = data.getForegroundColor();
+        mBackgroundColor = data.getBackgroundColor();
         if (mToken == null || !mToken.equals(token)) {
             if (mQSMediaBrowser != null) {
                 Log.d(TAG, "Disconnecting old media browser");
@@ -244,8 +264,6 @@
             mCheckedForResumption = false;
         }
 
-        mForegroundColor = iconColor;
-        mBackgroundColor = bgColor;
         mController = new MediaController(mContext, mToken);
         mKey = key;
 
@@ -258,6 +276,7 @@
             PackageManager pm = mContext.getPackageManager();
             Intent resumeIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE);
             List<ResolveInfo> resumeInfo = pm.queryIntentServices(resumeIntent, 0);
+            // TODO: look into this resumption
             if (resumeInfo != null) {
                 for (ResolveInfo inf : resumeInfo) {
                     if (inf.serviceInfo.packageName.equals(mController.getPackageName())) {
@@ -272,20 +291,33 @@
 
         mController.registerCallback(mSessionCallback);
 
+        albumView.setImageDrawable(data.getArtwork());
+
         mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor));
 
         // Click action
-        if (contentIntent != null) {
+        PendingIntent clickIntent = data.getClickIntent();
+        if (clickIntent != null) {
             mMediaNotifView.setOnClickListener(v -> {
-                mActivityStarter.postStartActivityDismissingKeyguard(contentIntent);
+                mActivityStarter.postStartActivityDismissingKeyguard(clickIntent);
             });
         }
 
         // App icon
-        ImageView appIcon = mMediaNotifView.findViewById(R.id.icon);
+        ImageView appIcon = mMediaNotifView.requireViewById(R.id.icon);
+        // TODO: look at iconDrawable
+        Drawable iconDrawable = data.getAppIcon();
         iconDrawable.setTint(mForegroundColor);
         appIcon.setImageDrawable(iconDrawable);
 
+        TextView titleText = mMediaNotifView.requireViewById(R.id.header_title);
+        titleText.setText(data.getSong());
+        TextView appName = mMediaNotifView.requireViewById(R.id.app_name);
+        appName.setText(data.getApp());
+        appName.setTextColor(mForegroundColor);
+        TextView artistText = mMediaNotifView.requireViewById(R.id.header_artist);
+        artistText.setText(data.getArtist());
+        artistText.setTextColor(mForegroundColor);
         // Transfer chip
         mSeamless = mMediaNotifView.findViewById(R.id.media_seamless);
         if (mSeamless != null) {
@@ -313,6 +345,54 @@
             mIsRemotePlayback = false;
         }
 
+        ConstraintSet expandedSet = mMediaNotifView.getConstraintSet(R.id.expanded);
+        ConstraintSet collapsedSet = mMediaNotifView.getConstraintSet(R.id.collapsed);
+        List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
+        // Media controls
+        int i = 0;
+        List<MediaAction> actionIcons = data.getActions();
+        for (; i < actionIcons.size() && i < ACTION_IDS.length; i++) {
+            final ImageButton button = mMediaNotifView.findViewById(ACTION_IDS[i]);
+            MediaAction mediaAction = actionIcons.get(i);
+            button.setImageDrawable(mediaAction.getDrawable());
+            button.setContentDescription(mediaAction.getContentDescription());
+            button.setImageTintList(ColorStateList.valueOf(mForegroundColor));
+            PendingIntent actionIntent = mediaAction.getIntent();
+
+            if (mMediaNotifView.getBackground() instanceof IlluminationDrawable) {
+                ((IlluminationDrawable) mMediaNotifView.getBackground())
+                        .setupTouch(button, mMediaNotifView);
+            }
+
+            button.setOnClickListener(v -> {
+                if (actionIntent != null) {
+                    try {
+                        actionIntent.send();
+                    } catch (PendingIntent.CanceledException e) {
+                        e.printStackTrace();
+                    }
+                }
+            });
+            boolean visibleInCompat = actionsWhenCollapsed.contains(i);
+            collapsedSet.setVisibility(ACTION_IDS[i],
+                    visibleInCompat ? ConstraintSet.VISIBLE : ConstraintSet.GONE);
+            expandedSet.setVisibility(ACTION_IDS[i], ConstraintSet.VISIBLE);
+        }
+
+        // Hide any unused buttons
+        for (; i < ACTION_IDS.length; i++) {
+            expandedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
+            collapsedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
+        }
+
+        // Seek Bar
+        final MediaController controller = new MediaController(getContext(), data.getToken());
+        mBackgroundExecutor.execute(
+                () -> mSeekBarViewModel.updateController(controller, data.getForegroundColor()));
+
+        // Set up long press menu
+        // TODO: b/156036025 bring back media guts
+
         makeActive();
 
         // App title (not in mini player)
@@ -614,15 +694,16 @@
      */
     protected void resetButtons() {
         // Hide all the old buttons
-        for (int i = 0; i < mActionIds.length; i++) {
-            ImageButton thisBtn = mMediaNotifView.findViewById(mActionIds[i]);
-            if (thisBtn != null) {
-                thisBtn.setVisibility(View.GONE);
-            }
+
+        ConstraintSet expandedSet = mMediaNotifView.getConstraintSet(R.id.expanded);
+        ConstraintSet collapsedSet = mMediaNotifView.getConstraintSet(R.id.collapsed);
+        for (int i = 1; i < ACTION_IDS.length; i++) {
+            expandedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
+            collapsedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
         }
 
         // Add a restart button
-        ImageButton btn = mMediaNotifView.findViewById(mActionIds[0]);
+        ImageButton btn = mMediaNotifView.findViewById(ACTION_IDS[0]);
         btn.setOnClickListener(v -> {
             Log.d(TAG, "Attempting to restart session");
             if (mQSMediaBrowser != null) {
@@ -644,7 +725,20 @@
         });
         btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play));
         btn.setImageTintList(ColorStateList.valueOf(mForegroundColor));
-        btn.setVisibility(View.VISIBLE);
+        expandedSet.setVisibility(ACTION_IDS[0], ConstraintSet.VISIBLE);
+        collapsedSet.setVisibility(ACTION_IDS[0], ConstraintSet.VISIBLE);
+
+        mSeekBarViewModel.clearController();
+        // TODO: fix guts
+        //        View guts = mMediaNotifView.findViewById(R.id.media_guts);
+        View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options);
+
+        mMediaNotifView.setOnLongClickListener(v -> {
+            // Replace player view with close/cancel view
+//            guts.setVisibility(View.GONE);
+            options.setVisibility(View.VISIBLE);
+            return true; // consumed click
+        });
     }
 
     private void makeActive() {
@@ -668,7 +762,6 @@
             mIsRegistered = false;
         }
     }
-
     /**
      * Verify that we can connect to the given component with a MediaBrowser, and if so, add that
      * component to the list of resumption components
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
new file mode 100644
index 0000000..8165362
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.app.PendingIntent
+import android.graphics.drawable.Drawable
+import android.media.session.MediaSession
+
+/** State of a media view. */
+data class MediaData(
+    val initialized: Boolean = false,
+    val foregroundColor: Int,
+    val backgroundColor: Int,
+    val app: String?,
+    val appIcon: Drawable?,
+    val artist: String?,
+    val song: String?,
+    val artwork: Drawable?,
+    val actions: List<MediaAction>,
+    val actionsToShowInCompact: List<Int>,
+    val packageName: String?,
+    val token: MediaSession.Token?,
+    val clickIntent: PendingIntent?
+)
+
+/** State of a media action. */
+data class MediaAction(
+    val drawable: Drawable?,
+    val intent: PendingIntent?,
+    val contentDescription: CharSequence?
+)
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
new file mode 100644
index 0000000..298a5fd
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
@@ -0,0 +1,206 @@
+/*
+ * 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.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.media.MediaMetadata
+import android.media.session.MediaSession
+import android.provider.Settings
+import android.service.notification.StatusBarNotification
+import androidx.core.graphics.drawable.RoundedBitmapDrawable
+import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
+import com.android.internal.util.ContrastColorUtil
+import com.android.systemui.R
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.notification.MediaNotificationProcessor
+import java.util.*
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.collections.LinkedHashMap
+
+/**
+ * A class that facilitates management and loading of Media Data, ready for binding.
+ */
+@Singleton
+class MediaDataManager @Inject constructor(
+    private val context: Context,
+    private val mediaControllerFactory: MediaControllerFactory,
+    @Background private val backgroundExecutor: Executor,
+    @Main private val foregroundExcecutor: Executor
+) {
+
+    lateinit var listener: NotificationMediaManager.MediaListener
+    private var albumArtSize: Int = 0
+    private var albumArtRadius: Int = 0
+    private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
+
+    init {
+        loadDimens()
+    }
+
+    private fun loadDimens() {
+        albumArtRadius = context.resources.getDimensionPixelSize(R.dimen.qs_media_corner_radius)
+        albumArtSize = context.resources.getDimensionPixelSize(R.dimen.qs_media_album_size)
+    }
+
+    fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
+        if (isMediaNotification(sbn)) {
+            if (!mediaEntries.containsKey(key)) {
+                mediaEntries.put(key, LOADING)
+            }
+            loadMediaData(key, sbn)
+        } else {
+            onNotificationRemoved(key)
+        }
+    }
+
+    private fun loadMediaData(key: String, sbn: StatusBarNotification) {
+        backgroundExecutor.execute {
+            loadMediaDataInBg(key, sbn)
+        }
+    }
+
+    private fun loadMediaDataInBg(key: String, sbn: StatusBarNotification) {
+        val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
+                as MediaSession.Token?
+        val metadata = mediaControllerFactory.create(token).metadata
+
+        if (metadata == null) {
+            // TODO: handle this better, removing media notification
+            return
+        }
+
+        // Foreground and Background colors computed from album art
+        val notif: Notification = sbn.notification
+        var fgColor = notif.color
+        var bgColor = -1
+        var artworkBitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ART)
+        if (artworkBitmap == null) {
+            artworkBitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
+        }
+        // TODO: load media data from uri
+        if (artworkBitmap != null) {
+            // If we have art, get colors from that
+            val p = MediaNotificationProcessor.generateArtworkPaletteBuilder(artworkBitmap)
+                    .generate()
+            val swatch = MediaNotificationProcessor.findBackgroundSwatch(p)
+            bgColor = swatch.rgb
+            fgColor = MediaNotificationProcessor.selectForegroundColor(bgColor, p)
+        }
+        // Make sure colors will be legible
+        val isDark = !ContrastColorUtil.isColorLight(bgColor)
+        fgColor = ContrastColorUtil.resolveContrastColor(context, fgColor, bgColor,
+                isDark)
+        fgColor = ContrastColorUtil.ensureTextContrast(fgColor, bgColor, isDark)
+
+        // Album art
+        var artwork: RoundedBitmapDrawable? = null
+        if (artworkBitmap != null) {
+            val original = artworkBitmap.copy(Bitmap.Config.ARGB_8888, true)
+            val scaled = Bitmap.createScaledBitmap(original, albumArtSize, albumArtSize,
+                    false)
+            artwork = RoundedBitmapDrawableFactory.create(context.resources, scaled)
+            artwork.cornerRadius = albumArtRadius.toFloat()
+        }
+
+        // App name
+        val builder = Notification.Builder.recoverBuilder(context, notif)
+        val app = builder.loadHeaderAppName()
+
+        // App Icon
+        val smallIconDrawable: Drawable = sbn.notification.smallIcon.loadDrawable(context)
+
+        // Song name
+        val song: String = metadata.getString(MediaMetadata.METADATA_KEY_TITLE)
+
+        // Artist name
+        val artist: String = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
+
+        // Control buttons
+        val actionIcons: MutableList<MediaAction> = ArrayList()
+        val actions = notif.actions
+        val actionsToShowCollapsed = notif.extras.getIntArray(
+                Notification.EXTRA_COMPACT_ACTIONS)?.toList() ?: emptyList()
+        // TODO: b/153736623 look into creating actions when this isn't a media style notification
+
+        val packageContext: Context = sbn.getPackageContext(context)
+        for (action in actions) {
+            val mediaAction = MediaAction(
+                    action.getIcon().loadDrawable(packageContext),
+                    action.actionIntent,
+                    action.title)
+            actionIcons.add(mediaAction)
+        }
+
+        foregroundExcecutor.execute {
+            onMediaDataLoaded(key, MediaData(true, fgColor, bgColor, app, smallIconDrawable, artist,
+                    song, artwork, actionIcons, actionsToShowCollapsed, sbn.packageName, token,
+                    notif.contentIntent))
+        }
+
+    }
+
+    fun onMediaDataLoaded(key: String, data: MediaData) {
+        if (mediaEntries.containsKey(key)) {
+            // Otherwise this was removed already
+            mediaEntries.put(key, data)
+            listener.onMediaDataLoaded(key, data)
+        }
+    }
+
+    fun onNotificationRemoved(key: String) {
+        val removed = mediaEntries.remove(key)
+        if (removed != null) {
+            listener.onMediaDataRemoved(key)
+        }
+    }
+
+    private fun isMediaNotification(sbn: StatusBarNotification) : Boolean {
+        if (!useUniversalMediaPlayer()) {
+            return false
+        }
+        if (!sbn.notification.hasMediaSession()) {
+            return false
+        }
+        val notificationStyle = sbn.notification.notificationStyle
+        if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle)
+                || Notification.MediaStyle::class.java.equals(notificationStyle)) {
+            return true
+        }
+        return false
+    }
+
+    /**
+     * are we using the universal media player
+     */
+    private fun useUniversalMediaPlayer()
+            = Settings.System.getInt(context.contentResolver, "qs_media_player", 1) > 0
+
+    /**
+     * Are there any media notifications active?
+     */
+    fun hasActiveMedia() = mediaEntries.size > 0
+}
+
+private val LOADING = MediaData(false, 0, 0, null, null, null, null, null,
+        emptyList(), emptyList(), null, null, null)
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
new file mode 100644
index 0000000..4a650ab
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
@@ -0,0 +1,216 @@
+/*
+ * 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.annotation.IntDef
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.LinearLayout
+
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.settingslib.media.InfoMediaManager
+import com.android.settingslib.media.LocalMediaManager
+import com.android.systemui.R
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.plugins.statusbar.StatusBarStateController
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.StatusBarState
+import com.android.systemui.statusbar.notification.VisualStabilityManager
+import com.android.systemui.statusbar.phone.KeyguardBypassController
+import com.android.systemui.util.animation.UniqueObjectHost
+import com.android.systemui.util.concurrency.DelayableExecutor
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * This manager is responsible for placement of the unique media view between the different hosts
+ * and animate the positions of the views to achieve seamless transitions.
+ */
+@Singleton
+class MediaHierarchyManager @Inject constructor(
+    private val context: Context,
+    @Main private val foregroundExecutor: Executor,
+    @Background private val backgroundExecutor: DelayableExecutor,
+    private val localBluetoothManager: LocalBluetoothManager?,
+    private val visualStabilityManager: VisualStabilityManager,
+    private val statusBarStateController: StatusBarStateController,
+    private val bypassController: KeyguardBypassController,
+    mediaManager: NotificationMediaManager
+) {
+    private val mediaCarousel: ViewGroup
+    private val mediaContent: ViewGroup
+    private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
+    private val mediaHosts = arrayOfNulls<ViewGroup>(LOCATION_LOCKSCREEN + 1)
+    private val visualStabilityCallback = ::reorderAllPlayers
+    private var currentAttachmentLocation = -1
+
+    var shouldListen = true
+        set(value) {
+            field = value
+            for (player in mediaPlayers.values) {
+                player.setListening(shouldListen)
+            }
+        }
+
+    init {
+        mediaCarousel = inflateMediaCarousel()
+        mediaContent = mediaCarousel.findViewById(R.id.media_carousel)
+        mediaManager.addCallback(object : NotificationMediaManager.MediaListener {
+            override fun onMediaDataLoaded(key: String, data: MediaData) {
+                updateView(key, data)
+            }
+
+            override fun onMediaDataRemoved(key: String) {
+                val removed = mediaPlayers.remove(key)
+                removed?.apply {
+                    mediaContent.removeView(removed.view)
+                }
+            }
+        })
+    }
+
+    private fun inflateMediaCarousel(): ViewGroup {
+        return LayoutInflater.from(context).inflate(
+                R.layout.media_carousel, UniqueObjectHost(context), false) as ViewGroup
+    }
+
+    private fun reorderAllPlayers() {
+        for (mediaPlayer in mediaPlayers.values) {
+            val view = mediaPlayer.view
+            if (mediaPlayer.isPlaying && mediaContent.indexOfChild(view) != 0) {
+                mediaContent.removeView(view)
+                mediaContent.addView(view, 0)
+            }
+        }
+    }
+
+    private fun updateView(key: String, data: MediaData) {
+        var existingPlayer = mediaPlayers[key]
+        if (existingPlayer == null) {
+            // Set up listener for device changes
+            // TODO: integrate with MediaTransferManager?
+            val imm = InfoMediaManager(context, data.packageName,
+                    null /* notification */, localBluetoothManager)
+            val routeManager = LocalMediaManager(context, localBluetoothManager,
+                    imm, data.packageName)
+
+            existingPlayer = MediaControlPanel(context, mediaContent, routeManager,
+                    foregroundExecutor, backgroundExecutor)
+            mediaPlayers[key] = existingPlayer
+            val padding = context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
+            val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+                    ViewGroup.LayoutParams.WRAP_CONTENT)
+            lp.marginStart = padding
+            lp.marginEnd = padding
+            existingPlayer.view.setLayoutParams(lp)
+            existingPlayer.setListening(shouldListen)
+            if (existingPlayer.isPlaying) {
+                mediaContent.addView(existingPlayer.view, 0)
+            } else {
+                mediaContent.addView(existingPlayer.view)
+            }
+        } else if (existingPlayer.isPlaying &&
+                mediaContent.indexOfChild(existingPlayer.view) != 0) {
+            if (visualStabilityManager.isReorderingAllowed) {
+                mediaContent.removeView(existingPlayer.view)
+                mediaContent.addView(existingPlayer.view, 0)
+            } else {
+                visualStabilityManager.addReorderingAllowedCallback(visualStabilityCallback)
+            }
+        }
+        existingPlayer.bind(data)
+    }
+
+    /**
+     * Create a media host view which can be attached to a view hierarchy and where the player
+     * will be placed in when the host is the currently desired state.
+     *
+     * @return the hostView associated with this location
+     */
+    fun createMediaHost(@MediaLocation location: Int) : ViewGroup {
+        val viewHost = UniqueObjectHost(context)
+        mediaHosts[location] = viewHost
+        if (location == currentAttachmentLocation) {
+            // In case we are overriding a view that is already visible, make sure we attach it
+            // to this new host view in the below call
+            currentAttachmentLocation = -1
+        }
+        updateAttachmentLocation(animate = false)
+        return viewHost
+    }
+
+    private fun updateAttachmentLocation(animate: Boolean) {
+        var desiredLocation = calculateLocation()
+        if (desiredLocation != currentAttachmentLocation) {
+            val host = mediaHosts[desiredLocation]
+            host?.apply {
+                if (currentAttachmentLocation >= 0) {
+                    val currentHost = mediaHosts[currentAttachmentLocation]
+                    currentHost?.removeView(mediaCarousel)
+                }
+                host.addView(mediaCarousel)
+                // TODO: handle animation / repositioning etc
+                currentAttachmentLocation = desiredLocation
+            }
+        }
+    }
+
+    @MediaLocation
+    private fun calculateLocation() : Int {
+        val onLockscreen = (!bypassController.bypassEnabled
+                && (statusBarStateController.state == StatusBarState.KEYGUARD
+                || statusBarStateController.state == StatusBarState.FULLSCREEN_USER_SWITCHER))
+        return when {
+            qsExpansion > 0.0f -> LOCATION_QS
+            onLockscreen -> LOCATION_LOCKSCREEN
+            else -> LOCATION_QQS
+        }
+    }
+
+    /**
+     * The expansion of quick settings
+     */
+    var qsExpansion: Float = 0.0f
+        set(value) {
+            field = value
+            updateAttachmentLocation(animate = false)
+        }
+
+    @IntDef(prefix = ["LOCATION_"], value = [LOCATION_QS, LOCATION_QQS, LOCATION_LOCKSCREEN])
+    @Retention(AnnotationRetention.SOURCE)
+    annotation class MediaLocation
+
+    companion object {
+        /**
+         * Attached in expanded quick settings
+         */
+        const val LOCATION_QS = 0
+
+        /**
+         * Attached in the collapsed QS
+         */
+        const val LOCATION_QQS = 1
+
+        /**
+         * Attached on the lock screen
+         */
+        const val LOCATION_LOCKSCREEN = 2
+    }
+}
\ No newline at end of file