• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.NotificationManager;
20 import android.app.PendingIntent;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.SharedPreferences;
24 import android.media.AudioManager;
25 import android.media.AudioManager.OnAudioFocusChangeListener;
26 import android.media.MediaDescription;
27 import android.media.MediaMetadata;
28 import android.media.MediaPlayer;
29 import android.media.MediaPlayer.OnCompletionListener;
30 import android.media.session.MediaSession;
31 import android.media.session.MediaSession.QueueItem;
32 import android.media.session.PlaybackState;
33 import android.media.session.PlaybackState.CustomAction;
34 import android.os.Bundle;
35 import android.util.Log;
36 
37 import com.android.car.media.localmediaplayer.nano.Proto.Playlist;
38 import com.android.car.media.localmediaplayer.nano.Proto.Song;
39 
40 // Proto should be available in AOSP.
41 import com.google.protobuf.nano.MessageNano;
42 import com.google.protobuf.nano.InvalidProtocolBufferNanoException;
43 
44 import java.io.IOException;
45 import java.io.File;
46 import java.util.ArrayList;
47 import java.util.Base64;
48 import java.util.Collections;
49 import java.util.List;
50 
51 /**
52  * TODO: Consider doing all content provider accesses and player operations asynchronously.
53  */
54 public 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 
Player(Context context, MediaSession session, DataModel dataModel)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 
makeNotificationAction(String action, int iconId, int stringId)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 
requestAudioFocus(Runnable onSuccess)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
onPlay()169     public void onPlay() {
170         super.onPlay();
171         if (Log.isLoggable(TAG, Log.DEBUG)) {
172             Log.d(TAG, "onPlay");
173         }
174         requestAudioFocus(() -> resumePlayback());
175     }
176 
177     @Override
onPause()178     public void onPause() {
179         super.onPause();
180         if (Log.isLoggable(TAG, Log.DEBUG)) {
181             Log.d(TAG, "onPause");
182         }
183         pausePlayback();
184         mAudioManager.abandonAudioFocus(mAudioFocusListener);
185     }
186 
destroy()187     public void destroy() {
188         stopPlayback();
189         mNotificationManager.cancelAll();
190         mAudioManager.abandonAudioFocus(mAudioFocusListener);
191         mMediaPlayer.release();
192     }
193 
saveState()194     public void saveState() {
195         if (mQueue == null || mQueue.isEmpty()) {
196             return;
197         }
198 
199         Playlist playlist = new Playlist();
200         playlist.songs = new Song[mQueue.size()];
201 
202         int idx = 0;
203         for (QueueItem item : mQueue) {
204             Song song = new Song();
205             song.queueId = item.getQueueId();
206             MediaDescription description = item.getDescription();
207             song.mediaId = description.getMediaId();
208             song.title = description.getTitle().toString();
209             song.subtitle = description.getSubtitle().toString();
210             song.path = description.getExtras().getString(DataModel.PATH_KEY);
211 
212             playlist.songs[idx] = song;
213             idx++;
214         }
215         playlist.currentQueueId = mQueue.get(mCurrentQueueIdx).getQueueId();
216         playlist.name = CURRENT_PLAYLIST_KEY;
217 
218         // Go to Base64 to ensure that we can actually store the string in a sharedpref. This is
219         // slightly wasteful because of the fact that base64 expands the size a bit but it's a
220         // lot less riskier than abusing the java string to directly store bytes coming out of
221         // proto encoding.
222         String serialized = Base64.getEncoder().encodeToString(MessageNano.toByteArray(playlist));
223         SharedPreferences.Editor editor = mSharedPrefs.edit();
224         editor.putString(CURRENT_PLAYLIST_KEY, serialized);
225         editor.commit();
226     }
227 
maybeRebuildQueue(Playlist playlist)228     private boolean maybeRebuildQueue(Playlist playlist) {
229         List<QueueItem> queue = new ArrayList<>();
230         int foundIdx = 0;
231         // You need to check if the playlist actually is still valid because the user could have
232         // deleted files or taken out the sd card between runs so we might as well check this ahead
233         // of time before we load up the playlist.
234         for (Song song : playlist.songs) {
235             File tmp = new File(song.path);
236             if (!tmp.exists()) {
237                 continue;
238             }
239 
240             if (playlist.currentQueueId == song.queueId) {
241                 foundIdx = queue.size();
242             }
243 
244             Bundle bundle = new Bundle();
245             bundle.putString(DataModel.PATH_KEY, song.path);
246             MediaDescription description = new MediaDescription.Builder()
247                     .setMediaId(song.mediaId)
248                     .setTitle(song.title)
249                     .setSubtitle(song.subtitle)
250                     .setExtras(bundle)
251                     .build();
252             queue.add(new QueueItem(description, song.queueId));
253         }
254 
255         if (queue.isEmpty()) {
256             return false;
257         }
258 
259         mQueue = queue;
260         mCurrentQueueIdx = foundIdx;  // Resumes from beginning if last playing song was not found.
261 
262         return true;
263     }
264 
maybeRestoreState()265     public boolean maybeRestoreState() {
266         String serialized = mSharedPrefs.getString(CURRENT_PLAYLIST_KEY, null);
267         if (serialized == null) {
268             return false;
269         }
270 
271         try {
272             Playlist playlist = Playlist.parseFrom(Base64.getDecoder().decode(serialized));
273             if (!maybeRebuildQueue(playlist)) {
274                 return false;
275             }
276             updateSessionQueueState();
277 
278             requestAudioFocus(() -> {
279                 try {
280                     updatePlaybackStatePlaying();
281                     playCurrentQueueIndex();
282                 } catch (IOException e) {
283                     Log.e(TAG, "Restored queue, but couldn't resume playback.");
284                 }
285             });
286         } catch (IllegalArgumentException | InvalidProtocolBufferNanoException e) {
287             // Couldn't restore the playlist. Not the end of the world.
288             return false;
289         }
290 
291         return true;
292     }
293 
updateSessionQueueState()294     private void updateSessionQueueState() {
295         mSession.setQueueTitle(mContext.getString(R.string.playlist));
296         mSession.setQueue(mQueue);
297     }
298 
startPlayback(String key)299     private void startPlayback(String key) {
300         if (Log.isLoggable(TAG, Log.DEBUG)) {
301             Log.d(TAG, "startPlayback()");
302         }
303 
304         List<QueueItem> queue = mDataModel.getQueue();
305         int idx = 0;
306         int foundIdx = -1;
307         for (QueueItem item : queue) {
308             if (item.getDescription().getMediaId().equals(key)) {
309                 foundIdx = idx;
310                 break;
311             }
312             idx++;
313         }
314 
315         if (foundIdx == -1) {
316             mSession.setPlaybackState(mErrorState);
317             return;
318         }
319 
320         mQueue = queue;
321         mCurrentQueueIdx = foundIdx;
322         QueueItem current = mQueue.get(mCurrentQueueIdx);
323         String path = current.getDescription().getExtras().getString(DataModel.PATH_KEY);
324         MediaMetadata metadata = mDataModel.getMetadata(current.getDescription().getMediaId());
325         updateSessionQueueState();
326 
327         try {
328             play(path, metadata);
329         } catch (IOException e) {
330             Log.e(TAG, "Playback failed.", e);
331             mSession.setPlaybackState(mErrorState);
332         }
333     }
334 
resumePlayback()335     private void resumePlayback() {
336         if (Log.isLoggable(TAG, Log.DEBUG)) {
337             Log.d(TAG, "resumePlayback()");
338         }
339 
340         updatePlaybackStatePlaying();
341 
342         if (!mMediaPlayer.isPlaying()) {
343             mMediaPlayer.start();
344         }
345     }
346 
postMediaNotification(Notification.Builder builder)347     private void postMediaNotification(Notification.Builder builder) {
348         if (mQueue == null) {
349             return;
350         }
351 
352         MediaDescription current = mQueue.get(mCurrentQueueIdx).getDescription();
353         Notification notification = builder
354                 .setStyle(new Notification.MediaStyle().setMediaSession(mSession.getSessionToken()))
355                 .setContentTitle(current.getTitle())
356                 .setContentText(current.getSubtitle())
357                 .build();
358         notification.flags |= Notification.FLAG_NO_CLEAR;
359         mNotificationManager.notify(NOTIFICATION_ID, notification);
360     }
361 
updatePlaybackStatePlaying()362     private void updatePlaybackStatePlaying() {
363         if (!mSession.isActive()) {
364             mSession.setActive(true);
365         }
366 
367         // Update the state in the media session.
368         PlaybackState state = new PlaybackState.Builder()
369                 .setState(PlaybackState.STATE_PLAYING,
370                         mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED)
371                 .setActions(PLAYING_ACTIONS)
372                 .addCustomAction(mShuffle)
373                 .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId())
374                 .build();
375         mSession.setPlaybackState(state);
376 
377         // Update the media styled notification.
378         postMediaNotification(mPlayingNotificationBuilder);
379     }
380 
pausePlayback()381     private void pausePlayback() {
382         if (Log.isLoggable(TAG, Log.DEBUG)) {
383             Log.d(TAG, "pausePlayback()");
384         }
385 
386         long currentPosition = 0;
387         if (mMediaPlayer.isPlaying()) {
388             currentPosition = mMediaPlayer.getCurrentPosition();
389             mMediaPlayer.pause();
390         }
391 
392         PlaybackState state = new PlaybackState.Builder()
393                 .setState(PlaybackState.STATE_PAUSED, currentPosition, PLAYBACK_SPEED_STOPPED)
394                 .setActions(PAUSED_ACTIONS)
395                 .addCustomAction(mShuffle)
396                 .build();
397         mSession.setPlaybackState(state);
398 
399         // Update the media styled notification.
400         postMediaNotification(mPausedNotificationBuilder);
401     }
402 
stopPlayback()403     private void stopPlayback() {
404         if (Log.isLoggable(TAG, Log.DEBUG)) {
405             Log.d(TAG, "stopPlayback()");
406         }
407 
408         if (mMediaPlayer.isPlaying()) {
409             mMediaPlayer.stop();
410         }
411 
412         PlaybackState state = new PlaybackState.Builder()
413                 .setState(PlaybackState.STATE_STOPPED, PLAYBACK_POSITION_STOPPED,
414                         PLAYBACK_SPEED_STOPPED)
415                 .setActions(STOPPED_ACTIONS)
416                 .build();
417         mSession.setPlaybackState(state);
418     }
419 
advance()420     private void advance() throws IOException {
421         if (Log.isLoggable(TAG, Log.DEBUG)) {
422             Log.d(TAG, "advance()");
423         }
424         // Go to the next song if one exists. Note that if you were to support gapless
425         // playback, you would have to change this code such that you had a currently
426         // playing and a loading MediaPlayer and juggled between them while also calling
427         // setNextMediaPlayer.
428 
429         if (mQueue != null) {
430             // Keep looping around when we run off the end of our current queue.
431             mCurrentQueueIdx = (mCurrentQueueIdx + 1) % mQueue.size();
432             playCurrentQueueIndex();
433         } else {
434             stopPlayback();
435         }
436     }
437 
retreat()438     private void retreat() throws IOException {
439         if (Log.isLoggable(TAG, Log.DEBUG)) {
440             Log.d(TAG, "retreat()");
441         }
442         // Go to the next song if one exists. Note that if you were to support gapless
443         // playback, you would have to change this code such that you had a currently
444         // playing and a loading MediaPlayer and juggled between them while also calling
445         // setNextMediaPlayer.
446         if (mQueue != null) {
447             // Keep looping around when we run off the end of our current queue.
448             mCurrentQueueIdx--;
449             if (mCurrentQueueIdx < 0) {
450                 mCurrentQueueIdx = mQueue.size() - 1;
451             }
452             playCurrentQueueIndex();
453         } else {
454             stopPlayback();
455         }
456     }
457 
playCurrentQueueIndex()458     private void playCurrentQueueIndex() throws IOException {
459         MediaDescription next = mQueue.get(mCurrentQueueIdx).getDescription();
460         String path = next.getExtras().getString(DataModel.PATH_KEY);
461         MediaMetadata metadata = mDataModel.getMetadata(next.getMediaId());
462 
463         play(path, metadata);
464     }
465 
play(String path, MediaMetadata metadata)466     private void play(String path, MediaMetadata metadata) throws IOException {
467         if (Log.isLoggable(TAG, Log.DEBUG)) {
468             Log.d(TAG, "play path=" + path + " metadata=" + metadata);
469         }
470 
471         mMediaPlayer.reset();
472         mMediaPlayer.setDataSource(path);
473         mMediaPlayer.prepare();
474         mMediaPlayer.start();
475 
476         if (metadata != null) {
477             mSession.setMetadata(metadata);
478         }
479         updatePlaybackStatePlaying();
480     }
481 
safeAdvance()482     private void safeAdvance() {
483         try {
484             advance();
485         } catch (IOException e) {
486             Log.e(TAG, "Failed to advance.", e);
487             mSession.setPlaybackState(mErrorState);
488         }
489     }
490 
safeRetreat()491     private void safeRetreat() {
492         try {
493             retreat();
494         } catch (IOException e) {
495             Log.e(TAG, "Failed to advance.", e);
496             mSession.setPlaybackState(mErrorState);
497         }
498     }
499 
500     /**
501      * This is a naive implementation of shuffle, previously played songs may repeat after the
502      * shuffle operation. Only call this from the main thread.
503      */
shuffle()504     private void shuffle() {
505         if (Log.isLoggable(TAG, Log.DEBUG)) {
506             Log.d(TAG, "Shuffling");
507         }
508 
509         if (mQueue != null) {
510             QueueItem current = mQueue.remove(mCurrentQueueIdx);
511             Collections.shuffle(mQueue);
512             mQueue.add(0, current);
513             mCurrentQueueIdx = 0;
514             updateSessionQueueState();
515             updatePlaybackStatePlaying();
516         }
517     }
518 
519     @Override
onPlayFromMediaId(String mediaId, Bundle extras)520     public void onPlayFromMediaId(String mediaId, Bundle extras) {
521         super.onPlayFromMediaId(mediaId, extras);
522         if (Log.isLoggable(TAG, Log.DEBUG)) {
523             Log.d(TAG, "onPlayFromMediaId mediaId" + mediaId + " extras=" + extras);
524         }
525 
526         requestAudioFocus(() -> startPlayback(mediaId));
527     }
528 
529     @Override
onSkipToNext()530     public void onSkipToNext() {
531         if (Log.isLoggable(TAG, Log.DEBUG)) {
532             Log.d(TAG, "onSkipToNext()");
533         }
534         safeAdvance();
535     }
536 
537     @Override
onSkipToPrevious()538     public void onSkipToPrevious() {
539         if (Log.isLoggable(TAG, Log.DEBUG)) {
540             Log.d(TAG, "onSkipToPrevious()");
541         }
542         safeRetreat();
543     }
544 
545     @Override
onSkipToQueueItem(long id)546     public void onSkipToQueueItem(long id) {
547         int idx = (int) id;
548         MediaSession.QueueItem item = mQueue.get(idx);
549         MediaDescription description = item.getDescription();
550 
551         String path = description.getExtras().getString(DataModel.PATH_KEY);
552         MediaMetadata metadata = mDataModel.getMetadata(description.getMediaId());
553 
554         try {
555             play(path, metadata);
556             mCurrentQueueIdx = idx;
557         } catch (IOException e) {
558             Log.e(TAG, "Failed to play.", e);
559             mSession.setPlaybackState(mErrorState);
560         }
561     }
562 
563     @Override
onCustomAction(String action, Bundle extras)564     public void onCustomAction(String action, Bundle extras) {
565         switch (action) {
566             case SHUFFLE:
567                 shuffle();
568                 break;
569             default:
570                 Log.e(TAG, "Unhandled custom action: " + action);
571         }
572     }
573 
574     private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() {
575         @Override
576         public void onAudioFocusChange(int focus) {
577             switch (focus) {
578                 case AudioManager.AUDIOFOCUS_GAIN:
579                     resumePlayback();
580                     break;
581                 case AudioManager.AUDIOFOCUS_LOSS:
582                 case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
583                 case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
584                     pausePlayback();
585                     break;
586                 default:
587                     Log.e(TAG, "Unhandled audio focus type: " + focus);
588             }
589         }
590     };
591 
592     private OnCompletionListener mOnCompletionListener = new OnCompletionListener() {
593         @Override
594         public void onCompletion(MediaPlayer mediaPlayer) {
595             if (Log.isLoggable(TAG, Log.DEBUG)) {
596                 Log.d(TAG, "onCompletion()");
597             }
598             safeAdvance();
599         }
600     };
601 }
602