blob: 9873d240efe3a7898cde8d0df74c2639e0171719 [file] [log] [blame]
Beth Thibodeau7b6c1782020-03-05 11:43:51 -05001/*
2 * Copyright (C) 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.systemui.media;
18
19import android.annotation.LayoutRes;
20import android.app.PendingIntent;
21import android.content.ComponentName;
22import android.content.Context;
23import android.content.Intent;
24import android.content.pm.PackageManager;
25import android.content.pm.ResolveInfo;
26import android.content.res.ColorStateList;
27import android.graphics.Bitmap;
28import android.graphics.drawable.Drawable;
29import android.graphics.drawable.GradientDrawable;
30import android.graphics.drawable.Icon;
31import android.graphics.drawable.RippleDrawable;
32import android.media.MediaMetadata;
33import android.media.session.MediaController;
34import android.media.session.MediaSession;
35import android.media.session.PlaybackState;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050036import android.util.Log;
37import android.view.KeyEvent;
38import android.view.LayoutInflater;
39import android.view.View;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040040import android.view.View.OnAttachStateChangeListener;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050041import android.view.ViewGroup;
42import android.widget.ImageButton;
43import android.widget.ImageView;
44import android.widget.LinearLayout;
45import android.widget.TextView;
46
47import androidx.core.graphics.drawable.RoundedBitmapDrawable;
48import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
49
50import com.android.settingslib.media.MediaDevice;
51import com.android.settingslib.media.MediaOutputSliceConstants;
52import com.android.settingslib.widget.AdaptiveIcon;
53import com.android.systemui.Dependency;
54import com.android.systemui.R;
55import com.android.systemui.plugins.ActivityStarter;
56import com.android.systemui.statusbar.NotificationMediaManager;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040057import com.android.systemui.statusbar.NotificationMediaManager.MediaListener;
58import com.android.systemui.util.Assert;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050059
60import java.util.List;
61import java.util.concurrent.Executor;
62
63/**
64 * Base media control panel for System UI
65 */
Robert Snoeberger3cc22222020-03-25 15:36:31 -040066public class MediaControlPanel {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050067 private static final String TAG = "MediaControlPanel";
68 private final NotificationMediaManager mMediaManager;
Beth Thibodeaua51c3142020-03-17 17:27:04 -040069 private final Executor mForegroundExecutor;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050070 private final Executor mBackgroundExecutor;
71
72 private Context mContext;
73 protected LinearLayout mMediaNotifView;
74 private View mSeamless;
75 private MediaSession.Token mToken;
76 private MediaController mController;
77 private int mForegroundColor;
78 private int mBackgroundColor;
79 protected ComponentName mRecvComponent;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040080 private boolean mIsRegistered = false;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050081
82 private final int[] mActionIds;
83
84 // Button IDs used in notifications
85 protected static final int[] NOTIF_ACTION_IDS = {
86 com.android.internal.R.id.action0,
87 com.android.internal.R.id.action1,
88 com.android.internal.R.id.action2,
89 com.android.internal.R.id.action3,
90 com.android.internal.R.id.action4
91 };
92
Robert Snoeberger3cc22222020-03-25 15:36:31 -040093 private final MediaController.Callback mSessionCallback = new MediaController.Callback() {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050094 @Override
95 public void onSessionDestroyed() {
96 Log.d(TAG, "session destroyed");
97 mController.unregisterCallback(mSessionCallback);
98 clearControls();
Robert Snoeberger3cc22222020-03-25 15:36:31 -040099 makeInactive();
100 }
101 };
102
103 private final MediaListener mMediaListener = new MediaListener() {
104 @Override
105 public void onMetadataOrStateChanged(MediaMetadata metadata, int state) {
106 if (state == PlaybackState.STATE_NONE) {
107 clearControls();
108 makeInactive();
109 }
110 }
111 };
112
113 private final OnAttachStateChangeListener mStateListener = new OnAttachStateChangeListener() {
114 @Override
115 public void onViewAttachedToWindow(View unused) {
116 makeActive();
117 }
118 @Override
119 public void onViewDetachedFromWindow(View unused) {
120 makeInactive();
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500121 }
122 };
123
124 /**
125 * Initialize a new control panel
126 * @param context
127 * @param parent
128 * @param manager
129 * @param layoutId layout resource to use for this control panel
130 * @param actionIds resource IDs for action buttons in the layout
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400131 * @param foregroundExecutor foreground executor
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500132 * @param backgroundExecutor background executor, used for processing artwork
133 */
134 public MediaControlPanel(Context context, ViewGroup parent, NotificationMediaManager manager,
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400135 @LayoutRes int layoutId, int[] actionIds, Executor foregroundExecutor,
136 Executor backgroundExecutor) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500137 mContext = context;
138 LayoutInflater inflater = LayoutInflater.from(mContext);
139 mMediaNotifView = (LinearLayout) inflater.inflate(layoutId, parent, false);
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400140 // TODO(b/150854549): removeOnAttachStateChangeListener when this doesn't inflate views
141 // mStateListener shouldn't need to be unregistered since this object shares the same
142 // lifecycle with the inflated view. It would be better, however, if this controller used an
143 // attach/detach of views instead of inflating them in the constructor, which would allow
144 // mStateListener to be unregistered in detach.
145 mMediaNotifView.addOnAttachStateChangeListener(mStateListener);
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500146 mMediaManager = manager;
147 mActionIds = actionIds;
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400148 mForegroundExecutor = foregroundExecutor;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500149 mBackgroundExecutor = backgroundExecutor;
150 }
151
152 /**
153 * Get the view used to display media controls
154 * @return the view
155 */
156 public View getView() {
157 return mMediaNotifView;
158 }
159
160 /**
161 * Get the context
162 * @return context
163 */
164 public Context getContext() {
165 return mContext;
166 }
167
168 /**
169 * Update the media panel view for the given media session
170 * @param token
171 * @param icon
172 * @param iconColor
173 * @param bgColor
174 * @param contentIntent
175 * @param appNameString
176 * @param device
177 */
178 public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor,
179 int bgColor, PendingIntent contentIntent, String appNameString, MediaDevice device) {
180 mToken = token;
181 mForegroundColor = iconColor;
182 mBackgroundColor = bgColor;
183 mController = new MediaController(mContext, mToken);
184
185 MediaMetadata mediaMetadata = mController.getMetadata();
186
187 // Try to find a receiver for the media button that matches this app
188 PackageManager pm = mContext.getPackageManager();
189 Intent it = new Intent(Intent.ACTION_MEDIA_BUTTON);
190 List<ResolveInfo> info = pm.queryBroadcastReceiversAsUser(it, 0, mContext.getUser());
191 if (info != null) {
192 for (ResolveInfo inf : info) {
193 if (inf.activityInfo.packageName.equals(mController.getPackageName())) {
194 mRecvComponent = inf.getComponentInfo().getComponentName();
195 }
196 }
197 }
198
199 mController.registerCallback(mSessionCallback);
200
201 if (mediaMetadata == null) {
202 Log.e(TAG, "Media metadata was null");
203 return;
204 }
205
206 ImageView albumView = mMediaNotifView.findViewById(R.id.album_art);
207 if (albumView != null) {
208 // Resize art in a background thread
209 mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView));
210 }
211 mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor));
212
213 // Click action
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400214 if (contentIntent != null) {
215 mMediaNotifView.setOnClickListener(v -> {
216 try {
217 contentIntent.send();
218 // Also close shade
219 mContext.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
220 } catch (PendingIntent.CanceledException e) {
221 Log.e(TAG, "Pending intent was canceled", e);
222 }
223 });
224 }
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500225
226 // App icon
227 ImageView appIcon = mMediaNotifView.findViewById(R.id.icon);
228 Drawable iconDrawable = icon.loadDrawable(mContext);
229 iconDrawable.setTint(mForegroundColor);
230 appIcon.setImageDrawable(iconDrawable);
231
232 // Song name
233 TextView titleText = mMediaNotifView.findViewById(R.id.header_title);
234 String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);
235 titleText.setText(songName);
236 titleText.setTextColor(mForegroundColor);
237
238 // Not in mini player:
239 // App title
240 TextView appName = mMediaNotifView.findViewById(R.id.app_name);
241 if (appName != null) {
242 appName.setText(appNameString);
243 appName.setTextColor(mForegroundColor);
244 }
245
246 // Artist name
247 TextView artistText = mMediaNotifView.findViewById(R.id.header_artist);
248 if (artistText != null) {
249 String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
250 artistText.setText(artistName);
251 artistText.setTextColor(mForegroundColor);
252 }
253
254 // Transfer chip
255 mSeamless = mMediaNotifView.findViewById(R.id.media_seamless);
256 if (mSeamless != null) {
257 mSeamless.setVisibility(View.VISIBLE);
258 updateDevice(device);
259 ActivityStarter mActivityStarter = Dependency.get(ActivityStarter.class);
260 mSeamless.setOnClickListener(v -> {
261 final Intent intent = new Intent()
262 .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
263 .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
264 mController.getPackageName())
265 .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
266 mActivityStarter.startActivity(intent, false, true /* dismissShade */,
267 Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
268 });
269 }
270
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400271 makeActive();
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500272 }
273
274 /**
275 * Return the token for the current media session
276 * @return the token
277 */
278 public MediaSession.Token getMediaSessionToken() {
279 return mToken;
280 }
281
282 /**
283 * Get the current media controller
284 * @return the controller
285 */
286 public MediaController getController() {
287 return mController;
288 }
289
290 /**
291 * Get the name of the package associated with the current media controller
292 * @return the package name
293 */
294 public String getMediaPlayerPackage() {
295 return mController.getPackageName();
296 }
297
298 /**
299 * Check whether this player has an attached media session.
300 * @return whether there is a controller with a current media session.
301 */
302 public boolean hasMediaSession() {
303 return mController != null && mController.getPlaybackState() != null;
304 }
305
306 /**
307 * Check whether the media controlled by this player is currently playing
308 * @return whether it is playing, or false if no controller information
309 */
310 public boolean isPlaying() {
311 return isPlaying(mController);
312 }
313
314 /**
315 * Check whether the given controller is currently playing
316 * @param controller media controller to check
317 * @return whether it is playing, or false if no controller information
318 */
319 protected boolean isPlaying(MediaController controller) {
320 if (controller == null) {
321 return false;
322 }
323
324 PlaybackState state = controller.getPlaybackState();
325 if (state == null) {
326 return false;
327 }
328
329 return (state.getState() == PlaybackState.STATE_PLAYING);
330 }
331
332 /**
333 * Process album art for layout
334 * @param metadata media metadata
335 * @param albumView view to hold the album art
336 */
337 private void processAlbumArt(MediaMetadata metadata, ImageView albumView) {
338 Bitmap albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
339 float radius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius);
340 RoundedBitmapDrawable roundedDrawable = null;
341 if (albumArt != null) {
342 Bitmap original = albumArt.copy(Bitmap.Config.ARGB_8888, true);
343 int albumSize = (int) mContext.getResources().getDimension(
344 R.dimen.qs_media_album_size);
345 Bitmap scaled = Bitmap.createScaledBitmap(original, albumSize, albumSize, false);
346 roundedDrawable = RoundedBitmapDrawableFactory.create(mContext.getResources(), scaled);
347 roundedDrawable.setCornerRadius(radius);
348 } else {
349 Log.e(TAG, "No album art available");
350 }
351
352 // Now that it's resized, update the UI
353 final RoundedBitmapDrawable result = roundedDrawable;
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400354 mForegroundExecutor.execute(() -> {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500355 if (result != null) {
356 albumView.setImageDrawable(result);
357 albumView.setVisibility(View.VISIBLE);
358 } else {
359 albumView.setImageDrawable(null);
360 albumView.setVisibility(View.GONE);
361 }
362 });
363 }
364
365 /**
366 * Update the current device information
367 * @param device device information to display
368 */
369 public void updateDevice(MediaDevice device) {
370 if (mSeamless == null) {
371 return;
372 }
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400373 mForegroundExecutor.execute(() -> {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500374 updateChipInternal(device);
375 });
376 }
377
378 private void updateChipInternal(MediaDevice device) {
379 ColorStateList fgTintList = ColorStateList.valueOf(mForegroundColor);
380
381 // Update the outline color
382 LinearLayout viewLayout = (LinearLayout) mSeamless;
383 RippleDrawable bkgDrawable = (RippleDrawable) viewLayout.getBackground();
384 GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0);
385 rect.setStroke(2, mForegroundColor);
386 rect.setColor(mBackgroundColor);
387
388 ImageView iconView = mSeamless.findViewById(R.id.media_seamless_image);
389 TextView deviceName = mSeamless.findViewById(R.id.media_seamless_text);
390 deviceName.setTextColor(fgTintList);
391
392 if (device != null) {
393 Drawable icon = device.getIcon();
394 iconView.setVisibility(View.VISIBLE);
395 iconView.setImageTintList(fgTintList);
396
397 if (icon instanceof AdaptiveIcon) {
398 AdaptiveIcon aIcon = (AdaptiveIcon) icon;
399 aIcon.setBackgroundColor(mBackgroundColor);
400 iconView.setImageDrawable(aIcon);
401 } else {
402 iconView.setImageDrawable(icon);
403 }
404 deviceName.setText(device.getName());
405 } else {
406 // Reset to default
407 iconView.setVisibility(View.GONE);
408 deviceName.setText(com.android.internal.R.string.ext_media_seamless_action);
409 }
410 }
411
412 /**
413 * Put controls into a resumption state
414 */
415 public void clearControls() {
416 // Hide all the old buttons
417 for (int i = 0; i < mActionIds.length; i++) {
418 ImageButton thisBtn = mMediaNotifView.findViewById(mActionIds[i]);
419 if (thisBtn != null) {
420 thisBtn.setVisibility(View.GONE);
421 }
422 }
423
424 // Add a restart button
425 ImageButton btn = mMediaNotifView.findViewById(mActionIds[0]);
426 btn.setOnClickListener(v -> {
427 Log.d(TAG, "Attempting to restart session");
428 // Send a media button event to previously found receiver
429 if (mRecvComponent != null) {
430 Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
431 intent.setComponent(mRecvComponent);
432 int keyCode = KeyEvent.KEYCODE_MEDIA_PLAY;
433 intent.putExtra(
434 Intent.EXTRA_KEY_EVENT,
435 new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
436 mContext.sendBroadcast(intent);
437 } else {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500438 // If we don't have a receiver, try relaunching the activity instead
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400439 if (mController.getSessionActivity() != null) {
440 try {
441 mController.getSessionActivity().send();
442 } catch (PendingIntent.CanceledException e) {
443 Log.e(TAG, "Pending intent was canceled", e);
444 }
445 } else {
446 Log.e(TAG, "No receiver or activity to restart");
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500447 }
448 }
449 });
450 btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play));
451 btn.setImageTintList(ColorStateList.valueOf(mForegroundColor));
452 btn.setVisibility(View.VISIBLE);
453 }
454
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400455 private void makeActive() {
456 Assert.isMainThread();
457 if (!mIsRegistered) {
458 mMediaManager.addCallback(mMediaListener);
459 mIsRegistered = true;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500460 }
461 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400462
463 private void makeInactive() {
464 Assert.isMainThread();
465 if (mIsRegistered) {
466 mMediaManager.removeCallback(mMediaListener);
467 mIsRegistered = false;
468 }
469 }
470
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500471}