blob: 62efd8ce4cee9a329a56ea3c094b1f809dbea163 [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
Robert Snoeberger9a7409b2020-04-09 18:12:27 -040047import androidx.annotation.Nullable;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050048import androidx.core.graphics.drawable.RoundedBitmapDrawable;
49import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
50
Robert Snoeberger9a7409b2020-04-09 18:12:27 -040051import com.android.settingslib.media.LocalMediaManager;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050052import com.android.settingslib.media.MediaDevice;
53import com.android.settingslib.media.MediaOutputSliceConstants;
54import com.android.settingslib.widget.AdaptiveIcon;
55import com.android.systemui.Dependency;
56import com.android.systemui.R;
57import com.android.systemui.plugins.ActivityStarter;
58import com.android.systemui.statusbar.NotificationMediaManager;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040059import com.android.systemui.statusbar.NotificationMediaManager.MediaListener;
60import com.android.systemui.util.Assert;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050061
62import java.util.List;
63import java.util.concurrent.Executor;
64
65/**
66 * Base media control panel for System UI
67 */
Robert Snoeberger3cc22222020-03-25 15:36:31 -040068public class MediaControlPanel {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050069 private static final String TAG = "MediaControlPanel";
70 private final NotificationMediaManager mMediaManager;
Robert Snoeberger9a7409b2020-04-09 18:12:27 -040071 @Nullable private final LocalMediaManager mLocalMediaManager;
Beth Thibodeaua51c3142020-03-17 17:27:04 -040072 private final Executor mForegroundExecutor;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050073 private final Executor mBackgroundExecutor;
74
75 private Context mContext;
76 protected LinearLayout mMediaNotifView;
77 private View mSeamless;
78 private MediaSession.Token mToken;
79 private MediaController mController;
80 private int mForegroundColor;
81 private int mBackgroundColor;
82 protected ComponentName mRecvComponent;
Robert Snoeberger9a7409b2020-04-09 18:12:27 -040083 private MediaDevice mDevice;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040084 private boolean mIsRegistered = false;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050085
86 private final int[] mActionIds;
87
88 // Button IDs used in notifications
89 protected static final int[] NOTIF_ACTION_IDS = {
90 com.android.internal.R.id.action0,
91 com.android.internal.R.id.action1,
92 com.android.internal.R.id.action2,
93 com.android.internal.R.id.action3,
94 com.android.internal.R.id.action4
95 };
96
Robert Snoeberger3cc22222020-03-25 15:36:31 -040097 private final MediaController.Callback mSessionCallback = new MediaController.Callback() {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050098 @Override
99 public void onSessionDestroyed() {
100 Log.d(TAG, "session destroyed");
101 mController.unregisterCallback(mSessionCallback);
102 clearControls();
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400103 makeInactive();
104 }
105 };
106
107 private final MediaListener mMediaListener = new MediaListener() {
108 @Override
109 public void onMetadataOrStateChanged(MediaMetadata metadata, int state) {
110 if (state == PlaybackState.STATE_NONE) {
111 clearControls();
112 makeInactive();
113 }
114 }
115 };
116
117 private final OnAttachStateChangeListener mStateListener = new OnAttachStateChangeListener() {
118 @Override
119 public void onViewAttachedToWindow(View unused) {
120 makeActive();
121 }
122 @Override
123 public void onViewDetachedFromWindow(View unused) {
124 makeInactive();
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500125 }
126 };
127
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400128 private final LocalMediaManager.DeviceCallback mDeviceCallback =
129 new LocalMediaManager.DeviceCallback() {
130 @Override
131 public void onDeviceListUpdate(List<MediaDevice> devices) {
132 if (mLocalMediaManager == null) {
133 return;
134 }
135 MediaDevice currentDevice = mLocalMediaManager.getCurrentConnectedDevice();
136 // Check because this can be called several times while changing devices
137 if (mDevice == null || !mDevice.equals(currentDevice)) {
138 mDevice = currentDevice;
139 updateDevice(mDevice);
140 }
141 }
142
143 @Override
144 public void onSelectedDeviceStateChanged(MediaDevice device, int state) {
145 if (mDevice == null || !mDevice.equals(device)) {
146 mDevice = device;
147 updateDevice(mDevice);
148 }
149 }
150 };
151
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500152 /**
153 * Initialize a new control panel
154 * @param context
155 * @param parent
156 * @param manager
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400157 * @param routeManager Manager used to listen for device change events.
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500158 * @param layoutId layout resource to use for this control panel
159 * @param actionIds resource IDs for action buttons in the layout
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400160 * @param foregroundExecutor foreground executor
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500161 * @param backgroundExecutor background executor, used for processing artwork
162 */
163 public MediaControlPanel(Context context, ViewGroup parent, NotificationMediaManager manager,
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400164 @Nullable LocalMediaManager routeManager, @LayoutRes int layoutId, int[] actionIds,
165 Executor foregroundExecutor, Executor backgroundExecutor) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500166 mContext = context;
167 LayoutInflater inflater = LayoutInflater.from(mContext);
168 mMediaNotifView = (LinearLayout) inflater.inflate(layoutId, parent, false);
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400169 // TODO(b/150854549): removeOnAttachStateChangeListener when this doesn't inflate views
170 // mStateListener shouldn't need to be unregistered since this object shares the same
171 // lifecycle with the inflated view. It would be better, however, if this controller used an
172 // attach/detach of views instead of inflating them in the constructor, which would allow
173 // mStateListener to be unregistered in detach.
174 mMediaNotifView.addOnAttachStateChangeListener(mStateListener);
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500175 mMediaManager = manager;
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400176 mLocalMediaManager = routeManager;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500177 mActionIds = actionIds;
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400178 mForegroundExecutor = foregroundExecutor;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500179 mBackgroundExecutor = backgroundExecutor;
180 }
181
182 /**
183 * Get the view used to display media controls
184 * @return the view
185 */
186 public View getView() {
187 return mMediaNotifView;
188 }
189
190 /**
191 * Get the context
192 * @return context
193 */
194 public Context getContext() {
195 return mContext;
196 }
197
198 /**
199 * Update the media panel view for the given media session
200 * @param token
201 * @param icon
202 * @param iconColor
203 * @param bgColor
204 * @param contentIntent
205 * @param appNameString
206 * @param device
207 */
208 public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor,
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400209 int bgColor, PendingIntent contentIntent, String appNameString) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500210 mToken = token;
211 mForegroundColor = iconColor;
212 mBackgroundColor = bgColor;
213 mController = new MediaController(mContext, mToken);
214
215 MediaMetadata mediaMetadata = mController.getMetadata();
216
217 // Try to find a receiver for the media button that matches this app
218 PackageManager pm = mContext.getPackageManager();
219 Intent it = new Intent(Intent.ACTION_MEDIA_BUTTON);
220 List<ResolveInfo> info = pm.queryBroadcastReceiversAsUser(it, 0, mContext.getUser());
221 if (info != null) {
222 for (ResolveInfo inf : info) {
223 if (inf.activityInfo.packageName.equals(mController.getPackageName())) {
224 mRecvComponent = inf.getComponentInfo().getComponentName();
225 }
226 }
227 }
228
229 mController.registerCallback(mSessionCallback);
230
231 if (mediaMetadata == null) {
232 Log.e(TAG, "Media metadata was null");
233 return;
234 }
235
236 ImageView albumView = mMediaNotifView.findViewById(R.id.album_art);
237 if (albumView != null) {
238 // Resize art in a background thread
239 mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView));
240 }
241 mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor));
242
243 // Click action
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400244 if (contentIntent != null) {
245 mMediaNotifView.setOnClickListener(v -> {
246 try {
247 contentIntent.send();
248 // Also close shade
249 mContext.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
250 } catch (PendingIntent.CanceledException e) {
251 Log.e(TAG, "Pending intent was canceled", e);
252 }
253 });
254 }
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500255
256 // App icon
257 ImageView appIcon = mMediaNotifView.findViewById(R.id.icon);
258 Drawable iconDrawable = icon.loadDrawable(mContext);
259 iconDrawable.setTint(mForegroundColor);
260 appIcon.setImageDrawable(iconDrawable);
261
262 // Song name
263 TextView titleText = mMediaNotifView.findViewById(R.id.header_title);
264 String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);
265 titleText.setText(songName);
266 titleText.setTextColor(mForegroundColor);
267
268 // Not in mini player:
269 // App title
270 TextView appName = mMediaNotifView.findViewById(R.id.app_name);
271 if (appName != null) {
272 appName.setText(appNameString);
273 appName.setTextColor(mForegroundColor);
274 }
275
276 // Artist name
277 TextView artistText = mMediaNotifView.findViewById(R.id.header_artist);
278 if (artistText != null) {
279 String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
280 artistText.setText(artistName);
281 artistText.setTextColor(mForegroundColor);
282 }
283
284 // Transfer chip
285 mSeamless = mMediaNotifView.findViewById(R.id.media_seamless);
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400286 if (mSeamless != null && mLocalMediaManager != null) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500287 mSeamless.setVisibility(View.VISIBLE);
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400288 updateDevice(mLocalMediaManager.getCurrentConnectedDevice());
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500289 ActivityStarter mActivityStarter = Dependency.get(ActivityStarter.class);
290 mSeamless.setOnClickListener(v -> {
291 final Intent intent = new Intent()
292 .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
293 .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
294 mController.getPackageName())
295 .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
296 mActivityStarter.startActivity(intent, false, true /* dismissShade */,
297 Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
298 });
299 }
300
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400301 makeActive();
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500302 }
303
304 /**
305 * Return the token for the current media session
306 * @return the token
307 */
308 public MediaSession.Token getMediaSessionToken() {
309 return mToken;
310 }
311
312 /**
313 * Get the current media controller
314 * @return the controller
315 */
316 public MediaController getController() {
317 return mController;
318 }
319
320 /**
321 * Get the name of the package associated with the current media controller
322 * @return the package name
323 */
324 public String getMediaPlayerPackage() {
325 return mController.getPackageName();
326 }
327
328 /**
329 * Check whether this player has an attached media session.
330 * @return whether there is a controller with a current media session.
331 */
332 public boolean hasMediaSession() {
333 return mController != null && mController.getPlaybackState() != null;
334 }
335
336 /**
337 * Check whether the media controlled by this player is currently playing
338 * @return whether it is playing, or false if no controller information
339 */
340 public boolean isPlaying() {
341 return isPlaying(mController);
342 }
343
344 /**
345 * Check whether the given controller is currently playing
346 * @param controller media controller to check
347 * @return whether it is playing, or false if no controller information
348 */
349 protected boolean isPlaying(MediaController controller) {
350 if (controller == null) {
351 return false;
352 }
353
354 PlaybackState state = controller.getPlaybackState();
355 if (state == null) {
356 return false;
357 }
358
359 return (state.getState() == PlaybackState.STATE_PLAYING);
360 }
361
362 /**
363 * Process album art for layout
364 * @param metadata media metadata
365 * @param albumView view to hold the album art
366 */
367 private void processAlbumArt(MediaMetadata metadata, ImageView albumView) {
368 Bitmap albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
369 float radius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius);
370 RoundedBitmapDrawable roundedDrawable = null;
371 if (albumArt != null) {
372 Bitmap original = albumArt.copy(Bitmap.Config.ARGB_8888, true);
373 int albumSize = (int) mContext.getResources().getDimension(
374 R.dimen.qs_media_album_size);
375 Bitmap scaled = Bitmap.createScaledBitmap(original, albumSize, albumSize, false);
376 roundedDrawable = RoundedBitmapDrawableFactory.create(mContext.getResources(), scaled);
377 roundedDrawable.setCornerRadius(radius);
378 } else {
379 Log.e(TAG, "No album art available");
380 }
381
382 // Now that it's resized, update the UI
383 final RoundedBitmapDrawable result = roundedDrawable;
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400384 mForegroundExecutor.execute(() -> {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500385 if (result != null) {
386 albumView.setImageDrawable(result);
387 albumView.setVisibility(View.VISIBLE);
388 } else {
389 albumView.setImageDrawable(null);
390 albumView.setVisibility(View.GONE);
391 }
392 });
393 }
394
395 /**
396 * Update the current device information
397 * @param device device information to display
398 */
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400399 private void updateDevice(MediaDevice device) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500400 if (mSeamless == null) {
401 return;
402 }
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400403 mForegroundExecutor.execute(() -> {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500404 updateChipInternal(device);
405 });
406 }
407
408 private void updateChipInternal(MediaDevice device) {
409 ColorStateList fgTintList = ColorStateList.valueOf(mForegroundColor);
410
411 // Update the outline color
412 LinearLayout viewLayout = (LinearLayout) mSeamless;
413 RippleDrawable bkgDrawable = (RippleDrawable) viewLayout.getBackground();
414 GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0);
415 rect.setStroke(2, mForegroundColor);
416 rect.setColor(mBackgroundColor);
417
418 ImageView iconView = mSeamless.findViewById(R.id.media_seamless_image);
419 TextView deviceName = mSeamless.findViewById(R.id.media_seamless_text);
420 deviceName.setTextColor(fgTintList);
421
422 if (device != null) {
423 Drawable icon = device.getIcon();
424 iconView.setVisibility(View.VISIBLE);
425 iconView.setImageTintList(fgTintList);
426
427 if (icon instanceof AdaptiveIcon) {
428 AdaptiveIcon aIcon = (AdaptiveIcon) icon;
429 aIcon.setBackgroundColor(mBackgroundColor);
430 iconView.setImageDrawable(aIcon);
431 } else {
432 iconView.setImageDrawable(icon);
433 }
434 deviceName.setText(device.getName());
435 } else {
436 // Reset to default
437 iconView.setVisibility(View.GONE);
438 deviceName.setText(com.android.internal.R.string.ext_media_seamless_action);
439 }
440 }
441
442 /**
443 * Put controls into a resumption state
444 */
445 public void clearControls() {
446 // Hide all the old buttons
447 for (int i = 0; i < mActionIds.length; i++) {
448 ImageButton thisBtn = mMediaNotifView.findViewById(mActionIds[i]);
449 if (thisBtn != null) {
450 thisBtn.setVisibility(View.GONE);
451 }
452 }
453
454 // Add a restart button
455 ImageButton btn = mMediaNotifView.findViewById(mActionIds[0]);
456 btn.setOnClickListener(v -> {
457 Log.d(TAG, "Attempting to restart session");
458 // Send a media button event to previously found receiver
459 if (mRecvComponent != null) {
460 Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
461 intent.setComponent(mRecvComponent);
462 int keyCode = KeyEvent.KEYCODE_MEDIA_PLAY;
463 intent.putExtra(
464 Intent.EXTRA_KEY_EVENT,
465 new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
466 mContext.sendBroadcast(intent);
467 } else {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500468 // If we don't have a receiver, try relaunching the activity instead
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400469 if (mController.getSessionActivity() != null) {
470 try {
471 mController.getSessionActivity().send();
472 } catch (PendingIntent.CanceledException e) {
473 Log.e(TAG, "Pending intent was canceled", e);
474 }
475 } else {
476 Log.e(TAG, "No receiver or activity to restart");
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500477 }
478 }
479 });
480 btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play));
481 btn.setImageTintList(ColorStateList.valueOf(mForegroundColor));
482 btn.setVisibility(View.VISIBLE);
483 }
484
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400485 private void makeActive() {
486 Assert.isMainThread();
487 if (!mIsRegistered) {
488 mMediaManager.addCallback(mMediaListener);
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400489 if (mLocalMediaManager != null) {
490 mLocalMediaManager.registerCallback(mDeviceCallback);
491 mLocalMediaManager.startScan();
492 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400493 mIsRegistered = true;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500494 }
495 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400496
497 private void makeInactive() {
498 Assert.isMainThread();
499 if (mIsRegistered) {
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400500 if (mLocalMediaManager != null) {
501 mLocalMediaManager.stopScan();
502 mLocalMediaManager.unregisterCallback(mDeviceCallback);
503 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400504 mMediaManager.removeCallback(mMediaListener);
505 mIsRegistered = false;
506 }
507 }
508
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500509}