• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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          */
onPlaybackStateChanged()71         protected void onPlaybackStateChanged() {};
72 
73         /**
74          * Called when the top source media app changes.
75          */
onSourceChanged()76         protected void onSourceChanged() {};
77 
78         /**
79          * Called when the media item being played changes.
80          */
onMetadataChanged()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      */
PlaybackModel(@onNull Context context)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      */
PlaybackModel(@onNull Context context, @Nullable MediaController controller)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      */
setMediaController(@ullable MediaController mediaController)120     public void setMediaController(@Nullable MediaController mediaController) {
121         changeMediaController(mediaController);
122     }
123 
changeMediaController(@ullable MediaController mediaController)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      */
start()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      */
stop()164     private void stop() {
165         if (mMediaController != null) {
166             mMediaController.unregisterCallback(mCallback);
167         }
168         mIsStarted = false;
169     }
170 
notify(Consumer<PlaybackObserver> notification)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
getMediaSource()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
getMediaController()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
getMainAction()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
getMetadata()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      */
getMaxProgress()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      */
onPlay()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      */
onSkipPreviews()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      */
onSkipNext()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      */
onPause()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      */
onStop()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      */
onCustomAction(String action, Bundle extras)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      */
onPlayItem(String mediaItemId)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      */
onSkipToQueueItem(long queueId)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      */
onPrepare()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
getMainAction(PlaybackState state)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      */
getProgress()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      */
isPlaying()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      */
registerObserver(PlaybackObserver observer)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      */
unregisterObserver(PlaybackObserver observer)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      */
isSkipNextEnabled()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      */
isSkipPreviewsEnabled()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      */
isBuffering()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
getErrorMessage()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
getQueue()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
getQueueTitle()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      */
getActiveQueueItemId()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      */
hasQueue()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 
getRatingAction()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      */
getCustomActions()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 
getResourcesForPackage(String packageName)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