1/*
2 * Copyright (c) 2016, 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 */
16package com.android.car.media.localmediaplayer;
17
18import android.app.Notification;
19import android.app.NotificationManager;
20import android.app.PendingIntent;
21import android.content.Context;
22import android.content.Intent;
23import android.content.SharedPreferences;
24import android.media.AudioManager;
25import android.media.AudioManager.OnAudioFocusChangeListener;
26import android.media.MediaDescription;
27import android.media.MediaMetadata;
28import android.media.MediaPlayer;
29import android.media.MediaPlayer.OnCompletionListener;
30import android.media.session.MediaSession;
31import android.media.session.MediaSession.QueueItem;
32import android.media.session.PlaybackState;
33import android.media.session.PlaybackState.CustomAction;
34import android.os.Bundle;
35import android.util.Log;
36
37import com.android.car.media.localmediaplayer.nano.Proto.Playlist;
38import com.android.car.media.localmediaplayer.nano.Proto.Song;
39
40// Proto should be available in AOSP.
41import com.google.protobuf.nano.MessageNano;
42import com.google.protobuf.nano.InvalidProtocolBufferNanoException;
43
44import java.io.IOException;
45import java.io.File;
46import java.util.ArrayList;
47import java.util.Base64;
48import java.util.Collections;
49import java.util.List;
50
51/**
52 * TODO: Consider doing all content provider accesses and player operations asynchronously.
53 */
54public class Player extends MediaSession.Callback {
55    private static final String TAG = "LMPlayer";
56    private static final String SHARED_PREFS_NAME = "com.android.car.media.localmediaplayer.prefs";
57    private static final String CURRENT_PLAYLIST_KEY = "__CURRENT_PLAYLIST_KEY__";
58    private static final int NOTIFICATION_ID = 42;
59    private static final int REQUEST_CODE = 94043;
60
61    private static final float PLAYBACK_SPEED = 1.0f;
62    private static final float PLAYBACK_SPEED_STOPPED = 1.0f;
63    private static final long PLAYBACK_POSITION_STOPPED = 0;
64
65    // Note: Queues loop around so next/previous are always available.
66    private static final long PLAYING_ACTIONS = PlaybackState.ACTION_PAUSE
67            | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT
68            | PlaybackState.ACTION_SKIP_TO_PREVIOUS | PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM;
69
70    private static final long PAUSED_ACTIONS = PlaybackState.ACTION_PLAY
71            | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT
72            | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
73
74    private static final long STOPPED_ACTIONS = PlaybackState.ACTION_PLAY
75            | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT
76            | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
77
78    private static final String SHUFFLE = "android.car.media.localmediaplayer.shuffle";
79
80    private final Context mContext;
81    private final MediaSession mSession;
82    private final AudioManager mAudioManager;
83    private final PlaybackState mErrorState;
84    private final DataModel mDataModel;
85    private final CustomAction mShuffle;
86
87    private List<QueueItem> mQueue;
88    private int mCurrentQueueIdx = 0;
89    private final SharedPreferences mSharedPrefs;
90
91    private NotificationManager mNotificationManager;
92    private Notification.Builder mPlayingNotificationBuilder;
93    private Notification.Builder mPausedNotificationBuilder;
94
95    // TODO: Use multiple media players for gapless playback.
96    private final MediaPlayer mMediaPlayer;
97
98    public Player(Context context, MediaSession session, DataModel dataModel) {
99        mContext = context;
100        mDataModel = dataModel;
101        mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
102        mSession = session;
103        mSharedPrefs = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);
104
105        mShuffle = new CustomAction.Builder(SHUFFLE, context.getString(R.string.shuffle),
106                R.drawable.shuffle).build();
107
108        mMediaPlayer = new MediaPlayer();
109        mMediaPlayer.reset();
110        mMediaPlayer.setOnCompletionListener(mOnCompletionListener);
111        mErrorState = new PlaybackState.Builder()
112                .setState(PlaybackState.STATE_ERROR, 0, 0)
113                .setErrorMessage(context.getString(R.string.playback_error))
114                .build();
115
116        mNotificationManager =
117                (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
118
119        // There are 2 forms of the media notification, when playing it needs to show the controls
120        // to pause & skip whereas when paused it needs to show controls to play & skip. Setup
121        // pre-populated builders for both of these up front.
122        Notification.Action prevAction = makeNotificationAction(
123                LocalMediaBrowserService.ACTION_PREV, R.drawable.ic_prev, R.string.prev);
124        Notification.Action nextAction = makeNotificationAction(
125                LocalMediaBrowserService.ACTION_NEXT, R.drawable.ic_next, R.string.next);
126        Notification.Action playAction = makeNotificationAction(
127                LocalMediaBrowserService.ACTION_PLAY, R.drawable.ic_play, R.string.play);
128        Notification.Action pauseAction = makeNotificationAction(
129                LocalMediaBrowserService.ACTION_PAUSE, R.drawable.ic_pause, R.string.pause);
130
131        // While playing, you need prev, pause, next.
132        mPlayingNotificationBuilder = new Notification.Builder(context)
133                .setVisibility(Notification.VISIBILITY_PUBLIC)
134                .setSmallIcon(R.drawable.ic_sd_storage_black)
135                .addAction(prevAction)
136                .addAction(pauseAction)
137                .addAction(nextAction);
138
139        // While paused, you need prev, play, next.
140        mPausedNotificationBuilder = new Notification.Builder(context)
141                .setVisibility(Notification.VISIBILITY_PUBLIC)
142                .setSmallIcon(R.drawable.ic_sd_storage_black)
143                .addAction(prevAction)
144                .addAction(playAction)
145                .addAction(nextAction);
146    }
147
148    private Notification.Action makeNotificationAction(String action, int iconId, int stringId) {
149        PendingIntent intent = PendingIntent.getBroadcast(mContext, REQUEST_CODE,
150                new Intent(action), PendingIntent.FLAG_UPDATE_CURRENT);
151        Notification.Action notificationAction = new Notification.Action.Builder(iconId,
152                mContext.getString(stringId), intent)
153                .build();
154        return notificationAction;
155    }
156
157    private boolean requestAudioFocus(Runnable onSuccess) {
158        int result = mAudioManager.requestAudioFocus(mAudioFocusListener, AudioManager.STREAM_MUSIC,
159                AudioManager.AUDIOFOCUS_GAIN);
160        if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
161            onSuccess.run();
162            return true;
163        }
164        Log.e(TAG, "Failed to acquire audio focus");
165        return false;
166    }
167
168    @Override
169    public void onPlay() {
170        super.onPlay();
171        if (Log.isLoggable(TAG, Log.DEBUG)) {
172            Log.d(TAG, "onPlay");
173        }
174        // Check permissions every time we try to play
175        if (!Utils.hasRequiredPermissions(mContext)) {
176            Utils.startPermissionRequest(mContext);
177        } else {
178            requestAudioFocus(() -> resumePlayback());
179        }
180    }
181
182    @Override
183    public void onPause() {
184        super.onPause();
185        if (Log.isLoggable(TAG, Log.DEBUG)) {
186            Log.d(TAG, "onPause");
187        }
188        pausePlayback();
189        mAudioManager.abandonAudioFocus(mAudioFocusListener);
190    }
191
192    public void destroy() {
193        stopPlayback();
194        mNotificationManager.cancelAll();
195        mAudioManager.abandonAudioFocus(mAudioFocusListener);
196        mMediaPlayer.release();
197    }
198
199    public void saveState() {
200        if (mQueue == null || mQueue.isEmpty()) {
201            return;
202        }
203
204        Playlist playlist = new Playlist();
205        playlist.songs = new Song[mQueue.size()];
206
207        int idx = 0;
208        for (QueueItem item : mQueue) {
209            Song song = new Song();
210            song.queueId = item.getQueueId();
211            MediaDescription description = item.getDescription();
212            song.mediaId = description.getMediaId();
213            song.title = description.getTitle().toString();
214            song.subtitle = description.getSubtitle().toString();
215            song.path = description.getExtras().getString(DataModel.PATH_KEY);
216
217            playlist.songs[idx] = song;
218            idx++;
219        }
220        playlist.currentQueueId = mQueue.get(mCurrentQueueIdx).getQueueId();
221        playlist.currentSongPosition = mMediaPlayer.getCurrentPosition();
222        playlist.name = CURRENT_PLAYLIST_KEY;
223
224        // Go to Base64 to ensure that we can actually store the string in a sharedpref. This is
225        // slightly wasteful because of the fact that base64 expands the size a bit but it's a
226        // lot less riskier than abusing the java string to directly store bytes coming out of
227        // proto encoding.
228        String serialized = Base64.getEncoder().encodeToString(MessageNano.toByteArray(playlist));
229        SharedPreferences.Editor editor = mSharedPrefs.edit();
230        editor.putString(CURRENT_PLAYLIST_KEY, serialized);
231        editor.commit();
232    }
233
234    private boolean maybeRebuildQueue(Playlist playlist) {
235        List<QueueItem> queue = new ArrayList<>();
236        int foundIdx = 0;
237        // You need to check if the playlist actually is still valid because the user could have
238        // deleted files or taken out the sd card between runs so we might as well check this ahead
239        // of time before we load up the playlist.
240        for (Song song : playlist.songs) {
241            File tmp = new File(song.path);
242            if (!tmp.exists()) {
243                continue;
244            }
245
246            if (playlist.currentQueueId == song.queueId) {
247                foundIdx = queue.size();
248            }
249
250            Bundle bundle = new Bundle();
251            bundle.putString(DataModel.PATH_KEY, song.path);
252            MediaDescription description = new MediaDescription.Builder()
253                    .setMediaId(song.mediaId)
254                    .setTitle(song.title)
255                    .setSubtitle(song.subtitle)
256                    .setExtras(bundle)
257                    .build();
258            queue.add(new QueueItem(description, song.queueId));
259        }
260
261        if (queue.isEmpty()) {
262            return false;
263        }
264
265        mQueue = queue;
266        mCurrentQueueIdx = foundIdx;  // Resumes from beginning if last playing song was not found.
267
268        return true;
269    }
270
271    public boolean maybeRestoreState() {
272        String serialized = mSharedPrefs.getString(CURRENT_PLAYLIST_KEY, null);
273        if (serialized == null) {
274            return false;
275        }
276
277        try {
278            Playlist playlist = Playlist.parseFrom(Base64.getDecoder().decode(serialized));
279            if (!maybeRebuildQueue(playlist)) {
280                return false;
281            }
282            updateSessionQueueState();
283
284            requestAudioFocus(() -> {
285                try {
286                    playCurrentQueueIndex();
287                    mMediaPlayer.seekTo(playlist.currentSongPosition);
288                    updatePlaybackStatePlaying();
289                } catch (IOException e) {
290                    Log.e(TAG, "Restored queue, but couldn't resume playback.");
291                }
292            });
293        } catch (IllegalArgumentException | InvalidProtocolBufferNanoException e) {
294            // Couldn't restore the playlist. Not the end of the world.
295            return false;
296        }
297
298        return true;
299    }
300
301    private void updateSessionQueueState() {
302        mSession.setQueueTitle(mContext.getString(R.string.playlist));
303        mSession.setQueue(mQueue);
304    }
305
306    private void startPlayback(String key) {
307        if (Log.isLoggable(TAG, Log.DEBUG)) {
308            Log.d(TAG, "startPlayback()");
309        }
310
311        List<QueueItem> queue = mDataModel.getQueue();
312        int idx = 0;
313        int foundIdx = -1;
314        for (QueueItem item : queue) {
315            if (item.getDescription().getMediaId().equals(key)) {
316                foundIdx = idx;
317                break;
318            }
319            idx++;
320        }
321
322        if (foundIdx == -1) {
323            mSession.setPlaybackState(mErrorState);
324            return;
325        }
326
327        mQueue = new ArrayList<>(queue);
328        mCurrentQueueIdx = foundIdx;
329        QueueItem current = mQueue.get(mCurrentQueueIdx);
330        String path = current.getDescription().getExtras().getString(DataModel.PATH_KEY);
331        MediaMetadata metadata = mDataModel.getMetadata(current.getDescription().getMediaId());
332        updateSessionQueueState();
333
334        try {
335            play(path, metadata);
336        } catch (IOException e) {
337            Log.e(TAG, "Playback failed.", e);
338            mSession.setPlaybackState(mErrorState);
339        }
340    }
341
342    private void resumePlayback() {
343        if (Log.isLoggable(TAG, Log.DEBUG)) {
344            Log.d(TAG, "resumePlayback()");
345        }
346
347        updatePlaybackStatePlaying();
348
349        if (!mMediaPlayer.isPlaying()) {
350            mMediaPlayer.start();
351        }
352    }
353
354    private void postMediaNotification(Notification.Builder builder) {
355        if (mQueue == null) {
356            return;
357        }
358
359        MediaDescription current = mQueue.get(mCurrentQueueIdx).getDescription();
360        Notification notification = builder
361                .setStyle(new Notification.MediaStyle().setMediaSession(mSession.getSessionToken()))
362                .setContentTitle(current.getTitle())
363                .setContentText(current.getSubtitle())
364                .setShowWhen(false)
365                .build();
366        notification.flags |= Notification.FLAG_NO_CLEAR;
367        mNotificationManager.notify(NOTIFICATION_ID, notification);
368    }
369
370    private void updatePlaybackStatePlaying() {
371        if (!mSession.isActive()) {
372            mSession.setActive(true);
373        }
374
375        // Update the state in the media session.
376        PlaybackState state = new PlaybackState.Builder()
377                .setState(PlaybackState.STATE_PLAYING,
378                        mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED)
379                .setActions(PLAYING_ACTIONS)
380                .addCustomAction(mShuffle)
381                .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId())
382                .build();
383        mSession.setPlaybackState(state);
384
385        // Update the media styled notification.
386        postMediaNotification(mPlayingNotificationBuilder);
387    }
388
389    private void pausePlayback() {
390        if (Log.isLoggable(TAG, Log.DEBUG)) {
391            Log.d(TAG, "pausePlayback()");
392        }
393
394        long currentPosition = 0;
395        if (mMediaPlayer.isPlaying()) {
396            currentPosition = mMediaPlayer.getCurrentPosition();
397            mMediaPlayer.pause();
398        }
399
400        PlaybackState state = new PlaybackState.Builder()
401                .setState(PlaybackState.STATE_PAUSED, currentPosition, PLAYBACK_SPEED_STOPPED)
402                .setActions(PAUSED_ACTIONS)
403                .addCustomAction(mShuffle)
404                .build();
405        mSession.setPlaybackState(state);
406
407        // Update the media styled notification.
408        postMediaNotification(mPausedNotificationBuilder);
409    }
410
411    private void stopPlayback() {
412        if (Log.isLoggable(TAG, Log.DEBUG)) {
413            Log.d(TAG, "stopPlayback()");
414        }
415
416        if (mMediaPlayer.isPlaying()) {
417            mMediaPlayer.stop();
418        }
419
420        PlaybackState state = new PlaybackState.Builder()
421                .setState(PlaybackState.STATE_STOPPED, PLAYBACK_POSITION_STOPPED,
422                        PLAYBACK_SPEED_STOPPED)
423                .setActions(STOPPED_ACTIONS)
424                .build();
425        mSession.setPlaybackState(state);
426    }
427
428    private void advance() throws IOException {
429        if (Log.isLoggable(TAG, Log.DEBUG)) {
430            Log.d(TAG, "advance()");
431        }
432        // Go to the next song if one exists. Note that if you were to support gapless
433        // playback, you would have to change this code such that you had a currently
434        // playing and a loading MediaPlayer and juggled between them while also calling
435        // setNextMediaPlayer.
436
437        if (mQueue != null && !mQueue.isEmpty()) {
438            // Keep looping around when we run off the end of our current queue.
439            mCurrentQueueIdx = (mCurrentQueueIdx + 1) % mQueue.size();
440            playCurrentQueueIndex();
441        } else {
442            stopPlayback();
443        }
444    }
445
446    private void retreat() throws IOException {
447        if (Log.isLoggable(TAG, Log.DEBUG)) {
448            Log.d(TAG, "retreat()");
449        }
450        // Go to the next song if one exists. Note that if you were to support gapless
451        // playback, you would have to change this code such that you had a currently
452        // playing and a loading MediaPlayer and juggled between them while also calling
453        // setNextMediaPlayer.
454        if (mQueue != null) {
455            // Keep looping around when we run off the end of our current queue.
456            mCurrentQueueIdx--;
457            if (mCurrentQueueIdx < 0) {
458                mCurrentQueueIdx = mQueue.size() - 1;
459            }
460            playCurrentQueueIndex();
461        } else {
462            stopPlayback();
463        }
464    }
465
466    private void playCurrentQueueIndex() throws IOException {
467        MediaDescription next = mQueue.get(mCurrentQueueIdx).getDescription();
468        String path = next.getExtras().getString(DataModel.PATH_KEY);
469        MediaMetadata metadata = mDataModel.getMetadata(next.getMediaId());
470
471        play(path, metadata);
472    }
473
474    private void play(String path, MediaMetadata metadata) throws IOException {
475        if (Log.isLoggable(TAG, Log.DEBUG)) {
476            Log.d(TAG, "play path=" + path + " metadata=" + metadata);
477        }
478
479        mMediaPlayer.reset();
480        mMediaPlayer.setDataSource(path);
481        mMediaPlayer.prepare();
482
483        if (metadata != null) {
484            mSession.setMetadata(metadata);
485        }
486        boolean wasGrantedAudio = requestAudioFocus(() -> {
487            mMediaPlayer.start();
488            updatePlaybackStatePlaying();
489        });
490        if (!wasGrantedAudio) {
491            // player.pause() isn't needed since it should not actually be playing, the
492            // other steps like, updating the notification and play state are needed, thus we
493            // call the pause method.
494            pausePlayback();
495        }
496    }
497
498    private void safeAdvance() {
499        try {
500            advance();
501        } catch (IOException e) {
502            Log.e(TAG, "Failed to advance.", e);
503            mSession.setPlaybackState(mErrorState);
504        }
505    }
506
507    private void safeRetreat() {
508        try {
509            retreat();
510        } catch (IOException e) {
511            Log.e(TAG, "Failed to advance.", e);
512            mSession.setPlaybackState(mErrorState);
513        }
514    }
515
516    /**
517     * This is a naive implementation of shuffle, previously played songs may repeat after the
518     * shuffle operation. Only call this from the main thread.
519     */
520    private void shuffle() {
521        if (Log.isLoggable(TAG, Log.DEBUG)) {
522            Log.d(TAG, "Shuffling");
523        }
524
525        // rebuild the the queue in a shuffled form.
526        if (mQueue != null && mQueue.size() > 2) {
527            QueueItem current = mQueue.remove(mCurrentQueueIdx);
528            Collections.shuffle(mQueue);
529            mQueue.add(0, current);
530            // A QueueItem contains a queue id that's used as the key for when the user selects
531            // the current play list. This means the QueueItems must be rebuilt to have their new
532            // id's set.
533            for (int i = 0; i < mQueue.size(); i++) {
534                mQueue.set(i, new QueueItem(mQueue.get(i).getDescription(), i));
535            }
536            mCurrentQueueIdx = 0;
537            updateSessionQueueState();
538        }
539    }
540
541    @Override
542    public void onPlayFromMediaId(String mediaId, Bundle extras) {
543        super.onPlayFromMediaId(mediaId, extras);
544        if (Log.isLoggable(TAG, Log.DEBUG)) {
545            Log.d(TAG, "onPlayFromMediaId mediaId" + mediaId + " extras=" + extras);
546        }
547
548        requestAudioFocus(() -> startPlayback(mediaId));
549    }
550
551    @Override
552    public void onSkipToNext() {
553        if (Log.isLoggable(TAG, Log.DEBUG)) {
554            Log.d(TAG, "onSkipToNext()");
555        }
556        safeAdvance();
557    }
558
559    @Override
560    public void onSkipToPrevious() {
561        if (Log.isLoggable(TAG, Log.DEBUG)) {
562            Log.d(TAG, "onSkipToPrevious()");
563        }
564        safeRetreat();
565    }
566
567    @Override
568    public void onSkipToQueueItem(long id) {
569        try {
570            mCurrentQueueIdx = (int) id;
571            playCurrentQueueIndex();
572        } catch (IOException e) {
573            Log.e(TAG, "Failed to play.", e);
574            mSession.setPlaybackState(mErrorState);
575        }
576    }
577
578    @Override
579    public void onCustomAction(String action, Bundle extras) {
580        switch (action) {
581            case SHUFFLE:
582                shuffle();
583                break;
584            default:
585                Log.e(TAG, "Unhandled custom action: " + action);
586        }
587    }
588
589    private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() {
590        @Override
591        public void onAudioFocusChange(int focus) {
592            switch (focus) {
593                case AudioManager.AUDIOFOCUS_GAIN:
594                    resumePlayback();
595                    break;
596                case AudioManager.AUDIOFOCUS_LOSS:
597                case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
598                case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
599                    pausePlayback();
600                    break;
601                default:
602                    Log.e(TAG, "Unhandled audio focus type: " + focus);
603            }
604        }
605    };
606
607    private OnCompletionListener mOnCompletionListener = new OnCompletionListener() {
608        @Override
609        public void onCompletion(MediaPlayer mediaPlayer) {
610            if (Log.isLoggable(TAG, Log.DEBUG)) {
611                Log.d(TAG, "onCompletion()");
612            }
613            safeAdvance();
614        }
615    };
616}
617