1 /* 2 * Copyright 2018 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.car.media.common; 18 19 import android.annotation.IntDef; 20 import android.annotation.NonNull; 21 import android.annotation.Nullable; 22 import android.content.Context; 23 import android.content.pm.PackageManager; 24 import android.content.res.Resources; 25 import android.graphics.drawable.Drawable; 26 import android.media.MediaMetadata; 27 import android.media.Rating; 28 import android.media.session.MediaController; 29 import android.media.session.MediaController.TransportControls; 30 import android.media.session.MediaSession; 31 import android.media.session.PlaybackState; 32 import android.media.session.PlaybackState.Actions; 33 import android.os.Bundle; 34 import android.os.Handler; 35 import android.os.SystemClock; 36 import android.util.Log; 37 38 import java.lang.annotation.Retention; 39 import java.lang.annotation.RetentionPolicy; 40 import java.util.ArrayList; 41 import java.util.List; 42 import java.util.function.Consumer; 43 import java.util.stream.Collectors; 44 45 /** 46 * Wrapper of {@link MediaSession}. It provides access to media session events and extended 47 * information on the currently playing item metadata. 48 */ 49 public class PlaybackModel { 50 private static final String TAG = "PlaybackModel"; 51 52 private static final String ACTION_SET_RATING = 53 "com.android.car.media.common.ACTION_SET_RATING"; 54 private static final String EXTRA_SET_HEART = "com.android.car.media.common.EXTRA_SET_HEART"; 55 56 private final Handler mHandler = new Handler(); 57 @Nullable 58 private final Context mContext; 59 private final List<PlaybackObserver> mObservers = new ArrayList<>(); 60 private MediaController mMediaController; 61 private MediaSource mMediaSource; 62 private boolean mIsStarted; 63 64 /** 65 * An observer of this model 66 */ 67 public abstract static class PlaybackObserver { 68 /** 69 * Called whenever the playback state of the current media item changes. 70 */ 71 protected void onPlaybackStateChanged() {}; 72 73 /** 74 * Called when the top source media app changes. 75 */ 76 protected void onSourceChanged() {}; 77 78 /** 79 * Called when the media item being played changes. 80 */ 81 protected void onMetadataChanged() {}; 82 } 83 84 private MediaController.Callback mCallback = new MediaController.Callback() { 85 @Override 86 public void onPlaybackStateChanged(PlaybackState state) { 87 if (Log.isLoggable(TAG, Log.DEBUG)) { 88 Log.d(TAG, "onPlaybackStateChanged: " + state); 89 } 90 PlaybackModel.this.notify(PlaybackObserver::onPlaybackStateChanged); 91 } 92 93 @Override 94 public void onMetadataChanged(MediaMetadata metadata) { 95 if (Log.isLoggable(TAG, Log.DEBUG)) { 96 Log.d(TAG, "onMetadataChanged: " + metadata); 97 } 98 PlaybackModel.this.notify(PlaybackObserver::onMetadataChanged); 99 } 100 }; 101 102 /** 103 * Creates a {@link PlaybackModel} 104 */ 105 public PlaybackModel(@NonNull Context context) { 106 this(context, null); 107 } 108 109 /** 110 * Creates a {@link PlaybackModel} wrapping to the given media controller 111 */ 112 public PlaybackModel(@NonNull Context context, @Nullable MediaController controller) { 113 mContext = context; 114 changeMediaController(controller); 115 } 116 117 /** 118 * Sets the {@link MediaController} wrapped by this model. 119 */ 120 public void setMediaController(@Nullable MediaController mediaController) { 121 changeMediaController(mediaController); 122 } 123 124 private void changeMediaController(@Nullable MediaController mediaController) { 125 if (Log.isLoggable(TAG, Log.DEBUG)) { 126 Log.d(TAG, "New media controller: " + (mediaController != null 127 ? mediaController.getPackageName() : null)); 128 } 129 if ((mediaController == null && mMediaController == null) 130 || (mediaController != null && mMediaController != null 131 && mediaController.getPackageName().equals(mMediaController.getPackageName()))) { 132 // If no change, do nothing. 133 return; 134 } 135 if (mMediaController != null) { 136 mMediaController.unregisterCallback(mCallback); 137 } 138 mMediaController = mediaController; 139 mMediaSource = mMediaController != null 140 ? new MediaSource(mContext, mMediaController.getPackageName()) : null; 141 if (mMediaController != null && mIsStarted) { 142 mMediaController.registerCallback(mCallback); 143 } 144 if (mIsStarted) { 145 notify(PlaybackObserver::onSourceChanged); 146 } 147 } 148 149 /** 150 * Starts following changes on the playback state of the given source. If any changes happen, 151 * all observers registered through {@link #registerObserver(PlaybackObserver)} will be 152 * notified. 153 */ 154 private void start() { 155 if (mMediaController != null) { 156 mMediaController.registerCallback(mCallback); 157 } 158 mIsStarted = true; 159 } 160 161 /** 162 * Stops following changes on the list of active media sources. 163 */ 164 private void stop() { 165 if (mMediaController != null) { 166 mMediaController.unregisterCallback(mCallback); 167 } 168 mIsStarted = false; 169 } 170 171 private void notify(Consumer<PlaybackObserver> notification) { 172 mHandler.post(() -> { 173 List<PlaybackObserver> observers = new ArrayList<>(mObservers); 174 for (PlaybackObserver observer : observers) { 175 notification.accept(observer); 176 } 177 }); 178 } 179 180 /** 181 * @return a {@link MediaSource} providing access to metadata of the currently playing media 182 * source, or NULL if the media source has no active session. 183 */ 184 @Nullable 185 public MediaSource getMediaSource() { 186 return mMediaSource; 187 } 188 189 /** 190 * @return a {@link MediaController} that can be used to control this media source, or NULL 191 * if the media source has no active session. 192 */ 193 @Nullable 194 public MediaController getMediaController() { 195 return mMediaController; 196 } 197 198 /** 199 * @return {@link Action} selected as the main action for the current media item, based on the 200 * current playback state and the available actions reported by the media source. 201 * Changes on this value will be notified through 202 * {@link PlaybackObserver#onPlaybackStateChanged()} 203 */ 204 @Action 205 public int getMainAction() { 206 return getMainAction(mMediaController != null ? mMediaController.getPlaybackState() : null); 207 } 208 209 /** 210 * @return {@link MediaItemMetadata} of the currently selected media item in the media source. 211 * Changes on this value will be notified through {@link PlaybackObserver#onMetadataChanged()} 212 */ 213 @Nullable 214 public MediaItemMetadata getMetadata() { 215 if (mMediaController == null) { 216 return null; 217 } 218 MediaMetadata metadata = mMediaController.getMetadata(); 219 if (metadata == null) { 220 return null; 221 } 222 return new MediaItemMetadata(metadata); 223 } 224 225 /** 226 * @return duration of the media item, in milliseconds. The current position in this duration 227 * can be obtained by calling {@link #getProgress()}. 228 * Changes on this value will be notified through {@link PlaybackObserver#onMetadataChanged()} 229 */ 230 public long getMaxProgress() { 231 if (mMediaController == null || mMediaController.getMetadata() == null) { 232 return 0; 233 } else { 234 return mMediaController.getMetadata() 235 .getLong(MediaMetadata.METADATA_KEY_DURATION); 236 } 237 } 238 239 /** 240 * Sends a 'play' command to the media source 241 */ 242 public void onPlay() { 243 if (mMediaController != null) { 244 mMediaController.getTransportControls().play(); 245 } 246 } 247 248 /** 249 * Sends a 'skip previews' command to the media source 250 */ 251 public void onSkipPreviews() { 252 if (mMediaController != null) { 253 mMediaController.getTransportControls().skipToPrevious(); 254 } 255 } 256 257 /** 258 * Sends a 'skip next' command to the media source 259 */ 260 public void onSkipNext() { 261 if (mMediaController != null) { 262 mMediaController.getTransportControls().skipToNext(); 263 } 264 } 265 266 /** 267 * Sends a 'pause' command to the media source 268 */ 269 public void onPause() { 270 if (mMediaController != null) { 271 mMediaController.getTransportControls().pause(); 272 } 273 } 274 275 /** 276 * Sends a 'stop' command to the media source 277 */ 278 public void onStop() { 279 if (mMediaController != null) { 280 mMediaController.getTransportControls().stop(); 281 } 282 } 283 284 /** 285 * Sends a custom action to the media source 286 * @param action identifier of the custom action 287 * @param extras additional data to send to the media source. 288 */ 289 public void onCustomAction(String action, Bundle extras) { 290 if (mMediaController == null) return; 291 TransportControls cntrl = mMediaController.getTransportControls(); 292 293 if (ACTION_SET_RATING.equals(action)) { 294 boolean setHeart = extras != null && extras.getBoolean(EXTRA_SET_HEART, false); 295 cntrl.setRating(Rating.newHeartRating(setHeart)); 296 } else { 297 cntrl.sendCustomAction(action, extras); 298 } 299 300 mMediaController.getTransportControls().sendCustomAction(action, extras); 301 } 302 303 /** 304 * Starts playing a given media item. This id corresponds to {@link MediaItemMetadata#getId()}. 305 */ 306 public void onPlayItem(String mediaItemId) { 307 if (mMediaController != null) { 308 mMediaController.getTransportControls().playFromMediaId(mediaItemId, null); 309 } 310 } 311 312 /** 313 * Skips to a particular item in the media queue. This id is {@link MediaItemMetadata#mQueueId} 314 * of the items obtained through {@link #getQueue()}. 315 */ 316 public void onSkipToQueueItem(long queueId) { 317 if (mMediaController != null) { 318 mMediaController.getTransportControls().skipToQueueItem(queueId); 319 } 320 } 321 322 /** 323 * Prepares the current media source for playback. 324 */ 325 public void onPrepare() { 326 if (mMediaController != null) { 327 mMediaController.getTransportControls().prepare(); 328 } 329 } 330 331 /** 332 * Possible main actions. 333 */ 334 @IntDef({ACTION_PLAY, ACTION_STOP, ACTION_PAUSE, ACTION_DISABLED}) 335 @Retention(RetentionPolicy.SOURCE) 336 public @interface Action {} 337 338 /** Main action is disabled. The source can't play media at this time */ 339 public static final int ACTION_DISABLED = 0; 340 /** Start playing */ 341 public static final int ACTION_PLAY = 1; 342 /** Stop playing */ 343 public static final int ACTION_STOP = 2; 344 /** Pause playing */ 345 public static final int ACTION_PAUSE = 3; 346 347 @Action 348 private static int getMainAction(PlaybackState state) { 349 if (state == null) { 350 return ACTION_DISABLED; 351 } 352 353 @Actions long actions = state.getActions(); 354 int stopAction = ACTION_DISABLED; 355 if ((actions & (PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_PLAY_PAUSE)) != 0) { 356 stopAction = ACTION_PAUSE; 357 } else if ((actions & PlaybackState.ACTION_STOP) != 0) { 358 stopAction = ACTION_STOP; 359 } 360 361 switch (state.getState()) { 362 case PlaybackState.STATE_PLAYING: 363 case PlaybackState.STATE_BUFFERING: 364 case PlaybackState.STATE_CONNECTING: 365 case PlaybackState.STATE_FAST_FORWARDING: 366 case PlaybackState.STATE_REWINDING: 367 case PlaybackState.STATE_SKIPPING_TO_NEXT: 368 case PlaybackState.STATE_SKIPPING_TO_PREVIOUS: 369 case PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM: 370 return stopAction; 371 case PlaybackState.STATE_STOPPED: 372 case PlaybackState.STATE_PAUSED: 373 case PlaybackState.STATE_NONE: 374 return ACTION_PLAY; 375 case PlaybackState.STATE_ERROR: 376 return ACTION_DISABLED; 377 default: 378 Log.w(TAG, String.format("Unknown PlaybackState: %d", state.getState())); 379 return ACTION_DISABLED; 380 } 381 } 382 383 /** 384 * @return the current playback progress, in milliseconds. This is a value between 0 and 385 * {@link #getMaxProgress()} or PROGRESS_UNKNOWN of the current position is unknown. 386 */ 387 public long getProgress() { 388 if (mMediaController == null) { 389 return 0; 390 } 391 PlaybackState state = mMediaController.getPlaybackState(); 392 if (state == null) { 393 return 0; 394 } 395 if (state.getPosition() == PlaybackState.PLAYBACK_POSITION_UNKNOWN) { 396 return PlaybackState.PLAYBACK_POSITION_UNKNOWN; 397 } 398 long timeDiff = SystemClock.elapsedRealtime() - state.getLastPositionUpdateTime(); 399 float speed = state.getPlaybackSpeed(); 400 if (state.getState() == PlaybackState.STATE_PAUSED 401 || state.getState() == PlaybackState.STATE_STOPPED) { 402 // This guards against apps who don't keep their playbackSpeed to spec (b/62375164) 403 speed = 0f; 404 } 405 long posDiff = (long) (timeDiff * speed); 406 return Math.min(posDiff + state.getPosition(), getMaxProgress()); 407 } 408 409 /** 410 * @return true if the current media source is playing a media item. Changes on this value 411 * would be notified through {@link PlaybackObserver#onPlaybackStateChanged()} 412 */ 413 public boolean isPlaying() { 414 return mMediaController != null 415 && mMediaController.getPlaybackState() != null 416 && mMediaController.getPlaybackState().getState() == PlaybackState.STATE_PLAYING; 417 } 418 419 /** 420 * Registers an observer to be notified of media events. If the model is not started yet it 421 * will start right away. If the model was already started, the observer will receive an 422 * immediate {@link PlaybackObserver#onSourceChanged()} event. 423 */ 424 public void registerObserver(PlaybackObserver observer) { 425 mObservers.add(observer); 426 if (!mIsStarted) { 427 start(); 428 } else { 429 observer.onSourceChanged(); 430 } 431 } 432 433 /** 434 * Unregisters an observer previously registered using 435 * {@link #registerObserver(PlaybackObserver)}. There are no other observers the model will 436 * stop tracking changes right away. 437 */ 438 public void unregisterObserver(PlaybackObserver observer) { 439 mObservers.remove(observer); 440 if (mObservers.isEmpty() && mIsStarted) { 441 stop(); 442 } 443 } 444 445 /** 446 * @return true if the media source supports skipping to next item. Changes on this value 447 * will be notified through {@link PlaybackObserver#onPlaybackStateChanged()} 448 */ 449 public boolean isSkipNextEnabled() { 450 return mMediaController != null 451 && mMediaController.getPlaybackState() != null 452 && (mMediaController.getPlaybackState().getActions() 453 & PlaybackState.ACTION_SKIP_TO_NEXT) != 0; 454 } 455 456 /** 457 * @return true if the media source supports skipping to previous item. Changes on this value 458 * will be notified through {@link PlaybackObserver#onPlaybackStateChanged()} 459 */ 460 public boolean isSkipPreviewsEnabled() { 461 return mMediaController != null 462 && mMediaController.getPlaybackState() != null 463 && (mMediaController.getPlaybackState().getActions() 464 & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0; 465 } 466 467 /** 468 * @return true if the media source is buffering. Changes on this value would be notified 469 * through {@link PlaybackObserver#onPlaybackStateChanged()} 470 */ 471 public boolean isBuffering() { 472 return mMediaController != null 473 && mMediaController.getPlaybackState() != null 474 && mMediaController.getPlaybackState().getState() == PlaybackState.STATE_BUFFERING; 475 } 476 477 /** 478 * @return a human readable description of the error that cause the media source to be in a 479 * non-playable state, or null if there is no error. Changes on this value will be notified 480 * through {@link PlaybackObserver#onPlaybackStateChanged()} 481 */ 482 @Nullable 483 public CharSequence getErrorMessage() { 484 return mMediaController != null && mMediaController.getPlaybackState() != null 485 ? mMediaController.getPlaybackState().getErrorMessage() 486 : null; 487 } 488 489 /** 490 * @return a sorted list of {@link MediaItemMetadata} corresponding to the queue of media items 491 * as reported by the media source. Changes on this value will be notified through 492 * {@link PlaybackObserver#onPlaybackStateChanged()}. 493 */ 494 @NonNull 495 public List<MediaItemMetadata> getQueue() { 496 if (mMediaController == null) { 497 return new ArrayList<>(); 498 } 499 List<MediaSession.QueueItem> items = mMediaController.getQueue(); 500 if (items != null) { 501 return items.stream() 502 .filter(item -> item.getDescription() != null 503 && item.getDescription().getTitle() != null) 504 .map(MediaItemMetadata::new) 505 .collect(Collectors.toList()); 506 } else { 507 return new ArrayList<>(); 508 } 509 } 510 511 /** 512 * @return the title of the queue or NULL if not available. 513 */ 514 @Nullable 515 public CharSequence getQueueTitle() { 516 if (mMediaController == null) { 517 return null; 518 } 519 return mMediaController.getQueueTitle(); 520 } 521 522 /** 523 * @return queue id of the currently playing queue item, or 524 * {@link MediaSession.QueueItem#UNKNOWN_ID} if none of the items is currently playing. 525 */ 526 public long getActiveQueueItemId() { 527 PlaybackState playbackState = mMediaController.getPlaybackState(); 528 if (playbackState == null) return MediaSession.QueueItem.UNKNOWN_ID; 529 return playbackState.getActiveQueueItemId(); 530 } 531 532 /** 533 * @return true if the media queue is not empty. Detailed information can be obtained by 534 * calling to {@link #getQueue()}. Changes on this value will be notified through 535 * {@link PlaybackObserver#onPlaybackStateChanged()}. 536 */ 537 public boolean hasQueue() { 538 if (mMediaController == null) { 539 return false; 540 } 541 List<MediaSession.QueueItem> items = mMediaController.getQueue(); 542 return items != null && !items.isEmpty(); 543 } 544 545 private @Nullable CustomPlaybackAction getRatingAction() { 546 PlaybackState playbackState = mMediaController.getPlaybackState(); 547 if (playbackState == null) return null; 548 549 long stdActions = playbackState.getActions(); 550 if ((stdActions & PlaybackState.ACTION_SET_RATING) == 0) return null; 551 552 int ratingType = mMediaController.getRatingType(); 553 if (ratingType != Rating.RATING_HEART) return null; 554 555 MediaMetadata metadata = mMediaController.getMetadata(); 556 boolean hasHeart = false; 557 if (metadata != null) { 558 Rating rating = metadata.getRating(MediaMetadata.METADATA_KEY_USER_RATING); 559 hasHeart = rating != null && rating.hasHeart(); 560 } 561 562 int iconResource = hasHeart ? R.drawable.ic_star_filled : R.drawable.ic_star_empty; 563 Drawable icon = mContext.getResources().getDrawable(iconResource, null); 564 Bundle extras = new Bundle(); 565 extras.putBoolean(EXTRA_SET_HEART, !hasHeart); 566 return new CustomPlaybackAction(icon, ACTION_SET_RATING, extras); 567 } 568 569 /** 570 * @return a sorted list of custom actions, as reported by the media source. Changes on this 571 * value will be notified through 572 * {@link PlaybackObserver#onPlaybackStateChanged()}. 573 */ 574 public List<CustomPlaybackAction> getCustomActions() { 575 List<CustomPlaybackAction> actions = new ArrayList<>(); 576 if (mMediaController == null) return actions; 577 PlaybackState playbackState = mMediaController.getPlaybackState(); 578 if (playbackState == null) return actions; 579 580 CustomPlaybackAction ratingAction = getRatingAction(); 581 if (ratingAction != null) actions.add(ratingAction); 582 583 for (PlaybackState.CustomAction action : playbackState.getCustomActions()) { 584 Resources resources = getResourcesForPackage(mMediaController.getPackageName()); 585 if (resources == null) { 586 actions.add(null); 587 } else { 588 // the resources may be from another package. we need to update the configuration 589 // using the context from the activity so we get the drawable from the correct DPI 590 // bucket. 591 resources.updateConfiguration(mContext.getResources().getConfiguration(), 592 mContext.getResources().getDisplayMetrics()); 593 Drawable icon = resources.getDrawable(action.getIcon(), null); 594 actions.add(new CustomPlaybackAction(icon, action.getAction(), action.getExtras())); 595 } 596 } 597 return actions; 598 } 599 600 private Resources getResourcesForPackage(String packageName) { 601 try { 602 return mContext.getPackageManager().getResourcesForApplication(packageName); 603 } catch (PackageManager.NameNotFoundException e) { 604 Log.e(TAG, "Unable to get resources for " + packageName); 605 return null; 606 } 607 } 608 } 609