blob: e5c0bf7d4453344013752b65e62ed95eb880ec3f [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;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040058import 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";
Robert Snoeberger9a7409b2020-04-09 18:12:27 -040068 @Nullable private final LocalMediaManager mLocalMediaManager;
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 Snoeberger9a7409b2020-04-09 18:12:27 -040080 private MediaDevice mDevice;
Robert Snoeberger3cc22222020-03-25 15:36:31 -040081 private boolean mIsRegistered = false;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050082
83 private final int[] mActionIds;
84
85 // Button IDs used in notifications
86 protected static final int[] NOTIF_ACTION_IDS = {
87 com.android.internal.R.id.action0,
88 com.android.internal.R.id.action1,
89 com.android.internal.R.id.action2,
90 com.android.internal.R.id.action3,
91 com.android.internal.R.id.action4
92 };
93
Robert Snoeberger3cc22222020-03-25 15:36:31 -040094 private final MediaController.Callback mSessionCallback = new MediaController.Callback() {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -050095 @Override
96 public void onSessionDestroyed() {
97 Log.d(TAG, "session destroyed");
98 mController.unregisterCallback(mSessionCallback);
99 clearControls();
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400100 makeInactive();
101 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400102 @Override
Robert Snoeberger445d4412020-04-15 00:03:13 -0400103 public void onPlaybackStateChanged(PlaybackState state) {
104 final int s = state != null ? state.getState() : PlaybackState.STATE_NONE;
105 // When the playback state is NONE or CONNECTING, transition the player to the
106 // resumption state. State CONNECTING needs to be considered for Cast sessions. Ending
107 // a cast session in YT results in the CONNECTING state, which makes sense if you
108 // thinking of the session as waiting to connect to another cast device.
109 if (s == PlaybackState.STATE_NONE || s == PlaybackState.STATE_CONNECTING) {
110 Log.d(TAG, "playback state change will trigger resumption, state=" + state);
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400111 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 */
Robert Snoeberger445d4412020-04-15 00:03:13 -0400163 public MediaControlPanel(Context context, ViewGroup parent,
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);
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400175 mLocalMediaManager = routeManager;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500176 mActionIds = actionIds;
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400177 mForegroundExecutor = foregroundExecutor;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500178 mBackgroundExecutor = backgroundExecutor;
179 }
180
181 /**
182 * Get the view used to display media controls
183 * @return the view
184 */
185 public View getView() {
186 return mMediaNotifView;
187 }
188
189 /**
190 * Get the context
191 * @return context
192 */
193 public Context getContext() {
194 return mContext;
195 }
196
197 /**
198 * Update the media panel view for the given media session
199 * @param token
200 * @param icon
201 * @param iconColor
202 * @param bgColor
203 * @param contentIntent
204 * @param appNameString
205 * @param device
206 */
207 public void setMediaSession(MediaSession.Token token, Icon icon, int iconColor,
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400208 int bgColor, PendingIntent contentIntent, String appNameString) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500209 mToken = token;
210 mForegroundColor = iconColor;
211 mBackgroundColor = bgColor;
212 mController = new MediaController(mContext, mToken);
213
214 MediaMetadata mediaMetadata = mController.getMetadata();
215
216 // Try to find a receiver for the media button that matches this app
217 PackageManager pm = mContext.getPackageManager();
218 Intent it = new Intent(Intent.ACTION_MEDIA_BUTTON);
219 List<ResolveInfo> info = pm.queryBroadcastReceiversAsUser(it, 0, mContext.getUser());
220 if (info != null) {
221 for (ResolveInfo inf : info) {
222 if (inf.activityInfo.packageName.equals(mController.getPackageName())) {
223 mRecvComponent = inf.getComponentInfo().getComponentName();
224 }
225 }
226 }
227
228 mController.registerCallback(mSessionCallback);
229
230 if (mediaMetadata == null) {
231 Log.e(TAG, "Media metadata was null");
232 return;
233 }
234
235 ImageView albumView = mMediaNotifView.findViewById(R.id.album_art);
236 if (albumView != null) {
237 // Resize art in a background thread
238 mBackgroundExecutor.execute(() -> processAlbumArt(mediaMetadata, albumView));
239 }
240 mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor));
241
242 // Click action
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400243 if (contentIntent != null) {
244 mMediaNotifView.setOnClickListener(v -> {
245 try {
246 contentIntent.send();
247 // Also close shade
248 mContext.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
249 } catch (PendingIntent.CanceledException e) {
250 Log.e(TAG, "Pending intent was canceled", e);
251 }
252 });
253 }
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500254
255 // App icon
256 ImageView appIcon = mMediaNotifView.findViewById(R.id.icon);
257 Drawable iconDrawable = icon.loadDrawable(mContext);
258 iconDrawable.setTint(mForegroundColor);
259 appIcon.setImageDrawable(iconDrawable);
260
261 // Song name
262 TextView titleText = mMediaNotifView.findViewById(R.id.header_title);
263 String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);
264 titleText.setText(songName);
265 titleText.setTextColor(mForegroundColor);
266
267 // Not in mini player:
268 // App title
269 TextView appName = mMediaNotifView.findViewById(R.id.app_name);
270 if (appName != null) {
271 appName.setText(appNameString);
272 appName.setTextColor(mForegroundColor);
273 }
274
275 // Artist name
276 TextView artistText = mMediaNotifView.findViewById(R.id.header_artist);
277 if (artistText != null) {
278 String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
279 artistText.setText(artistName);
280 artistText.setTextColor(mForegroundColor);
281 }
282
283 // Transfer chip
284 mSeamless = mMediaNotifView.findViewById(R.id.media_seamless);
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400285 if (mSeamless != null && mLocalMediaManager != null) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500286 mSeamless.setVisibility(View.VISIBLE);
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400287 updateDevice(mLocalMediaManager.getCurrentConnectedDevice());
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500288 ActivityStarter mActivityStarter = Dependency.get(ActivityStarter.class);
289 mSeamless.setOnClickListener(v -> {
290 final Intent intent = new Intent()
291 .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT)
292 .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME,
293 mController.getPackageName())
294 .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken);
295 mActivityStarter.startActivity(intent, false, true /* dismissShade */,
296 Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
297 });
298 }
299
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400300 makeActive();
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500301 }
302
303 /**
304 * Return the token for the current media session
305 * @return the token
306 */
307 public MediaSession.Token getMediaSessionToken() {
308 return mToken;
309 }
310
311 /**
312 * Get the current media controller
313 * @return the controller
314 */
315 public MediaController getController() {
316 return mController;
317 }
318
319 /**
320 * Get the name of the package associated with the current media controller
321 * @return the package name
322 */
323 public String getMediaPlayerPackage() {
324 return mController.getPackageName();
325 }
326
327 /**
328 * Check whether this player has an attached media session.
329 * @return whether there is a controller with a current media session.
330 */
331 public boolean hasMediaSession() {
332 return mController != null && mController.getPlaybackState() != null;
333 }
334
335 /**
336 * Check whether the media controlled by this player is currently playing
337 * @return whether it is playing, or false if no controller information
338 */
339 public boolean isPlaying() {
340 return isPlaying(mController);
341 }
342
343 /**
344 * Check whether the given controller is currently playing
345 * @param controller media controller to check
346 * @return whether it is playing, or false if no controller information
347 */
348 protected boolean isPlaying(MediaController controller) {
349 if (controller == null) {
350 return false;
351 }
352
353 PlaybackState state = controller.getPlaybackState();
354 if (state == null) {
355 return false;
356 }
357
358 return (state.getState() == PlaybackState.STATE_PLAYING);
359 }
360
361 /**
362 * Process album art for layout
363 * @param metadata media metadata
364 * @param albumView view to hold the album art
365 */
366 private void processAlbumArt(MediaMetadata metadata, ImageView albumView) {
367 Bitmap albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
368 float radius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius);
369 RoundedBitmapDrawable roundedDrawable = null;
370 if (albumArt != null) {
371 Bitmap original = albumArt.copy(Bitmap.Config.ARGB_8888, true);
372 int albumSize = (int) mContext.getResources().getDimension(
373 R.dimen.qs_media_album_size);
374 Bitmap scaled = Bitmap.createScaledBitmap(original, albumSize, albumSize, false);
375 roundedDrawable = RoundedBitmapDrawableFactory.create(mContext.getResources(), scaled);
376 roundedDrawable.setCornerRadius(radius);
377 } else {
378 Log.e(TAG, "No album art available");
379 }
380
381 // Now that it's resized, update the UI
382 final RoundedBitmapDrawable result = roundedDrawable;
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400383 mForegroundExecutor.execute(() -> {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500384 if (result != null) {
385 albumView.setImageDrawable(result);
386 albumView.setVisibility(View.VISIBLE);
387 } else {
388 albumView.setImageDrawable(null);
389 albumView.setVisibility(View.GONE);
390 }
391 });
392 }
393
394 /**
395 * Update the current device information
396 * @param device device information to display
397 */
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400398 private void updateDevice(MediaDevice device) {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500399 if (mSeamless == null) {
400 return;
401 }
Beth Thibodeaua51c3142020-03-17 17:27:04 -0400402 mForegroundExecutor.execute(() -> {
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500403 updateChipInternal(device);
404 });
405 }
406
407 private void updateChipInternal(MediaDevice device) {
408 ColorStateList fgTintList = ColorStateList.valueOf(mForegroundColor);
409
410 // Update the outline color
411 LinearLayout viewLayout = (LinearLayout) mSeamless;
412 RippleDrawable bkgDrawable = (RippleDrawable) viewLayout.getBackground();
413 GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0);
414 rect.setStroke(2, mForegroundColor);
415 rect.setColor(mBackgroundColor);
416
417 ImageView iconView = mSeamless.findViewById(R.id.media_seamless_image);
418 TextView deviceName = mSeamless.findViewById(R.id.media_seamless_text);
419 deviceName.setTextColor(fgTintList);
420
421 if (device != null) {
422 Drawable icon = device.getIcon();
423 iconView.setVisibility(View.VISIBLE);
424 iconView.setImageTintList(fgTintList);
425
426 if (icon instanceof AdaptiveIcon) {
427 AdaptiveIcon aIcon = (AdaptiveIcon) icon;
428 aIcon.setBackgroundColor(mBackgroundColor);
429 iconView.setImageDrawable(aIcon);
430 } else {
431 iconView.setImageDrawable(icon);
432 }
433 deviceName.setText(device.getName());
434 } else {
435 // Reset to default
436 iconView.setVisibility(View.GONE);
437 deviceName.setText(com.android.internal.R.string.ext_media_seamless_action);
438 }
439 }
440
441 /**
442 * Put controls into a resumption state
443 */
444 public void clearControls() {
Robert Snoeberger445d4412020-04-15 00:03:13 -0400445 Log.d(TAG, "clearControls to resumption state package=" + getMediaPlayerPackage());
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500446 // 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) {
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400488 if (mLocalMediaManager != null) {
489 mLocalMediaManager.registerCallback(mDeviceCallback);
490 mLocalMediaManager.startScan();
491 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400492 mIsRegistered = true;
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500493 }
494 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400495
496 private void makeInactive() {
497 Assert.isMainThread();
498 if (mIsRegistered) {
Robert Snoeberger9a7409b2020-04-09 18:12:27 -0400499 if (mLocalMediaManager != null) {
500 mLocalMediaManager.stopScan();
501 mLocalMediaManager.unregisterCallback(mDeviceCallback);
502 }
Robert Snoeberger3cc22222020-03-25 15:36:31 -0400503 mIsRegistered = false;
504 }
505 }
506
Beth Thibodeau7b6c1782020-03-05 11:43:51 -0500507}