1 /* 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 17 package com.android.systemui.media; 18 19 import static android.app.Notification.safeCharSequence; 20 import static android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS; 21 22 import android.app.PendingIntent; 23 import android.content.Context; 24 import android.content.Intent; 25 import android.content.res.ColorStateList; 26 import android.graphics.Outline; 27 import android.graphics.Rect; 28 import android.graphics.drawable.Drawable; 29 import android.graphics.drawable.Icon; 30 import android.media.session.MediaController; 31 import android.media.session.MediaSession; 32 import android.media.session.PlaybackState; 33 import android.util.Log; 34 import android.view.View; 35 import android.view.ViewOutlineProvider; 36 import android.widget.ImageButton; 37 import android.widget.ImageView; 38 import android.widget.TextView; 39 40 import androidx.annotation.NonNull; 41 import androidx.annotation.Nullable; 42 import androidx.annotation.UiThread; 43 import androidx.constraintlayout.widget.ConstraintSet; 44 45 import com.android.settingslib.Utils; 46 import com.android.settingslib.widget.AdaptiveIcon; 47 import com.android.systemui.R; 48 import com.android.systemui.dagger.qualifiers.Background; 49 import com.android.systemui.media.dialog.MediaOutputDialogFactory; 50 import com.android.systemui.plugins.ActivityStarter; 51 import com.android.systemui.statusbar.phone.KeyguardDismissUtil; 52 import com.android.systemui.util.animation.TransitionLayout; 53 54 import java.util.List; 55 import java.util.concurrent.Executor; 56 57 import javax.inject.Inject; 58 59 import dagger.Lazy; 60 61 /** 62 * A view controller used for Media Playback. 63 */ 64 public class MediaControlPanel { 65 private static final String TAG = "MediaControlPanel"; 66 private static final float DISABLED_ALPHA = 0.38f; 67 68 private static final Intent SETTINGS_INTENT = new Intent(ACTION_MEDIA_CONTROLS_SETTINGS); 69 70 // Button IDs for QS controls 71 static final int[] ACTION_IDS = { 72 R.id.action0, 73 R.id.action1, 74 R.id.action2, 75 R.id.action3, 76 R.id.action4 77 }; 78 79 private final SeekBarViewModel mSeekBarViewModel; 80 private SeekBarObserver mSeekBarObserver; 81 protected final Executor mBackgroundExecutor; 82 private final ActivityStarter mActivityStarter; 83 84 private Context mContext; 85 private PlayerViewHolder mViewHolder; 86 private String mKey; 87 private MediaViewController mMediaViewController; 88 private MediaSession.Token mToken; 89 private MediaController mController; 90 private KeyguardDismissUtil mKeyguardDismissUtil; 91 private Lazy<MediaDataManager> mMediaDataManagerLazy; 92 private int mBackgroundColor; 93 private int mAlbumArtSize; 94 private int mAlbumArtRadius; 95 // This will provide the corners for the album art. 96 private final ViewOutlineProvider mViewOutlineProvider; 97 private final MediaOutputDialogFactory mMediaOutputDialogFactory; 98 /** 99 * Initialize a new control panel 100 * @param context 101 * @param backgroundExecutor background executor, used for processing artwork 102 * @param activityStarter activity starter 103 */ 104 @Inject MediaControlPanel(Context context, @Background Executor backgroundExecutor, ActivityStarter activityStarter, MediaViewController mediaViewController, SeekBarViewModel seekBarViewModel, Lazy<MediaDataManager> lazyMediaDataManager, KeyguardDismissUtil keyguardDismissUtil, MediaOutputDialogFactory mediaOutputDialogFactory)105 public MediaControlPanel(Context context, @Background Executor backgroundExecutor, 106 ActivityStarter activityStarter, MediaViewController mediaViewController, 107 SeekBarViewModel seekBarViewModel, Lazy<MediaDataManager> lazyMediaDataManager, 108 KeyguardDismissUtil keyguardDismissUtil, MediaOutputDialogFactory 109 mediaOutputDialogFactory) { 110 mContext = context; 111 mBackgroundExecutor = backgroundExecutor; 112 mActivityStarter = activityStarter; 113 mSeekBarViewModel = seekBarViewModel; 114 mMediaViewController = mediaViewController; 115 mMediaDataManagerLazy = lazyMediaDataManager; 116 mKeyguardDismissUtil = keyguardDismissUtil; 117 mMediaOutputDialogFactory = mediaOutputDialogFactory; 118 loadDimens(); 119 120 mViewOutlineProvider = new ViewOutlineProvider() { 121 @Override 122 public void getOutline(View view, Outline outline) { 123 outline.setRoundRect(0, 0, mAlbumArtSize, mAlbumArtSize, mAlbumArtRadius); 124 } 125 }; 126 } 127 onDestroy()128 public void onDestroy() { 129 if (mSeekBarObserver != null) { 130 mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver); 131 } 132 mSeekBarViewModel.onDestroy(); 133 mMediaViewController.onDestroy(); 134 } 135 loadDimens()136 private void loadDimens() { 137 mAlbumArtRadius = mContext.getResources().getDimensionPixelSize( 138 Utils.getThemeAttr(mContext, android.R.attr.dialogCornerRadius)); 139 mAlbumArtSize = mContext.getResources().getDimensionPixelSize(R.dimen.qs_media_album_size); 140 } 141 142 /** 143 * Get the view holder used to display media controls 144 * @return the view holder 145 */ 146 @Nullable getView()147 public PlayerViewHolder getView() { 148 return mViewHolder; 149 } 150 151 /** 152 * Get the view controller used to display media controls 153 * @return the media view controller 154 */ 155 @NonNull getMediaViewController()156 public MediaViewController getMediaViewController() { 157 return mMediaViewController; 158 } 159 160 /** 161 * Sets the listening state of the player. 162 * 163 * Should be set to true when the QS panel is open. Otherwise, false. This is a signal to avoid 164 * unnecessary work when the QS panel is closed. 165 * 166 * @param listening True when player should be active. Otherwise, false. 167 */ setListening(boolean listening)168 public void setListening(boolean listening) { 169 mSeekBarViewModel.setListening(listening); 170 } 171 172 /** 173 * Get the context 174 * @return context 175 */ getContext()176 public Context getContext() { 177 return mContext; 178 } 179 180 /** Attaches the player to the view holder. */ attach(PlayerViewHolder vh)181 public void attach(PlayerViewHolder vh) { 182 mViewHolder = vh; 183 TransitionLayout player = vh.getPlayer(); 184 185 ImageView albumView = vh.getAlbumView(); 186 albumView.setOutlineProvider(mViewOutlineProvider); 187 albumView.setClipToOutline(true); 188 189 mSeekBarObserver = new SeekBarObserver(vh); 190 mSeekBarViewModel.getProgress().observeForever(mSeekBarObserver); 191 mSeekBarViewModel.attachTouchHandlers(vh.getSeekBar()); 192 mMediaViewController.attach(player); 193 194 mViewHolder.getPlayer().setOnLongClickListener(v -> { 195 if (!mMediaViewController.isGutsVisible()) { 196 mMediaViewController.openGuts(); 197 return true; 198 } else { 199 return false; 200 } 201 }); 202 mViewHolder.getCancel().setOnClickListener(v -> { 203 closeGuts(); 204 }); 205 mViewHolder.getSettings().setOnClickListener(v -> { 206 mActivityStarter.startActivity(SETTINGS_INTENT, true /* dismissShade */); 207 }); 208 } 209 210 /** 211 * Bind this view based on the data given 212 */ bind(@onNull MediaData data, String key)213 public void bind(@NonNull MediaData data, String key) { 214 if (mViewHolder == null) { 215 return; 216 } 217 mKey = key; 218 MediaSession.Token token = data.getToken(); 219 mBackgroundColor = data.getBackgroundColor(); 220 if (mToken == null || !mToken.equals(token)) { 221 mToken = token; 222 } 223 224 if (mToken != null) { 225 mController = new MediaController(mContext, mToken); 226 } else { 227 mController = null; 228 } 229 230 ConstraintSet expandedSet = mMediaViewController.getExpandedLayout(); 231 ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout(); 232 233 mViewHolder.getPlayer().setBackgroundTintList( 234 ColorStateList.valueOf(mBackgroundColor)); 235 236 // Click action 237 PendingIntent clickIntent = data.getClickIntent(); 238 if (clickIntent != null) { 239 mViewHolder.getPlayer().setOnClickListener(v -> { 240 if (mMediaViewController.isGutsVisible()) return; 241 mActivityStarter.postStartActivityDismissingKeyguard(clickIntent); 242 }); 243 } 244 245 ImageView albumView = mViewHolder.getAlbumView(); 246 boolean hasArtwork = data.getArtwork() != null; 247 if (hasArtwork) { 248 Drawable artwork = scaleDrawable(data.getArtwork()); 249 albumView.setImageDrawable(artwork); 250 } 251 setVisibleAndAlpha(collapsedSet, R.id.album_art, hasArtwork); 252 setVisibleAndAlpha(expandedSet, R.id.album_art, hasArtwork); 253 254 // App icon 255 ImageView appIcon = mViewHolder.getAppIcon(); 256 if (data.getAppIcon() != null) { 257 appIcon.setImageDrawable(data.getAppIcon()); 258 } else { 259 Drawable iconDrawable = mContext.getDrawable(R.drawable.ic_music_note); 260 appIcon.setImageDrawable(iconDrawable); 261 } 262 263 // Song name 264 TextView titleText = mViewHolder.getTitleText(); 265 titleText.setText(safeCharSequence(data.getSong())); 266 267 // App title 268 TextView appName = mViewHolder.getAppName(); 269 appName.setText(data.getApp()); 270 271 // Artist name 272 TextView artistText = mViewHolder.getArtistText(); 273 artistText.setText(safeCharSequence(data.getArtist())); 274 275 // Transfer chip 276 mViewHolder.getSeamless().setVisibility(View.VISIBLE); 277 setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */); 278 setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */); 279 mViewHolder.getSeamless().setOnClickListener(v -> { 280 mMediaOutputDialogFactory.create(data.getPackageName(), true); 281 }); 282 283 ImageView iconView = mViewHolder.getSeamlessIcon(); 284 TextView deviceName = mViewHolder.getSeamlessText(); 285 286 final MediaDeviceData device = data.getDevice(); 287 final int seamlessId = mViewHolder.getSeamless().getId(); 288 final int seamlessFallbackId = mViewHolder.getSeamlessFallback().getId(); 289 final boolean showFallback = device != null && !device.getEnabled(); 290 final int seamlessFallbackVisibility = showFallback ? View.VISIBLE : View.GONE; 291 mViewHolder.getSeamlessFallback().setVisibility(seamlessFallbackVisibility); 292 expandedSet.setVisibility(seamlessFallbackId, seamlessFallbackVisibility); 293 collapsedSet.setVisibility(seamlessFallbackId, seamlessFallbackVisibility); 294 final int seamlessVisibility = showFallback ? View.GONE : View.VISIBLE; 295 mViewHolder.getSeamless().setVisibility(seamlessVisibility); 296 expandedSet.setVisibility(seamlessId, seamlessVisibility); 297 collapsedSet.setVisibility(seamlessId, seamlessVisibility); 298 final float seamlessAlpha = data.getResumption() ? DISABLED_ALPHA : 1.0f; 299 expandedSet.setAlpha(seamlessId, seamlessAlpha); 300 collapsedSet.setAlpha(seamlessId, seamlessAlpha); 301 // Disable clicking on output switcher for resumption controls. 302 mViewHolder.getSeamless().setEnabled(!data.getResumption()); 303 if (showFallback) { 304 iconView.setImageDrawable(null); 305 deviceName.setText(null); 306 } else if (device != null) { 307 Drawable icon = device.getIcon(); 308 iconView.setVisibility(View.VISIBLE); 309 if (icon instanceof AdaptiveIcon) { 310 AdaptiveIcon aIcon = (AdaptiveIcon) icon; 311 aIcon.setBackgroundColor(mBackgroundColor); 312 iconView.setImageDrawable(aIcon); 313 } else { 314 iconView.setImageDrawable(icon); 315 } 316 deviceName.setText(device.getName()); 317 } else { 318 // Reset to default 319 Log.w(TAG, "device is null. Not binding output chip."); 320 iconView.setVisibility(View.GONE); 321 deviceName.setText(com.android.internal.R.string.ext_media_seamless_action); 322 } 323 324 List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact(); 325 // Media controls 326 int i = 0; 327 List<MediaAction> actionIcons = data.getActions(); 328 for (; i < actionIcons.size() && i < ACTION_IDS.length; i++) { 329 int actionId = ACTION_IDS[i]; 330 final ImageButton button = mViewHolder.getAction(actionId); 331 MediaAction mediaAction = actionIcons.get(i); 332 button.setImageDrawable(mediaAction.getDrawable()); 333 button.setContentDescription(mediaAction.getContentDescription()); 334 Runnable action = mediaAction.getAction(); 335 336 if (action == null) { 337 button.setEnabled(false); 338 } else { 339 button.setEnabled(true); 340 button.setOnClickListener(v -> { 341 action.run(); 342 }); 343 } 344 boolean visibleInCompat = actionsWhenCollapsed.contains(i); 345 setVisibleAndAlpha(collapsedSet, actionId, visibleInCompat); 346 setVisibleAndAlpha(expandedSet, actionId, true /*visible */); 347 } 348 349 // Hide any unused buttons 350 for (; i < ACTION_IDS.length; i++) { 351 setVisibleAndAlpha(expandedSet, ACTION_IDS[i], false /*visible */); 352 setVisibleAndAlpha(collapsedSet, ACTION_IDS[i], false /*visible */); 353 } 354 355 // Seek Bar 356 final MediaController controller = getController(); 357 mBackgroundExecutor.execute(() -> mSeekBarViewModel.updateController(controller)); 358 359 // Guts label 360 boolean isDismissible = data.isClearable(); 361 mViewHolder.getSettingsText().setText(isDismissible 362 ? R.string.controls_media_close_session 363 : R.string.controls_media_active_session); 364 365 // Dismiss 366 mViewHolder.getDismissLabel().setAlpha(isDismissible ? 1 : DISABLED_ALPHA); 367 mViewHolder.getDismiss().setEnabled(isDismissible); 368 mViewHolder.getDismiss().setOnClickListener(v -> { 369 if (mKey != null) { 370 closeGuts(); 371 mKeyguardDismissUtil.executeWhenUnlocked(() -> { 372 mMediaDataManagerLazy.get().dismissMediaData(mKey, 373 MediaViewController.GUTS_ANIMATION_DURATION + 100); 374 return true; 375 }, /* requiresShadeOpen */ true); 376 } else { 377 Log.w(TAG, "Dismiss media with null notification. Token uid=" 378 + data.getToken().getUid()); 379 } 380 }); 381 382 // TODO: We don't need to refresh this state constantly, only if the state actually changed 383 // to something which might impact the measurement 384 mMediaViewController.refreshState(); 385 } 386 387 /** 388 * Close the guts for this player. 389 * @param immediate {@code true} if it should be closed without animation 390 */ closeGuts(boolean immediate)391 public void closeGuts(boolean immediate) { 392 mMediaViewController.closeGuts(immediate); 393 } 394 closeGuts()395 private void closeGuts() { 396 closeGuts(false); 397 } 398 399 @UiThread scaleDrawable(Icon icon)400 private Drawable scaleDrawable(Icon icon) { 401 if (icon == null) { 402 return null; 403 } 404 // Let's scale down the View, such that the content always nicely fills the view. 405 // ThumbnailUtils actually scales it down such that it may not be filled for odd aspect 406 // ratios 407 Drawable drawable = icon.loadDrawable(mContext); 408 float aspectRatio = drawable.getIntrinsicHeight() / (float) drawable.getIntrinsicWidth(); 409 Rect bounds; 410 if (aspectRatio > 1.0f) { 411 bounds = new Rect(0, 0, mAlbumArtSize, (int) (mAlbumArtSize * aspectRatio)); 412 } else { 413 bounds = new Rect(0, 0, (int) (mAlbumArtSize / aspectRatio), mAlbumArtSize); 414 } 415 if (bounds.width() > mAlbumArtSize || bounds.height() > mAlbumArtSize) { 416 float offsetX = (bounds.width() - mAlbumArtSize) / 2.0f; 417 float offsetY = (bounds.height() - mAlbumArtSize) / 2.0f; 418 bounds.offset((int) -offsetX,(int) -offsetY); 419 } 420 drawable.setBounds(bounds); 421 return drawable; 422 } 423 424 /** 425 * Get the current media controller 426 * @return the controller 427 */ getController()428 public MediaController getController() { 429 return mController; 430 } 431 432 /** 433 * Check whether the media controlled by this player is currently playing 434 * @return whether it is playing, or false if no controller information 435 */ isPlaying()436 public boolean isPlaying() { 437 return isPlaying(mController); 438 } 439 440 /** 441 * Check whether the given controller is currently playing 442 * @param controller media controller to check 443 * @return whether it is playing, or false if no controller information 444 */ isPlaying(MediaController controller)445 protected boolean isPlaying(MediaController controller) { 446 if (controller == null) { 447 return false; 448 } 449 450 PlaybackState state = controller.getPlaybackState(); 451 if (state == null) { 452 return false; 453 } 454 455 return (state.getState() == PlaybackState.STATE_PLAYING); 456 } 457 setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible)458 private void setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible) { 459 set.setVisibility(actionId, visible? ConstraintSet.VISIBLE : ConstraintSet.GONE); 460 set.setAlpha(actionId, visible ? 1.0f : 0.0f); 461 } 462 } 463