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