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.playback;
18 
19 import static android.car.media.CarMediaManager.MEDIA_SOURCE_MODE_PLAYBACK;
20 
21 import static androidx.lifecycle.Transformations.switchMap;
22 
23 import static com.android.car.arch.common.LiveDataFunctions.dataOf;
24 import static com.android.car.media.common.playback.PlaybackStateAnnotations.Actions;
25 
26 import android.app.Application;
27 import android.content.Context;
28 import android.content.pm.PackageManager;
29 import android.content.res.Resources;
30 import android.graphics.drawable.Drawable;
31 import android.media.MediaMetadata;
32 import android.os.Bundle;
33 import android.support.v4.media.MediaMetadataCompat;
34 import android.support.v4.media.RatingCompat;
35 import android.support.v4.media.session.MediaControllerCompat;
36 import android.support.v4.media.session.MediaSessionCompat;
37 import android.support.v4.media.session.PlaybackStateCompat;
38 import android.util.Log;
39 
40 import androidx.annotation.IntDef;
41 import androidx.annotation.NonNull;
42 import androidx.annotation.Nullable;
43 import androidx.annotation.VisibleForTesting;
44 import androidx.lifecycle.AndroidViewModel;
45 import androidx.lifecycle.LiveData;
46 import androidx.lifecycle.MutableLiveData;
47 import androidx.lifecycle.Observer;
48 
49 import com.android.car.media.common.CustomPlaybackAction;
50 import com.android.car.media.common.MediaConstants;
51 import com.android.car.media.common.MediaItemMetadata;
52 import com.android.car.media.common.R;
53 import com.android.car.media.common.source.MediaSourceColors;
54 import com.android.car.media.common.source.MediaSourceViewModel;
55 
56 import java.lang.annotation.Retention;
57 import java.lang.annotation.RetentionPolicy;
58 import java.util.ArrayList;
59 import java.util.Collections;
60 import java.util.List;
61 import java.util.Objects;
62 import java.util.stream.Collectors;
63 
64 /**
65  * ViewModel for media playback.
66  * <p>
67  * Observes changes to the provided MediaController to expose playback state and metadata
68  * observables.
69  * <p>
70  * PlaybackViewModel is a singleton tied to the application to provide a single source of truth.
71  */
72 public class PlaybackViewModel extends AndroidViewModel {
73     private static final String TAG = "PlaybackViewModel";
74 
75     private static final String ACTION_SET_RATING =
76             "com.android.car.media.common.ACTION_SET_RATING";
77     private static final String EXTRA_SET_HEART = "com.android.car.media.common.EXTRA_SET_HEART";
78 
79     private static PlaybackViewModel[] sInstances = new PlaybackViewModel[2];
80 
81     /**
82      * Returns the PlaybackViewModel singleton tied to the application.
83      * @deprecated should use get(Application application, int mode) instead
84      */
get(@onNull Application application)85     public static PlaybackViewModel get(@NonNull Application application) {
86         return get(application, MEDIA_SOURCE_MODE_PLAYBACK);
87     }
88 
89     /** Returns the PlaybackViewModel singleton tied to the application. */
get(@onNull Application application, int mode)90     public static PlaybackViewModel get(@NonNull Application application, int mode) {
91         if (sInstances[mode] == null) {
92             sInstances[mode] = new PlaybackViewModel(application, mode);
93         }
94         return sInstances[mode];
95     }
96 
97     /**
98      * Possible main actions.
99      */
100     @IntDef({ACTION_PLAY, ACTION_STOP, ACTION_PAUSE, ACTION_DISABLED})
101     @Retention(RetentionPolicy.SOURCE)
102     public @interface Action {
103     }
104 
105     /**
106      * Main action is disabled. The source can't play media at this time
107      */
108     public static final int ACTION_DISABLED = 0;
109     /**
110      * Start playing
111      */
112     public static final int ACTION_PLAY = 1;
113     /**
114      * Stop playing
115      */
116     public static final int ACTION_STOP = 2;
117     /**
118      * Pause playing
119      */
120     public static final int ACTION_PAUSE = 3;
121 
122     /** Needs to be a MediaMetadata because the compat class doesn't implement equals... */
123     private static final MediaMetadata EMPTY_MEDIA_METADATA = new MediaMetadata.Builder().build();
124 
125     private final MediaControllerCallback mMediaControllerCallback = new MediaControllerCallback();
126     private final Observer<MediaControllerCompat> mMediaControllerObserver =
127             mMediaControllerCallback::onMediaControllerChanged;
128 
129     private final MediaSourceColors.Factory mColorsFactory;
130     private final MutableLiveData<MediaSourceColors> mColors = dataOf(null);
131 
132     private final MutableLiveData<MediaItemMetadata> mMetadata = dataOf(null);
133 
134     // Filters out queue items with no description or title and converts them to MediaItemMetadata
135     private final MutableLiveData<List<MediaItemMetadata>> mSanitizedQueue = dataOf(null);
136 
137     private final MutableLiveData<Boolean> mHasQueue = dataOf(null);
138 
139     private final MutableLiveData<CharSequence> mQueueTitle = dataOf(null);
140 
141     private final MutableLiveData<PlaybackController> mPlaybackControls = dataOf(null);
142 
143     private final MutableLiveData<PlaybackStateWrapper> mPlaybackStateWrapper = dataOf(null);
144 
145     private final LiveData<PlaybackProgress> mProgress =
146             switchMap(mPlaybackStateWrapper,
147                     state -> state == null ? dataOf(new PlaybackProgress(0L, 0L))
148                             : new ProgressLiveData(state.mState, state.getMaxProgress()));
149 
PlaybackViewModel(Application application, int mode)150     private PlaybackViewModel(Application application, int mode) {
151         this(application, MediaSourceViewModel.get(application, mode).getMediaController());
152     }
153 
154     @VisibleForTesting
PlaybackViewModel(Application application, LiveData<MediaControllerCompat> controller)155     public PlaybackViewModel(Application application, LiveData<MediaControllerCompat> controller) {
156         super(application);
157         mColorsFactory = new MediaSourceColors.Factory(application);
158         controller.observeForever(mMediaControllerObserver);
159     }
160 
161     /**
162      * Returns a LiveData that emits the colors for the currently set media source.
163      */
getMediaSourceColors()164     public LiveData<MediaSourceColors> getMediaSourceColors() {
165         return mColors;
166     }
167 
168     /**
169      * Returns a LiveData that emits a MediaItemMetadata of the current media item in the session
170      * managed by the provided {@link MediaControllerCompat}.
171      */
getMetadata()172     public LiveData<MediaItemMetadata> getMetadata() {
173         return mMetadata;
174     }
175 
176     /**
177      * Returns a LiveData that emits the current queue as MediaItemMetadatas where items without a
178      * title have been filtered out.
179      */
getQueue()180     public LiveData<List<MediaItemMetadata>> getQueue() {
181         return mSanitizedQueue;
182     }
183 
184     /**
185      * Returns a LiveData that emits whether the MediaController has a non-empty queue
186      */
hasQueue()187     public LiveData<Boolean> hasQueue() {
188         return mHasQueue;
189     }
190 
191     /**
192      * Returns a LiveData that emits the current queue title.
193      */
getQueueTitle()194     public LiveData<CharSequence> getQueueTitle() {
195         return mQueueTitle;
196     }
197 
198     /**
199      * Returns a LiveData that emits an object for controlling the currently selected
200      * MediaController.
201      */
getPlaybackController()202     public LiveData<PlaybackController> getPlaybackController() {
203         return mPlaybackControls;
204     }
205 
206     /** Returns a {@PlaybackStateWrapper} live data. */
getPlaybackStateWrapper()207     public LiveData<PlaybackStateWrapper> getPlaybackStateWrapper() {
208         return mPlaybackStateWrapper;
209     }
210 
211     /**
212      * Returns a LiveData that emits the current playback progress, in milliseconds. This is a
213      * value between 0 and {@link #getPlaybackStateWrapper#getMaxProgress()} or
214      * {@link PlaybackStateCompat#PLAYBACK_POSITION_UNKNOWN} if the current position is unknown.
215      * This value will update on its own periodically (less than a second) while active.
216      */
getProgress()217     public LiveData<PlaybackProgress> getProgress() {
218         return mProgress;
219     }
220 
221     @VisibleForTesting
getMediaController()222     MediaControllerCompat getMediaController() {
223         return mMediaControllerCallback.mMediaController;
224     }
225 
226     @VisibleForTesting
getMediaMetadata()227     MediaMetadataCompat getMediaMetadata() {
228         return mMediaControllerCallback.mMediaMetadata;
229     }
230 
231 
232     private class MediaControllerCallback extends MediaControllerCompat.Callback {
233 
234         private MediaControllerCompat mMediaController;
235         private MediaMetadataCompat mMediaMetadata;
236         private PlaybackStateCompat mPlaybackState;
237 
onMediaControllerChanged(MediaControllerCompat controller)238         void onMediaControllerChanged(MediaControllerCompat controller) {
239             if (mMediaController == controller) {
240                 Log.w(TAG, "onMediaControllerChanged noop");
241                 return;
242             }
243 
244             if (mMediaController != null) {
245                 mMediaController.unregisterCallback(this);
246             }
247 
248             mMediaMetadata = null;
249             mPlaybackState = null;
250             mMediaController = controller;
251             mPlaybackControls.setValue(new PlaybackController(controller));
252 
253             if (mMediaController != null) {
254                 mMediaController.registerCallback(this);
255 
256                 mColors.setValue(mColorsFactory.extractColors(controller.getPackageName()));
257 
258                 // The apps don't always send updates so make sure we fetch the most recent values.
259                 onMetadataChanged(mMediaController.getMetadata());
260                 onPlaybackStateChanged(mMediaController.getPlaybackState());
261                 onQueueChanged(mMediaController.getQueue());
262                 onQueueTitleChanged(mMediaController.getQueueTitle());
263             } else {
264                 mColors.setValue(null);
265                 onMetadataChanged(null);
266                 onPlaybackStateChanged(null);
267                 onQueueChanged(null);
268                 onQueueTitleChanged(null);
269             }
270 
271             updatePlaybackStatus();
272         }
273 
274         @Override
onSessionDestroyed()275         public void onSessionDestroyed() {
276             Log.w(TAG, "onSessionDestroyed");
277             onMediaControllerChanged(null);
278         }
279 
280         @Override
onMetadataChanged(@ullable MediaMetadataCompat mmdCompat)281         public void onMetadataChanged(@Nullable MediaMetadataCompat mmdCompat) {
282             // MediaSession#setMetadata builds an empty MediaMetadata when its argument is null,
283             // yet MediaMetadataCompat doesn't implement equals... so if the given mmdCompat's
284             // MediaMetadata equals EMPTY_MEDIA_METADATA, set mMediaMetadata to null to keep
285             // the code simpler everywhere else.
286             if ((mmdCompat != null) && EMPTY_MEDIA_METADATA.equals(mmdCompat.getMediaMetadata())) {
287                 mMediaMetadata = null;
288             } else {
289                 mMediaMetadata = mmdCompat;
290             }
291             MediaItemMetadata item =
292                     (mMediaMetadata != null) ? new MediaItemMetadata(mMediaMetadata) : null;
293             mMetadata.setValue(item);
294             updatePlaybackStatus();
295         }
296 
297         @Override
onQueueTitleChanged(CharSequence title)298         public void onQueueTitleChanged(CharSequence title) {
299             mQueueTitle.setValue(title);
300         }
301 
302         @Override
onQueueChanged(@ullable List<MediaSessionCompat.QueueItem> queue)303         public void onQueueChanged(@Nullable List<MediaSessionCompat.QueueItem> queue) {
304             List<MediaItemMetadata> filtered = queue == null ? Collections.emptyList()
305                     : queue.stream()
306                             .filter(item -> item != null
307                                     && item.getDescription() != null
308                                     && item.getDescription().getTitle() != null)
309                             .map(MediaItemMetadata::new)
310                             .collect(Collectors.toList());
311 
312             mSanitizedQueue.setValue(filtered);
313             mHasQueue.setValue(filtered.size() > 1);
314         }
315 
316         @Override
onPlaybackStateChanged(PlaybackStateCompat playbackState)317         public void onPlaybackStateChanged(PlaybackStateCompat playbackState) {
318             mPlaybackState = playbackState;
319             updatePlaybackStatus();
320         }
321 
updatePlaybackStatus()322         private void updatePlaybackStatus() {
323             if (mMediaController != null && mPlaybackState != null) {
324                 mPlaybackStateWrapper.setValue(
325                         new PlaybackStateWrapper(mMediaController, mMediaMetadata, mPlaybackState));
326             } else {
327                 mPlaybackStateWrapper.setValue(null);
328             }
329         }
330     }
331 
332     /** Convenient extension of {@link PlaybackStateCompat}. */
333     public static final class PlaybackStateWrapper {
334 
335         private final MediaControllerCompat mMediaController;
336         @Nullable
337         private final MediaMetadataCompat mMetadata;
338         private final PlaybackStateCompat mState;
339 
PlaybackStateWrapper(@onNull MediaControllerCompat mediaController, @Nullable MediaMetadataCompat metadata, @NonNull PlaybackStateCompat state)340         PlaybackStateWrapper(@NonNull MediaControllerCompat mediaController,
341                 @Nullable MediaMetadataCompat metadata, @NonNull PlaybackStateCompat state) {
342             mMediaController = mediaController;
343             mMetadata = metadata;
344             mState = state;
345         }
346 
347         /** Returns true if there's enough information in the state to show a UI for it. */
shouldDisplay()348         public boolean shouldDisplay() {
349             // STATE_NONE means no content to play.
350             return mState.getState() != PlaybackStateCompat.STATE_NONE && ((mMetadata != null) || (
351                     getMainAction() != ACTION_DISABLED));
352         }
353 
354         /** Returns the main action. */
355         @Action
getMainAction()356         public int getMainAction() {
357             @Actions long actions = mState.getActions();
358             @Action int stopAction = ACTION_DISABLED;
359             if ((actions & (PlaybackStateCompat.ACTION_PAUSE
360                     | PlaybackStateCompat.ACTION_PLAY_PAUSE)) != 0) {
361                 stopAction = ACTION_PAUSE;
362             } else if ((actions & PlaybackStateCompat.ACTION_STOP) != 0) {
363                 stopAction = ACTION_STOP;
364             }
365 
366             switch (mState.getState()) {
367                 case PlaybackStateCompat.STATE_PLAYING:
368                 case PlaybackStateCompat.STATE_BUFFERING:
369                 case PlaybackStateCompat.STATE_CONNECTING:
370                 case PlaybackStateCompat.STATE_FAST_FORWARDING:
371                 case PlaybackStateCompat.STATE_REWINDING:
372                 case PlaybackStateCompat.STATE_SKIPPING_TO_NEXT:
373                 case PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS:
374                 case PlaybackStateCompat.STATE_SKIPPING_TO_QUEUE_ITEM:
375                     return stopAction;
376                 case PlaybackStateCompat.STATE_STOPPED:
377                 case PlaybackStateCompat.STATE_PAUSED:
378                 case PlaybackStateCompat.STATE_NONE:
379                 case PlaybackStateCompat.STATE_ERROR:
380                     return (actions & PlaybackStateCompat.ACTION_PLAY) != 0 ? ACTION_PLAY
381                             : ACTION_DISABLED;
382                 default:
383                     Log.w(TAG, String.format("Unknown PlaybackState: %d", mState.getState()));
384                     return ACTION_DISABLED;
385             }
386         }
387 
388         /**
389          * Returns the currently supported playback actions
390          */
getSupportedActions()391         public long getSupportedActions() {
392             return mState.getActions();
393         }
394 
395         /**
396          * Returns the duration of the media item in milliseconds. The current position in this
397          * duration can be obtained by calling {@link #getProgress()}.
398          */
getMaxProgress()399         public long getMaxProgress() {
400             return mMetadata == null ? 0 :
401                     mMetadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
402         }
403 
404         /** Returns whether the current media source is playing a media item. */
isPlaying()405         public boolean isPlaying() {
406             return mState.getState() == PlaybackStateCompat.STATE_PLAYING;
407         }
408 
409         /** Returns whether the media source supports skipping to the next item. */
isSkipNextEnabled()410         public boolean isSkipNextEnabled() {
411             return (mState.getActions() & PlaybackStateCompat.ACTION_SKIP_TO_NEXT) != 0;
412         }
413 
414         /** Returns whether the media source supports skipping to the previous item. */
isSkipPreviousEnabled()415         public boolean isSkipPreviousEnabled() {
416             return (mState.getActions() & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) != 0;
417         }
418 
419         /**
420          * Returns whether the media source supports seeking to a new location in the media stream.
421          */
isSeekToEnabled()422         public boolean isSeekToEnabled() {
423             return (mState.getActions() & PlaybackStateCompat.ACTION_SEEK_TO) != 0;
424         }
425 
426         /** Returns whether the media source requires reserved space for the skip to next action. */
isSkipNextReserved()427         public boolean isSkipNextReserved() {
428             return mMediaController.getExtras() != null
429                     && (mMediaController.getExtras().getBoolean(
430                     MediaConstants.SLOT_RESERVATION_SKIP_TO_NEXT)
431                     || mMediaController.getExtras().getBoolean(
432                     MediaConstants.PLAYBACK_SLOT_RESERVATION_SKIP_TO_NEXT));
433         }
434 
435         /**
436          * Returns whether the media source requires reserved space for the skip to previous action.
437          */
iSkipPreviousReserved()438         public boolean iSkipPreviousReserved() {
439             return mMediaController.getExtras() != null
440                     && (mMediaController.getExtras().getBoolean(
441                     MediaConstants.SLOT_RESERVATION_SKIP_TO_PREV)
442                     || mMediaController.getExtras().getBoolean(
443                     MediaConstants.PLAYBACK_SLOT_RESERVATION_SKIP_TO_PREV));
444         }
445 
446         /** Returns whether the media source is loading (e.g.: buffering, connecting, etc.). */
isLoading()447         public boolean isLoading() {
448             int state = mState.getState();
449             return state == PlaybackStateCompat.STATE_BUFFERING
450                     || state == PlaybackStateCompat.STATE_CONNECTING
451                     || state == PlaybackStateCompat.STATE_FAST_FORWARDING
452                     || state == PlaybackStateCompat.STATE_REWINDING
453                     || state == PlaybackStateCompat.STATE_SKIPPING_TO_NEXT
454                     || state == PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS
455                     || state == PlaybackStateCompat.STATE_SKIPPING_TO_QUEUE_ITEM;
456         }
457 
458         /** See {@link PlaybackStateCompat#getErrorMessage}. */
getErrorMessage()459         public CharSequence getErrorMessage() {
460             return mState.getErrorMessage();
461         }
462 
463         /** See {@link PlaybackStateCompat#getErrorCode()}. */
getErrorCode()464         public int getErrorCode() {
465             return mState.getErrorCode();
466         }
467 
468         /** See {@link PlaybackStateCompat#getActiveQueueItemId}. */
getActiveQueueItemId()469         public long getActiveQueueItemId() {
470             return mState.getActiveQueueItemId();
471         }
472 
473         /** See {@link PlaybackStateCompat#getState}. */
474         @PlaybackStateCompat.State
getState()475         public int getState() {
476             return mState.getState();
477         }
478 
479         /** See {@link PlaybackStateCompat#getExtras}. */
getExtras()480         public Bundle getExtras() {
481             return mState.getExtras();
482         }
483 
484         @VisibleForTesting
getStateCompat()485         PlaybackStateCompat getStateCompat() {
486             return mState;
487         }
488 
489         /**
490          * Returns a sorted list of custom actions available. Call {@link
491          * RawCustomPlaybackAction#fetchDrawable(Context)} to get the appropriate icon Drawable.
492          */
getCustomActions()493         public List<RawCustomPlaybackAction> getCustomActions() {
494             List<RawCustomPlaybackAction> actions = new ArrayList<>();
495             RawCustomPlaybackAction ratingAction = getRatingAction();
496             if (ratingAction != null) actions.add(ratingAction);
497 
498             for (PlaybackStateCompat.CustomAction action : mState.getCustomActions()) {
499                 String packageName = mMediaController.getPackageName();
500                 actions.add(
501                         new RawCustomPlaybackAction(action.getIcon(), packageName,
502                                 action.getAction(),
503                                 action.getExtras()));
504             }
505             return actions;
506         }
507 
508         @Nullable
getRatingAction()509         private RawCustomPlaybackAction getRatingAction() {
510             long stdActions = mState.getActions();
511             if ((stdActions & PlaybackStateCompat.ACTION_SET_RATING) == 0) return null;
512 
513             int ratingType = mMediaController.getRatingType();
514             if (ratingType != RatingCompat.RATING_HEART) return null;
515 
516             boolean hasHeart = false;
517             if (mMetadata != null) {
518                 RatingCompat rating = mMetadata.getRating(
519                         MediaMetadataCompat.METADATA_KEY_USER_RATING);
520                 hasHeart = rating != null && rating.hasHeart();
521             }
522 
523             int iconResource = hasHeart ? R.drawable.ic_star_filled : R.drawable.ic_star_empty;
524             Bundle extras = new Bundle();
525             extras.putBoolean(EXTRA_SET_HEART, !hasHeart);
526             return new RawCustomPlaybackAction(iconResource, null, ACTION_SET_RATING, extras);
527         }
528     }
529 
530 
531     /**
532      * Wraps the {@link android.media.session.MediaController.TransportControls TransportControls}
533      * for a {@link MediaControllerCompat} to send commands.
534      */
535     // TODO(arnaudberry) does this wrapping make sense since we're still null checking the wrapper?
536     // Should we call action methods on the model class instead ?
537     public class PlaybackController {
538         private final MediaControllerCompat mMediaController;
539 
PlaybackController(@ullable MediaControllerCompat mediaController)540         private PlaybackController(@Nullable MediaControllerCompat mediaController) {
541             mMediaController = mediaController;
542         }
543 
544         /**
545          * Sends a 'play' command to the media source
546          */
play()547         public void play() {
548             if (mMediaController != null) {
549                 mMediaController.getTransportControls().play();
550             }
551         }
552 
553         /**
554          * Sends a 'skip previews' command to the media source
555          */
skipToPrevious()556         public void skipToPrevious() {
557             if (mMediaController != null) {
558                 mMediaController.getTransportControls().skipToPrevious();
559             }
560         }
561 
562         /**
563          * Sends a 'skip next' command to the media source
564          */
skipToNext()565         public void skipToNext() {
566             if (mMediaController != null) {
567                 mMediaController.getTransportControls().skipToNext();
568             }
569         }
570 
571         /**
572          * Sends a 'pause' command to the media source
573          */
pause()574         public void pause() {
575             if (mMediaController != null) {
576                 mMediaController.getTransportControls().pause();
577             }
578         }
579 
580         /**
581          * Sends a 'stop' command to the media source
582          */
stop()583         public void stop() {
584             if (mMediaController != null) {
585                 mMediaController.getTransportControls().stop();
586             }
587         }
588 
589         /**
590          * Moves to a new location in the media stream
591          *
592          * @param pos Position to move to, in milliseconds.
593          */
seekTo(long pos)594         public void seekTo(long pos) {
595             if (mMediaController != null) {
596                 PlaybackStateCompat oldState = mMediaController.getPlaybackState();
597                 PlaybackStateCompat newState = new PlaybackStateCompat.Builder(oldState)
598                         .setState(oldState.getState(), pos, oldState.getPlaybackSpeed())
599                         .build();
600                 mMediaControllerCallback.onPlaybackStateChanged(newState);
601 
602                 mMediaController.getTransportControls().seekTo(pos);
603             }
604         }
605 
606         /**
607          * Sends a custom action to the media source
608          *
609          * @param action identifier of the custom action
610          * @param extras additional data to send to the media source.
611          */
doCustomAction(String action, Bundle extras)612         public void doCustomAction(String action, Bundle extras) {
613             if (mMediaController == null) return;
614             MediaControllerCompat.TransportControls cntrl = mMediaController.getTransportControls();
615 
616             if (ACTION_SET_RATING.equals(action)) {
617                 boolean setHeart = extras != null && extras.getBoolean(EXTRA_SET_HEART, false);
618                 cntrl.setRating(RatingCompat.newHeartRating(setHeart));
619             } else {
620                 cntrl.sendCustomAction(action, extras);
621             }
622         }
623 
624         /**
625          * Starts playing a given media item.
626          */
playItem(MediaItemMetadata item)627         public void playItem(MediaItemMetadata item) {
628             if (mMediaController != null) {
629                 // Do NOT pass the extras back as that's not the official API and isn't supported
630                 // in media2, so apps should not rely on this.
631                 mMediaController.getTransportControls().playFromMediaId(item.getId(), null);
632             }
633         }
634 
635         /**
636          * Skips to a particular item in the media queue. This id is {@link
637          * MediaItemMetadata#mQueueId} of the items obtained through {@link
638          * PlaybackViewModel#getQueue()}.
639          */
skipToQueueItem(long queueId)640         public void skipToQueueItem(long queueId) {
641             if (mMediaController != null) {
642                 mMediaController.getTransportControls().skipToQueueItem(queueId);
643             }
644         }
645 
646         /**
647          * Prepares the current media source for playback.
648          */
prepare()649         public void prepare() {
650             if (mMediaController != null) {
651                 mMediaController.getTransportControls().prepare();
652             }
653         }
654     }
655 
656     /**
657      * Abstract representation of a custom playback action. A custom playback action represents a
658      * visual element that can be used to trigger playback actions not included in the standard
659      * {@link PlaybackController} class. Custom actions for the current media source are exposed
660      * through {@link PlaybackStateWrapper#getCustomActions}
661      * <p>
662      * Does not contain a {@link Drawable} representation of the icon. Instances of this object
663      * should be converted to a {@link CustomPlaybackAction} via {@link
664      * RawCustomPlaybackAction#fetchDrawable(Context)} for display.
665      */
666     public static class RawCustomPlaybackAction {
667         // TODO (keyboardr): This class (and associtated translation code) will be merged with
668         // CustomPlaybackAction in a future CL.
669         /**
670          * Icon to display for this custom action
671          */
672         public final int mIcon;
673         /**
674          * If true, use the resources from the this package to resolve the icon. If null use our own
675          * resources.
676          */
677         @Nullable
678         public final String mPackageName;
679         /**
680          * Action identifier used to request this action to the media service
681          */
682         @NonNull
683         public final String mAction;
684         /**
685          * Any additional information to send along with the action identifier
686          */
687         @Nullable
688         public final Bundle mExtras;
689 
690         /**
691          * Creates a custom action
692          */
RawCustomPlaybackAction(int icon, String packageName, @NonNull String action, @Nullable Bundle extras)693         public RawCustomPlaybackAction(int icon, String packageName,
694                 @NonNull String action,
695                 @Nullable Bundle extras) {
696             mIcon = icon;
697             mPackageName = packageName;
698             mAction = action;
699             mExtras = extras;
700         }
701 
702         @Override
equals(Object o)703         public boolean equals(Object o) {
704             if (this == o) return true;
705             if (o == null || getClass() != o.getClass()) return false;
706 
707             RawCustomPlaybackAction that = (RawCustomPlaybackAction) o;
708 
709             return mIcon == that.mIcon
710                     && Objects.equals(mPackageName, that.mPackageName)
711                     && Objects.equals(mAction, that.mAction)
712                     && Objects.equals(mExtras, that.mExtras);
713         }
714 
715         @Override
hashCode()716         public int hashCode() {
717             return Objects.hash(mIcon, mPackageName, mAction, mExtras);
718         }
719 
720         /**
721          * Converts this {@link RawCustomPlaybackAction} into a {@link CustomPlaybackAction} by
722          * fetching the appropriate drawable for the icon.
723          *
724          * @param context Context into which the icon will be drawn
725          * @return the converted CustomPlaybackAction or null if appropriate {@link Resources}
726          * cannot be obtained
727          */
728         @Nullable
fetchDrawable(@onNull Context context)729         public CustomPlaybackAction fetchDrawable(@NonNull Context context) {
730             Drawable icon;
731             if (mPackageName == null) {
732                 icon = context.getDrawable(mIcon);
733             } else {
734                 Resources resources = getResourcesForPackage(context, mPackageName);
735                 if (resources == null) {
736                     return null;
737                 } else {
738                     // the resources may be from another package. we need to update the
739                     // configuration
740                     // using the context from the activity so we get the drawable from the
741                     // correct DPI
742                     // bucket.
743                     resources.updateConfiguration(context.getResources().getConfiguration(),
744                             context.getResources().getDisplayMetrics());
745                     icon = resources.getDrawable(mIcon, null);
746                 }
747             }
748             return new CustomPlaybackAction(icon, mAction, mExtras);
749         }
750 
getResourcesForPackage(Context context, String packageName)751         private Resources getResourcesForPackage(Context context, String packageName) {
752             try {
753                 return context.getPackageManager().getResourcesForApplication(packageName);
754             } catch (PackageManager.NameNotFoundException e) {
755                 Log.e(TAG, "Unable to get resources for " + packageName);
756                 return null;
757             }
758         }
759     }
760 
761 }
762