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
17package com.android.car.media.common;
18
19import android.annotation.IntDef;
20import android.annotation.NonNull;
21import android.annotation.Nullable;
22import android.content.Context;
23import android.content.pm.PackageManager;
24import android.content.res.Resources;
25import android.graphics.drawable.Drawable;
26import android.media.MediaMetadata;
27import android.media.Rating;
28import android.media.session.MediaController;
29import android.media.session.MediaController.TransportControls;
30import android.media.session.MediaSession;
31import android.media.session.PlaybackState;
32import android.media.session.PlaybackState.Actions;
33import android.os.Bundle;
34import android.os.Handler;
35import android.os.SystemClock;
36import android.util.Log;
37
38import java.lang.annotation.Retention;
39import java.lang.annotation.RetentionPolicy;
40import java.util.ArrayList;
41import java.util.List;
42import java.util.function.Consumer;
43import 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 */
49public 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