Add media browser for resumption
Doc: go/sysui-media-resumption-requirements
The three main pieces are
1. When the user plays media from an app, check if that app implements a
MediaBrowserService. If so, store that app's info for up to N=5 apps
2. When QSPanel is created, use a QSMediaBrowser to query the saved services
for a playable media item and load those in the media controls panel
3. If the user taps the play button on one of those controls, use
QSMediaBrowser to send a play command to the app's MediaBrowserService
Also, if a media player does not have a MediaBrowserService that allows us to
connect, auto-remove the controls when the media session has ended.
Will explore adding a media button receiver back as an alternative in b/154127084
Bug: 151103474
Bug: 151737807
Test: manual- play from app, reboot, see controls, can play
Change-Id: Ia1172316f1b0c301d794d93b77c7628a736fb153
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index 9217eb1..f25de6a 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -21,20 +21,21 @@
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
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;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
+import android.service.media.MediaBrowserService;
import android.util.Log;
-import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnAttachStateChangeListener;
@@ -55,6 +56,7 @@
import com.android.systemui.Dependency;
import com.android.systemui.R;
import com.android.systemui.plugins.ActivityStarter;
+import com.android.systemui.qs.QSMediaBrowser;
import com.android.systemui.util.Assert;
import java.util.List;
@@ -67,7 +69,7 @@
private static final String TAG = "MediaControlPanel";
@Nullable private final LocalMediaManager mLocalMediaManager;
private final Executor mForegroundExecutor;
- private final Executor mBackgroundExecutor;
+ protected final Executor mBackgroundExecutor;
private Context mContext;
protected LinearLayout mMediaNotifView;
@@ -76,13 +78,18 @@
private MediaController mController;
private int mForegroundColor;
private int mBackgroundColor;
- protected ComponentName mRecvComponent;
private MediaDevice mDevice;
+ protected ComponentName mServiceComponent;
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;
+ private boolean mCheckedForResumption = false;
+
// Button IDs used in notifications
protected static final int[] NOTIF_ACTION_IDS = {
com.android.internal.R.id.action0,
@@ -154,7 +161,6 @@
* Initialize a new control panel
* @param context
* @param parent
- * @param manager
* @param routeManager Manager used to listen for device change events.
* @param layoutId layout resource to use for this control panel
* @param actionIds resource IDs for action buttons in the layout
@@ -198,47 +204,50 @@
/**
* Update the media panel view for the given media session
* @param token
- * @param icon
+ * @param iconDrawable
* @param iconColor
* @param bgColor
* @param contentIntent
* @param appNameString
* @param key
*/
- public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor,
+ public void setMediaSession(MediaSession.Token token, Drawable iconDrawable, int iconColor,
int bgColor, PendingIntent contentIntent, String appNameString, String key) {
- mToken = token;
+ // Ensure that component names are updated if token has changed
+ if (mToken == null || !mToken.equals(token)) {
+ mToken = token;
+ mServiceComponent = null;
+ mCheckedForResumption = false;
+ }
+
mForegroundColor = iconColor;
mBackgroundColor = bgColor;
mController = new MediaController(mContext, mToken);
mKey = key;
- MediaMetadata mediaMetadata = mController.getMetadata();
-
- // Try to find a receiver for the media button that matches this app
- PackageManager pm = mContext.getPackageManager();
- Intent it = new Intent(Intent.ACTION_MEDIA_BUTTON);
- List<ResolveInfo> info = pm.queryBroadcastReceiversAsUser(it, 0, mContext.getUser());
- if (info != null) {
- for (ResolveInfo inf : info) {
- if (inf.activityInfo.packageName.equals(mController.getPackageName())) {
- mRecvComponent = inf.getComponentInfo().getComponentName();
+ // Try to find a browser service component for this app
+ // TODO also check for a media button receiver intended for restarting (b/154127084)
+ // Only check if we haven't tried yet or the session token changed
+ String pkgName = mController.getPackageName();
+ if (mServiceComponent == null && !mCheckedForResumption) {
+ Log.d(TAG, "Checking for service component");
+ PackageManager pm = mContext.getPackageManager();
+ Intent resumeIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE);
+ List<ResolveInfo> resumeInfo = pm.queryIntentServices(resumeIntent, 0);
+ if (resumeInfo != null) {
+ for (ResolveInfo inf : resumeInfo) {
+ if (inf.serviceInfo.packageName.equals(mController.getPackageName())) {
+ mBackgroundExecutor.execute(() ->
+ tryUpdateResumptionList(inf.getComponentInfo().getComponentName()));
+ break;
+ }
}
}
+ mCheckedForResumption = true;
}
mController.registerCallback(mSessionCallback);
- if (mediaMetadata == null) {
- Log.e(TAG, "Media metadata was null");
- return;
- }
-
- ImageView albumView = mMediaNotifView.findViewById(R.id.album_art);
- if (albumView != null) {
- // Resize art in a background thread
- mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView));
- }
mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor));
// Click action
@@ -256,32 +265,9 @@
// App icon
ImageView appIcon = mMediaNotifView.findViewById(R.id.icon);
- Drawable iconDrawable = icon.loadDrawable(mContext);
iconDrawable.setTint(mForegroundColor);
appIcon.setImageDrawable(iconDrawable);
- // Song name
- TextView titleText = mMediaNotifView.findViewById(R.id.header_title);
- String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);
- titleText.setText(songName);
- titleText.setTextColor(mForegroundColor);
-
- // Not in mini player:
- // App title
- TextView appName = mMediaNotifView.findViewById(R.id.app_name);
- if (appName != null) {
- appName.setText(appNameString);
- appName.setTextColor(mForegroundColor);
- }
-
- // Artist name
- TextView artistText = mMediaNotifView.findViewById(R.id.header_artist);
- if (artistText != null) {
- String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
- artistText.setText(artistName);
- artistText.setTextColor(mForegroundColor);
- }
-
// Transfer chip
mSeamless = mMediaNotifView.findViewById(R.id.media_seamless);
if (mSeamless != null && mLocalMediaManager != null) {
@@ -300,6 +286,39 @@
}
makeActive();
+
+ // App title (not in mini player)
+ TextView appName = mMediaNotifView.findViewById(R.id.app_name);
+ if (appName != null) {
+ appName.setText(appNameString);
+ appName.setTextColor(mForegroundColor);
+ }
+
+ MediaMetadata mediaMetadata = mController.getMetadata();
+ if (mediaMetadata == null) {
+ Log.e(TAG, "Media metadata was null");
+ return;
+ }
+
+ ImageView albumView = mMediaNotifView.findViewById(R.id.album_art);
+ if (albumView != null) {
+ // Resize art in a background thread
+ mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView));
+ }
+
+ // Song name
+ TextView titleText = mMediaNotifView.findViewById(R.id.header_title);
+ String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);
+ titleText.setText(songName);
+ titleText.setTextColor(mForegroundColor);
+
+ // Artist name (not in mini player)
+ TextView artistText = mMediaNotifView.findViewById(R.id.header_artist);
+ if (artistText != null) {
+ String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
+ artistText.setText(artistName);
+ artistText.setTextColor(mForegroundColor);
+ }
}
/**
@@ -320,9 +339,12 @@
/**
* Get the name of the package associated with the current media controller
- * @return the package name
+ * @return the package name, or null if no controller
*/
public String getMediaPlayerPackage() {
+ if (mController == null) {
+ return null;
+ }
return mController.getPackageName();
}
@@ -370,11 +392,27 @@
/**
* Process album art for layout
+ * @param description media description
+ * @param albumView view to hold the album art
+ */
+ protected void processAlbumArt(MediaDescription description, ImageView albumView) {
+ Bitmap albumArt = description.getIconBitmap();
+ //TODO check other fields (b/151054111, b/152067055)
+ processAlbumArtInternal(albumArt, albumView);
+ }
+
+ /**
+ * Process album art for layout
* @param metadata media metadata
* @param albumView view to hold the album art
*/
private void processAlbumArt(MediaMetadata metadata, ImageView albumView) {
Bitmap albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
+ //TODO check other fields (b/151054111, b/152067055)
+ processAlbumArtInternal(albumArt, albumView);
+ }
+
+ private void processAlbumArtInternal(Bitmap albumArt, ImageView albumView) {
float radius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius);
RoundedBitmapDrawable roundedDrawable = null;
if (albumArt != null) {
@@ -449,10 +487,24 @@
}
/**
- * Put controls into a resumption state
+ * Puts controls into a resumption state if possible, or calls removePlayer if no component was
+ * found that could resume playback
*/
public void clearControls() {
Log.d(TAG, "clearControls to resumption state package=" + getMediaPlayerPackage());
+ if (mServiceComponent == null) {
+ // If we don't have a way to resume, just remove the player altogether
+ Log.d(TAG, "Removing unresumable controls");
+ removePlayer();
+ return;
+ }
+ resetButtons();
+ }
+
+ /**
+ * Hide the media buttons and show only a restart button
+ */
+ protected void resetButtons() {
// Hide all the old buttons
for (int i = 0; i < mActionIds.length; i++) {
ImageButton thisBtn = mMediaNotifView.findViewById(mActionIds[i]);
@@ -465,27 +517,8 @@
ImageButton btn = mMediaNotifView.findViewById(mActionIds[0]);
btn.setOnClickListener(v -> {
Log.d(TAG, "Attempting to restart session");
- // Send a media button event to previously found receiver
- if (mRecvComponent != null) {
- Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
- intent.setComponent(mRecvComponent);
- int keyCode = KeyEvent.KEYCODE_MEDIA_PLAY;
- intent.putExtra(
- Intent.EXTRA_KEY_EVENT,
- new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
- mContext.sendBroadcast(intent);
- } else {
- // If we don't have a receiver, try relaunching the activity instead
- if (mController.getSessionActivity() != null) {
- try {
- mController.getSessionActivity().send();
- } catch (PendingIntent.CanceledException e) {
- Log.e(TAG, "Pending intent was canceled", e);
- }
- } else {
- Log.e(TAG, "No receiver or activity to restart");
- }
- }
+ QSMediaBrowser browser = new QSMediaBrowser(mContext, null, mServiceComponent);
+ browser.restart();
});
btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play));
btn.setImageTintList(ColorStateList.valueOf(mForegroundColor));
@@ -514,4 +547,65 @@
}
}
+ /**
+ * Verify that we can connect to the given component with a MediaBrowser, and if so, add that
+ * component to the list of resumption components
+ */
+ private void tryUpdateResumptionList(ComponentName componentName) {
+ Log.d(TAG, "Testing if we can connect to " + componentName);
+ QSMediaBrowser.testConnection(mContext,
+ new QSMediaBrowser.Callback() {
+ @Override
+ public void onConnected() {
+ Log.d(TAG, "yes we can resume with " + componentName);
+ mServiceComponent = componentName;
+ updateResumptionList(componentName);
+ }
+
+ @Override
+ public void onError() {
+ Log.d(TAG, "Cannot resume with " + componentName);
+ mServiceComponent = null;
+ clearControls();
+ // remove
+ }
+ },
+ componentName);
+ }
+
+ /**
+ * Add the component to the saved list of media browser services, checking for duplicates and
+ * removing older components that exceed the maximum limit
+ * @param componentName
+ */
+ private synchronized void updateResumptionList(ComponentName componentName) {
+ // Add to front of saved list
+ if (mSharedPrefs == null) {
+ mSharedPrefs = mContext.getSharedPreferences(MEDIA_PREFERENCES, 0);
+ }
+ String componentString = componentName.flattenToString();
+ String listString = mSharedPrefs.getString(MEDIA_PREFERENCE_KEY, null);
+ if (listString == null) {
+ listString = componentString;
+ } else {
+ String[] components = listString.split(QSMediaBrowser.DELIMITER);
+ StringBuilder updated = new StringBuilder(componentString);
+ int nBrowsers = 1;
+ for (int i = 0; i < components.length
+ && nBrowsers < QSMediaBrowser.MAX_RESUMPTION_CONTROLS; i++) {
+ if (componentString.equals(components[i])) {
+ continue;
+ }
+ updated.append(QSMediaBrowser.DELIMITER).append(components[i]);
+ nBrowsers++;
+ }
+ listString = updated.toString();
+ }
+ mSharedPrefs.edit().putString(MEDIA_PREFERENCE_KEY, listString).apply();
+ }
+
+ /**
+ * Called when a player can't be resumed to give it an opportunity to hide or remove itself
+ */
+ protected void removePlayer() { }
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java b/packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java
new file mode 100644
index 0000000..302b8420
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSMediaBrowser.java
@@ -0,0 +1,259 @@
+/*
+ * 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.qs;
+
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.media.MediaDescription;
+import android.media.browse.MediaBrowser;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.os.Bundle;
+import android.service.media.MediaBrowserService;
+import android.util.Log;
+
+import java.util.List;
+
+/**
+ * Media browser for managing resumption in QS media controls
+ */
+public class QSMediaBrowser {
+
+ /** Maximum number of controls to show on boot */
+ public static final int MAX_RESUMPTION_CONTROLS = 5;
+
+ /** Delimiter for saved component names */
+ public static final String DELIMITER = ":";
+
+ private static final String TAG = "QSMediaBrowser";
+ private final Context mContext;
+ private final Callback mCallback;
+ private MediaBrowser mMediaBrowser;
+ private ComponentName mComponentName;
+
+ /**
+ * Initialize a new media browser
+ * @param context the context
+ * @param callback used to report media items found
+ * @param componentName Component name of the MediaBrowserService this browser will connect to
+ */
+ public QSMediaBrowser(Context context, Callback callback, ComponentName componentName) {
+ mContext = context;
+ mCallback = callback;
+ mComponentName = componentName;
+
+ Bundle rootHints = new Bundle();
+ rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
+ mMediaBrowser = new MediaBrowser(mContext,
+ mComponentName,
+ mConnectionCallback,
+ rootHints);
+ }
+
+ /**
+ * Connects to the MediaBrowserService and looks for valid media. If a media item is returned
+ * by the service, QSMediaBrowser.Callback#addTrack will be called with its MediaDescription
+ */
+ public void findRecentMedia() {
+ Log.d(TAG, "Connecting to " + mComponentName);
+ mMediaBrowser.connect();
+ }
+
+ private final MediaBrowser.SubscriptionCallback mSubscriptionCallback =
+ new MediaBrowser.SubscriptionCallback() {
+ @Override
+ public void onChildrenLoaded(String parentId,
+ List<MediaBrowser.MediaItem> children) {
+ if (children.size() == 0) {
+ Log.e(TAG, "No children found");
+ return;
+ }
+ // We ask apps to return a playable item as the first child when sending
+ // a request with EXTRA_RECENT; if they don't, no resume controls
+ MediaBrowser.MediaItem child = children.get(0);
+ MediaDescription desc = child.getDescription();
+ if (child.isPlayable()) {
+ mCallback.addTrack(desc, mMediaBrowser.getServiceComponent(), QSMediaBrowser.this);
+ } else {
+ Log.e(TAG, "Child found but not playable for " + mComponentName);
+ }
+ mMediaBrowser.disconnect();
+ }
+
+ @Override
+ public void onError(String parentId) {
+ Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId);
+ mMediaBrowser.disconnect();
+ }
+
+ @Override
+ public void onError(String parentId, Bundle options) {
+ Log.e(TAG, "Subscribe error for " + mComponentName + ": " + parentId
+ + ", options: " + options);
+ mMediaBrowser.disconnect();
+ }
+ };
+
+ private final MediaBrowser.ConnectionCallback mConnectionCallback =
+ new MediaBrowser.ConnectionCallback() {
+ /**
+ * Invoked after {@link MediaBrowser#connect()} when the request has successfully completed.
+ * For resumption controls, apps are expected to return a playable media item as the first
+ * child. If there are no children or it isn't playable it will be ignored.
+ */
+ @Override
+ public void onConnected() {
+ if (mMediaBrowser.isConnected()) {
+ mCallback.onConnected();
+ Log.d(TAG, "Service connected for " + mComponentName);
+ String root = mMediaBrowser.getRoot();
+ mMediaBrowser.subscribe(root, mSubscriptionCallback);
+ }
+ }
+
+ /**
+ * Invoked when the client is disconnected from the media browser.
+ */
+ @Override
+ public void onConnectionSuspended() {
+ Log.d(TAG, "Connection suspended for " + mComponentName);
+ }
+
+ /**
+ * Invoked when the connection to the media browser failed.
+ */
+ @Override
+ public void onConnectionFailed() {
+ Log.e(TAG, "Connection failed for " + mComponentName);
+ mCallback.onError();
+ }
+ };
+
+ /**
+ * Connects to the MediaBrowserService and starts playback
+ */
+ public void restart() {
+ if (mMediaBrowser.isConnected()) {
+ mMediaBrowser.disconnect();
+ }
+ Bundle rootHints = new Bundle();
+ rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
+ mMediaBrowser = new MediaBrowser(mContext, mComponentName,
+ new MediaBrowser.ConnectionCallback() {
+ @Override
+ public void onConnected() {
+ Log.d(TAG, "Connected for restart " + mMediaBrowser.isConnected());
+ MediaSession.Token token = mMediaBrowser.getSessionToken();
+ MediaController controller = new MediaController(mContext, token);
+ controller.getTransportControls();
+ controller.getTransportControls().prepare();
+ controller.getTransportControls().play();
+ }
+ }, rootHints);
+ mMediaBrowser.connect();
+ }
+
+ /**
+ * Get the media session token
+ * @return the token, or null if the MediaBrowser is null or disconnected
+ */
+ public MediaSession.Token getToken() {
+ if (mMediaBrowser == null || !mMediaBrowser.isConnected()) {
+ return null;
+ }
+ return mMediaBrowser.getSessionToken();
+ }
+
+ /**
+ * Get an intent to launch the app associated with this browser service
+ * @return
+ */
+ public PendingIntent getAppIntent() {
+ PackageManager pm = mContext.getPackageManager();
+ Intent launchIntent = pm.getLaunchIntentForPackage(mComponentName.getPackageName());
+ return PendingIntent.getActivity(mContext, 0, launchIntent, 0);
+ }
+
+ /**
+ * Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser
+ * @param mContext the context
+ * @param callback methods onConnected or onError will be called to indicate whether the
+ * connection was successful or not
+ * @param mComponentName Component name of the MediaBrowserService this browser will connect to
+ */
+ public static MediaBrowser testConnection(Context mContext, Callback callback,
+ ComponentName mComponentName) {
+ final MediaBrowser.ConnectionCallback mConnectionCallback =
+ new MediaBrowser.ConnectionCallback() {
+ @Override
+ public void onConnected() {
+ Log.d(TAG, "connected");
+ callback.onConnected();
+ }
+
+ @Override
+ public void onConnectionSuspended() {
+ Log.d(TAG, "suspended");
+ callback.onError();
+ }
+
+ @Override
+ public void onConnectionFailed() {
+ Log.d(TAG, "failed");
+ callback.onError();
+ }
+ };
+ Bundle rootHints = new Bundle();
+ rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
+ MediaBrowser browser = new MediaBrowser(mContext,
+ mComponentName,
+ mConnectionCallback,
+ rootHints);
+ browser.connect();
+ return browser;
+ }
+
+ /**
+ * Interface to handle results from QSMediaBrowser
+ */
+ public static class Callback {
+ /**
+ * Called when the browser has successfully connected to the service
+ */
+ public void onConnected() {
+ }
+
+ /**
+ * Called when the browser encountered an error connecting to the service
+ */
+ public void onError() {
+ }
+
+ /**
+ * Called when the browser finds a suitable track to add to the media carousel
+ * @param track media info for the item
+ * @param component component of the MediaBrowserService which returned this
+ * @param browser reference to the browser
+ */
+ public void addTrack(MediaDescription track, ComponentName component,
+ QSMediaBrowser browser) {
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java b/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java
index 89b22bc..0f06566 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java
@@ -18,11 +18,12 @@
import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle;
-import android.app.Notification;
+import android.app.PendingIntent;
import android.content.Context;
+import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
-import android.graphics.drawable.Icon;
+import android.media.MediaDescription;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.util.Log;
@@ -60,9 +61,11 @@
};
private final QSPanel mParent;
+ private final Executor mForegroundExecutor;
private final DelayableExecutor mBackgroundExecutor;
private final SeekBarViewModel mSeekBarViewModel;
private final SeekBarObserver mSeekBarObserver;
+ private String mPackageName;
/**
* Initialize quick shade version of player
@@ -77,6 +80,7 @@
super(context, parent, routeManager, R.layout.qs_media_panel, QS_ACTION_IDS,
foregroundExecutor, backgroundExecutor);
mParent = (QSPanel) parent;
+ mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor;
mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
mSeekBarObserver = new SeekBarObserver(getView());
@@ -90,47 +94,101 @@
}
/**
+ * Add a media panel view based on a media description. Used for resumption
+ * @param description
+ * @param iconColor
+ * @param bgColor
+ * @param contentIntent
+ * @param pkgName
+ */
+ public void setMediaSession(MediaSession.Token token, MediaDescription description,
+ int iconColor, int bgColor, PendingIntent contentIntent, String pkgName) {
+ mPackageName = pkgName;
+ PackageManager pm = getContext().getPackageManager();
+ Drawable icon = null;
+ CharSequence appName = pkgName.substring(pkgName.lastIndexOf("."));
+ try {
+ icon = pm.getApplicationIcon(pkgName);
+ appName = pm.getApplicationLabel(pm.getApplicationInfo(pkgName, 0));
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.e(TAG, "Error getting package information", e);
+ }
+
+ // Set what we can normally
+ super.setMediaSession(token, icon, iconColor, bgColor, contentIntent, appName.toString(),
+ null);
+
+ // Then add info from MediaDescription
+ ImageView albumView = mMediaNotifView.findViewById(R.id.album_art);
+ if (albumView != null) {
+ // Resize art in a background thread
+ mBackgroundExecutor.execute(() -> processAlbumArt(description, albumView));
+ }
+
+ // Song name
+ TextView titleText = mMediaNotifView.findViewById(R.id.header_title);
+ CharSequence songName = description.getTitle();
+ titleText.setText(songName);
+ titleText.setTextColor(iconColor);
+
+ // Artist name (not in mini player)
+ TextView artistText = mMediaNotifView.findViewById(R.id.header_artist);
+ if (artistText != null) {
+ CharSequence artistName = description.getSubtitle();
+ artistText.setText(artistName);
+ artistText.setTextColor(iconColor);
+ }
+
+ initLongPressMenu(iconColor);
+
+ // Set buttons to resume state
+ resetButtons();
+ }
+
+ /**
* Update media panel view for the given media session
* @param token token for this media session
* @param icon app notification icon
* @param iconColor foreground color (for text, icons)
* @param bgColor background color
* @param actionsContainer a LinearLayout containing the media action buttons
- * @param notif reference to original notification
+ * @param contentIntent Intent to send when user taps on player
+ * @param appName Application title
* @param key original notification's key
*/
- public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor,
- int bgColor, View actionsContainer, Notification notif, String key) {
+ public void setMediaSession(MediaSession.Token token, Drawable icon, int iconColor,
+ int bgColor, View actionsContainer, PendingIntent contentIntent, String appName,
+ String key) {
- String appName = Notification.Builder.recoverBuilder(getContext(), notif)
- .loadHeaderAppName();
- super.setMediaSession(token, icon, iconColor, bgColor, notif.contentIntent, appName, key);
+ super.setMediaSession(token, icon, iconColor, bgColor, contentIntent, appName, key);
// Media controls
- LinearLayout parentActionsLayout = (LinearLayout) actionsContainer;
- int i = 0;
- for (; i < parentActionsLayout.getChildCount() && i < QS_ACTION_IDS.length; i++) {
- ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]);
- ImageButton thatBtn = parentActionsLayout.findViewById(NOTIF_ACTION_IDS[i]);
- if (thatBtn == null || thatBtn.getDrawable() == null
- || thatBtn.getVisibility() != View.VISIBLE) {
- thisBtn.setVisibility(View.GONE);
- continue;
+ if (actionsContainer != null) {
+ LinearLayout parentActionsLayout = (LinearLayout) actionsContainer;
+ int i = 0;
+ for (; i < parentActionsLayout.getChildCount() && i < QS_ACTION_IDS.length; i++) {
+ ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]);
+ ImageButton thatBtn = parentActionsLayout.findViewById(NOTIF_ACTION_IDS[i]);
+ if (thatBtn == null || thatBtn.getDrawable() == null
+ || thatBtn.getVisibility() != View.VISIBLE) {
+ thisBtn.setVisibility(View.GONE);
+ continue;
+ }
+
+ Drawable thatIcon = thatBtn.getDrawable();
+ thisBtn.setImageDrawable(thatIcon.mutate());
+ thisBtn.setVisibility(View.VISIBLE);
+ thisBtn.setOnClickListener(v -> {
+ Log.d(TAG, "clicking on other button");
+ thatBtn.performClick();
+ });
}
- Drawable thatIcon = thatBtn.getDrawable();
- thisBtn.setImageDrawable(thatIcon.mutate());
- thisBtn.setVisibility(View.VISIBLE);
- thisBtn.setOnClickListener(v -> {
- Log.d(TAG, "clicking on other button");
- thatBtn.performClick();
- });
- }
-
- // Hide any unused buttons
- for (; i < QS_ACTION_IDS.length; i++) {
- ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]);
- thisBtn.setVisibility(View.GONE);
+ // Hide any unused buttons
+ for (; i < QS_ACTION_IDS.length; i++) {
+ ImageButton thisBtn = mMediaNotifView.findViewById(QS_ACTION_IDS[i]);
+ thisBtn.setVisibility(View.GONE);
+ }
}
// Seek Bar
@@ -138,6 +196,10 @@
mBackgroundExecutor.execute(
() -> mSeekBarViewModel.updateController(controller, iconColor));
+ initLongPressMenu(iconColor);
+ }
+
+ private void initLongPressMenu(int iconColor) {
// Set up long press menu
View guts = mMediaNotifView.findViewById(R.id.media_guts);
View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options);
@@ -145,7 +207,7 @@
View clearView = options.findViewById(R.id.remove);
clearView.setOnClickListener(b -> {
- mParent.removeMediaPlayer(QSMediaPlayer.this);
+ removePlayer();
});
ImageView removeIcon = options.findViewById(R.id.remove_icon);
removeIcon.setImageTintList(ColorStateList.valueOf(iconColor));
@@ -165,11 +227,9 @@
}
@Override
- public void clearControls() {
- super.clearControls();
-
+ protected void resetButtons() {
+ super.resetButtons();
mSeekBarViewModel.clearController();
-
View guts = mMediaNotifView.findViewById(R.id.media_guts);
View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options);
@@ -192,4 +252,19 @@
public void setListening(boolean listening) {
mSeekBarViewModel.setListening(listening);
}
+
+ @Override
+ public void removePlayer() {
+ Log.d(TAG, "removing player from parent: " + mParent);
+ // Ensure this happens on the main thread (could happen in QSMediaBrowser callback)
+ mForegroundExecutor.execute(() -> mParent.removeMediaPlayer(QSMediaPlayer.this));
+ }
+
+ @Override
+ public String getMediaPlayerPackage() {
+ if (getController() == null) {
+ return mPackageName;
+ }
+ return super.getMediaPlayerPackage();
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
index fee0838..1eb5778 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
@@ -21,16 +21,25 @@
import static com.android.systemui.util.Utils.useQsMediaPlayer;
import android.annotation.Nullable;
+import android.app.Notification;
+import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources;
-import android.graphics.drawable.Icon;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.media.MediaDescription;
import android.media.session.MediaSession;
import android.metrics.LogMaker;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
+import android.os.UserHandle;
+import android.os.UserManager;
import android.service.notification.StatusBarNotification;
import android.service.quicksettings.Tile;
import android.util.AttributeSet;
@@ -54,6 +63,7 @@
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.dump.DumpManager;
+import com.android.systemui.media.MediaControlPanel;
import com.android.systemui.plugins.qs.DetailAdapter;
import com.android.systemui.plugins.qs.QSTile;
import com.android.systemui.plugins.qs.QSTileView;
@@ -90,6 +100,7 @@
protected final Context mContext;
protected final ArrayList<TileRecord> mRecords = new ArrayList<>();
+ private final BroadcastDispatcher mBroadcastDispatcher;
private String mCachedSpecs = "";
protected final View mBrightnessView;
private final H mHandler = new H();
@@ -123,6 +134,19 @@
private BrightnessMirrorController mBrightnessMirrorController;
private View mDivider;
+ private boolean mHasLoadedMediaControls;
+
+ private final BroadcastReceiver mUserChangeReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final String action = intent.getAction();
+ if (Intent.ACTION_USER_UNLOCKED.equals(action)) {
+ if (!mHasLoadedMediaControls) {
+ loadMediaResumptionControls();
+ }
+ }
+ }
+ };
@Inject
public QSPanel(
@@ -142,6 +166,7 @@
mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor;
mLocalBluetoothManager = localBluetoothManager;
+ mBroadcastDispatcher = broadcastDispatcher;
setOrientation(VERTICAL);
@@ -176,7 +201,7 @@
updateResources();
mBrightnessController = new BrightnessController(getContext(),
- findViewById(R.id.brightness_slider), broadcastDispatcher);
+ findViewById(R.id.brightness_slider), mBroadcastDispatcher);
}
@Override
@@ -206,7 +231,7 @@
* @param notif
* @param key
*/
- public void addMediaSession(MediaSession.Token token, Icon icon, int iconColor, int bgColor,
+ public void addMediaSession(MediaSession.Token token, Drawable icon, int iconColor, int bgColor,
View actionsContainer, StatusBarNotification notif, String key) {
if (!useQsMediaPlayer(mContext)) {
// Shouldn't happen, but just in case
@@ -221,7 +246,14 @@
QSMediaPlayer player = null;
String packageName = notif.getPackageName();
for (QSMediaPlayer p : mMediaPlayers) {
- if (p.getMediaSessionToken().equals(token)) {
+ if (p.getKey() == null) {
+ // No notification key = loaded via mediabrowser, so just match on package
+ if (packageName.equals(p.getMediaPlayerPackage())) {
+ Log.d(TAG, "Found matching resume player by package: " + packageName);
+ player = p;
+ break;
+ }
+ } else if (p.getMediaSessionToken().equals(token)) {
Log.d(TAG, "Found matching player by token " + packageName);
player = p;
break;
@@ -262,8 +294,10 @@
}
Log.d(TAG, "setting player session");
+ String appName = Notification.Builder.recoverBuilder(getContext(), notif.getNotification())
+ .loadHeaderAppName();
player.setMediaSession(token, icon, iconColor, bgColor, actionsContainer,
- notif.getNotification(), key);
+ notif.getNotification().contentIntent, appName, key);
if (mMediaPlayers.size() > 0) {
((View) mMediaCarousel.getParent()).setVisibility(View.VISIBLE);
@@ -293,6 +327,74 @@
return true;
}
+ private final QSMediaBrowser.Callback mMediaBrowserCallback = new QSMediaBrowser.Callback() {
+ @Override
+ public void addTrack(MediaDescription desc, ComponentName component,
+ QSMediaBrowser browser) {
+ if (component == null) {
+ Log.e(TAG, "Component cannot be null");
+ return;
+ }
+
+ Log.d(TAG, "adding track from browser: " + desc + ", " + component);
+ QSMediaPlayer player = new QSMediaPlayer(mContext, QSPanel.this,
+ null, mForegroundExecutor, mBackgroundExecutor);
+
+ String pkgName = component.getPackageName();
+
+ // Add controls to carousel
+ int playerWidth = (int) getResources().getDimension(R.dimen.qs_media_width);
+ int padding = (int) getResources().getDimension(R.dimen.qs_media_padding);
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(playerWidth,
+ LayoutParams.MATCH_PARENT);
+ lp.setMarginStart(padding);
+ lp.setMarginEnd(padding);
+ mMediaCarousel.addView(player.getView(), lp);
+ ((View) mMediaCarousel.getParent()).setVisibility(View.VISIBLE);
+ mMediaPlayers.add(player);
+
+ int iconColor = Color.DKGRAY;
+ int bgColor = Color.LTGRAY;
+
+ MediaSession.Token token = browser.getToken();
+ player.setMediaSession(token, desc, iconColor, bgColor, browser.getAppIntent(),
+ pkgName);
+ }
+ };
+
+ /**
+ * Load controls for resuming media, if available
+ */
+ private void loadMediaResumptionControls() {
+ if (!useQsMediaPlayer(mContext)) {
+ return;
+ }
+ Log.d(TAG, "Loading resumption controls");
+
+ // Look up saved components to resume
+ Context userContext = mContext.createContextAsUser(mContext.getUser(), 0);
+ SharedPreferences prefs = userContext.getSharedPreferences(
+ MediaControlPanel.MEDIA_PREFERENCES, Context.MODE_PRIVATE);
+ String listString = prefs.getString(MediaControlPanel.MEDIA_PREFERENCE_KEY, null);
+ if (listString == null) {
+ Log.d(TAG, "No saved media components");
+ return;
+ }
+
+ String[] components = listString.split(QSMediaBrowser.DELIMITER);
+ Log.d(TAG, "components are: " + listString + " count " + components.length);
+ for (int i = 0; i < components.length && i < QSMediaBrowser.MAX_RESUMPTION_CONTROLS; i++) {
+ String[] info = components[i].split("/");
+ String packageName = info[0];
+ String className = info[1];
+ ComponentName component = new ComponentName(packageName, className);
+ QSMediaBrowser browser = new QSMediaBrowser(mContext, mMediaBrowserCallback,
+ component);
+ browser.findRecentMedia();
+ }
+ mHasLoadedMediaControls = true;
+ }
+
protected void addDivider() {
mDivider = LayoutInflater.from(mContext).inflate(R.layout.qs_divider, this, false);
mDivider.setBackgroundColor(Utils.applyAlpha(mDivider.getAlpha(),
@@ -343,6 +445,22 @@
mBrightnessMirrorController.addCallback(this);
}
mDumpManager.registerDumpable(getDumpableTag(), this);
+
+ if (getClass() == QSPanel.class) {
+ //TODO(ethibodeau) remove class check after media refactor in ag/11059751
+ // Only run this in QSPanel proper, not QQS
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_USER_UNLOCKED);
+ mBroadcastDispatcher.registerReceiver(mUserChangeReceiver, filter, null,
+ UserHandle.ALL);
+ mHasLoadedMediaControls = false;
+
+ UserManager userManager = mContext.getSystemService(UserManager.class);
+ if (userManager.isUserUnlocked(mContext.getUserId())) {
+ // If it's already unlocked (like if dark theme was toggled), we can load now
+ loadMediaResumptionControls();
+ }
+ }
}
@Override
@@ -358,6 +476,7 @@
mBrightnessMirrorController.removeCallback(this);
}
mDumpManager.unregisterDumpable(getDumpableTag());
+ mBroadcastDispatcher.unregisterReceiver(mUserChangeReceiver);
super.onDetachedFromWindow();
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java b/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java
index 6229672..7ba7c5f 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QuickQSMediaPlayer.java
@@ -19,7 +19,6 @@
import android.app.PendingIntent;
import android.content.Context;
import android.graphics.drawable.Drawable;
-import android.graphics.drawable.Icon;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.view.View;
@@ -67,7 +66,7 @@
* @param contentIntent Intent to send when user taps on the view
* @param key original notification's key
*/
- public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor, int bgColor,
+ public void setMediaSession(MediaSession.Token token, Drawable icon, int iconColor, int bgColor,
View actionsContainer, int[] actionsToShow, PendingIntent contentIntent, String key) {
// Only update if this is a different session and currently playing
String oldPackage = "";
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java
index 2da2724..796f22c 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationMediaTemplateViewWrapper.java
@@ -22,6 +22,7 @@
import android.app.Notification;
import android.content.Context;
import android.content.res.ColorStateList;
+import android.graphics.drawable.Drawable;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.MediaSession;
@@ -187,8 +188,9 @@
com.android.systemui.R.id.quick_qs_panel);
StatusBarNotification sbn = mRow.getEntry().getSbn();
Notification notif = sbn.getNotification();
+ Drawable iconDrawable = notif.getSmallIcon().loadDrawable(mContext);
panel.getMediaPlayer().setMediaSession(token,
- notif.getSmallIcon(),
+ iconDrawable,
tintColor,
mBackgroundColor,
mActions,
@@ -198,7 +200,7 @@
QSPanel bigPanel = ctrl.getNotificationShadeView().findViewById(
com.android.systemui.R.id.quick_settings_panel);
bigPanel.addMediaSession(token,
- notif.getSmallIcon(),
+ iconDrawable,
tintColor,
mBackgroundColor,
mActions,