| /* |
| * Copyright (c) 2016, The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.android.car.media.localmediaplayer; |
| |
| import android.app.Notification; |
| import android.app.NotificationManager; |
| import android.app.PendingIntent; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.SharedPreferences; |
| import android.media.AudioManager; |
| import android.media.AudioManager.OnAudioFocusChangeListener; |
| import android.media.MediaDescription; |
| import android.media.MediaMetadata; |
| import android.media.MediaPlayer; |
| import android.media.MediaPlayer.OnCompletionListener; |
| import android.media.session.MediaSession; |
| import android.media.session.MediaSession.QueueItem; |
| import android.media.session.PlaybackState; |
| import android.media.session.PlaybackState.CustomAction; |
| import android.os.Bundle; |
| import android.util.Log; |
| |
| import com.android.car.media.localmediaplayer.nano.Proto.Playlist; |
| import com.android.car.media.localmediaplayer.nano.Proto.Song; |
| |
| // Proto should be available in AOSP. |
| import com.google.protobuf.nano.MessageNano; |
| import com.google.protobuf.nano.InvalidProtocolBufferNanoException; |
| |
| import java.io.IOException; |
| import java.io.File; |
| import java.util.ArrayList; |
| import java.util.Base64; |
| import java.util.Collections; |
| import java.util.List; |
| |
| /** |
| * TODO: Consider doing all content provider accesses and player operations asynchronously. |
| */ |
| public class Player extends MediaSession.Callback { |
| private static final String TAG = "LMPlayer"; |
| private static final String SHARED_PREFS_NAME = "com.android.car.media.localmediaplayer.prefs"; |
| private static final String CURRENT_PLAYLIST_KEY = "__CURRENT_PLAYLIST_KEY__"; |
| private static final int NOTIFICATION_ID = 42; |
| private static final int REQUEST_CODE = 94043; |
| |
| private static final float PLAYBACK_SPEED = 1.0f; |
| private static final float PLAYBACK_SPEED_STOPPED = 1.0f; |
| private static final long PLAYBACK_POSITION_STOPPED = 0; |
| |
| // Note: Queues loop around so next/previous are always available. |
| private static final long PLAYING_ACTIONS = PlaybackState.ACTION_PAUSE |
| | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT |
| | PlaybackState.ACTION_SKIP_TO_PREVIOUS | PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM; |
| |
| private static final long PAUSED_ACTIONS = PlaybackState.ACTION_PLAY |
| | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT |
| | PlaybackState.ACTION_SKIP_TO_PREVIOUS; |
| |
| private static final long STOPPED_ACTIONS = PlaybackState.ACTION_PLAY |
| | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT |
| | PlaybackState.ACTION_SKIP_TO_PREVIOUS; |
| |
| private static final String SHUFFLE = "android.car.media.localmediaplayer.shuffle"; |
| |
| private final Context mContext; |
| private final MediaSession mSession; |
| private final AudioManager mAudioManager; |
| private final PlaybackState mErrorState; |
| private final DataModel mDataModel; |
| private final CustomAction mShuffle; |
| |
| private List<QueueItem> mQueue; |
| private int mCurrentQueueIdx = 0; |
| private final SharedPreferences mSharedPrefs; |
| |
| private NotificationManager mNotificationManager; |
| private Notification.Builder mPlayingNotificationBuilder; |
| private Notification.Builder mPausedNotificationBuilder; |
| |
| // TODO: Use multiple media players for gapless playback. |
| private final MediaPlayer mMediaPlayer; |
| |
| public Player(Context context, MediaSession session, DataModel dataModel) { |
| mContext = context; |
| mDataModel = dataModel; |
| mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); |
| mSession = session; |
| mSharedPrefs = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE); |
| |
| mShuffle = new CustomAction.Builder(SHUFFLE, context.getString(R.string.shuffle), |
| R.drawable.shuffle).build(); |
| |
| mMediaPlayer = new MediaPlayer(); |
| mMediaPlayer.reset(); |
| mMediaPlayer.setOnCompletionListener(mOnCompletionListener); |
| mErrorState = new PlaybackState.Builder() |
| .setState(PlaybackState.STATE_ERROR, 0, 0) |
| .setErrorMessage(context.getString(R.string.playback_error)) |
| .build(); |
| |
| mNotificationManager = |
| (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); |
| |
| // There are 2 forms of the media notification, when playing it needs to show the controls |
| // to pause & skip whereas when paused it needs to show controls to play & skip. Setup |
| // pre-populated builders for both of these up front. |
| Notification.Action prevAction = makeNotificationAction( |
| LocalMediaBrowserService.ACTION_PREV, R.drawable.ic_prev, R.string.prev); |
| Notification.Action nextAction = makeNotificationAction( |
| LocalMediaBrowserService.ACTION_NEXT, R.drawable.ic_next, R.string.next); |
| Notification.Action playAction = makeNotificationAction( |
| LocalMediaBrowserService.ACTION_PLAY, R.drawable.ic_play, R.string.play); |
| Notification.Action pauseAction = makeNotificationAction( |
| LocalMediaBrowserService.ACTION_PAUSE, R.drawable.ic_pause, R.string.pause); |
| |
| // While playing, you need prev, pause, next. |
| mPlayingNotificationBuilder = new Notification.Builder(context) |
| .setVisibility(Notification.VISIBILITY_PUBLIC) |
| .setSmallIcon(R.drawable.ic_sd_storage_black) |
| .addAction(prevAction) |
| .addAction(pauseAction) |
| .addAction(nextAction); |
| |
| // While paused, you need prev, play, next. |
| mPausedNotificationBuilder = new Notification.Builder(context) |
| .setVisibility(Notification.VISIBILITY_PUBLIC) |
| .setSmallIcon(R.drawable.ic_sd_storage_black) |
| .addAction(prevAction) |
| .addAction(playAction) |
| .addAction(nextAction); |
| } |
| |
| private Notification.Action makeNotificationAction(String action, int iconId, int stringId) { |
| PendingIntent intent = PendingIntent.getBroadcast(mContext, REQUEST_CODE, |
| new Intent(action), PendingIntent.FLAG_UPDATE_CURRENT); |
| Notification.Action notificationAction = new Notification.Action.Builder(iconId, |
| mContext.getString(stringId), intent) |
| .build(); |
| return notificationAction; |
| } |
| |
| private boolean requestAudioFocus(Runnable onSuccess) { |
| int result = mAudioManager.requestAudioFocus(mAudioFocusListener, AudioManager.STREAM_MUSIC, |
| AudioManager.AUDIOFOCUS_GAIN); |
| if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { |
| onSuccess.run(); |
| return true; |
| } |
| Log.e(TAG, "Failed to acquire audio focus"); |
| return false; |
| } |
| |
| @Override |
| public void onPlay() { |
| super.onPlay(); |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "onPlay"); |
| } |
| // Check permissions every time we try to play |
| if (!Utils.hasRequiredPermissions(mContext)) { |
| Utils.startPermissionRequest(mContext); |
| } else { |
| requestAudioFocus(() -> resumePlayback()); |
| } |
| } |
| |
| @Override |
| public void onPause() { |
| super.onPause(); |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "onPause"); |
| } |
| pausePlayback(); |
| mAudioManager.abandonAudioFocus(mAudioFocusListener); |
| } |
| |
| public void destroy() { |
| stopPlayback(); |
| mNotificationManager.cancelAll(); |
| mAudioManager.abandonAudioFocus(mAudioFocusListener); |
| mMediaPlayer.release(); |
| } |
| |
| public void saveState() { |
| if (mQueue == null || mQueue.isEmpty()) { |
| return; |
| } |
| |
| Playlist playlist = new Playlist(); |
| playlist.songs = new Song[mQueue.size()]; |
| |
| int idx = 0; |
| for (QueueItem item : mQueue) { |
| Song song = new Song(); |
| song.queueId = item.getQueueId(); |
| MediaDescription description = item.getDescription(); |
| song.mediaId = description.getMediaId(); |
| song.title = description.getTitle().toString(); |
| song.subtitle = description.getSubtitle().toString(); |
| song.path = description.getExtras().getString(DataModel.PATH_KEY); |
| |
| playlist.songs[idx] = song; |
| idx++; |
| } |
| playlist.currentQueueId = mQueue.get(mCurrentQueueIdx).getQueueId(); |
| playlist.currentSongPosition = mMediaPlayer.getCurrentPosition(); |
| playlist.name = CURRENT_PLAYLIST_KEY; |
| |
| // Go to Base64 to ensure that we can actually store the string in a sharedpref. This is |
| // slightly wasteful because of the fact that base64 expands the size a bit but it's a |
| // lot less riskier than abusing the java string to directly store bytes coming out of |
| // proto encoding. |
| String serialized = Base64.getEncoder().encodeToString(MessageNano.toByteArray(playlist)); |
| SharedPreferences.Editor editor = mSharedPrefs.edit(); |
| editor.putString(CURRENT_PLAYLIST_KEY, serialized); |
| editor.commit(); |
| } |
| |
| private boolean maybeRebuildQueue(Playlist playlist) { |
| List<QueueItem> queue = new ArrayList<>(); |
| int foundIdx = 0; |
| // You need to check if the playlist actually is still valid because the user could have |
| // deleted files or taken out the sd card between runs so we might as well check this ahead |
| // of time before we load up the playlist. |
| for (Song song : playlist.songs) { |
| File tmp = new File(song.path); |
| if (!tmp.exists()) { |
| continue; |
| } |
| |
| if (playlist.currentQueueId == song.queueId) { |
| foundIdx = queue.size(); |
| } |
| |
| Bundle bundle = new Bundle(); |
| bundle.putString(DataModel.PATH_KEY, song.path); |
| MediaDescription description = new MediaDescription.Builder() |
| .setMediaId(song.mediaId) |
| .setTitle(song.title) |
| .setSubtitle(song.subtitle) |
| .setExtras(bundle) |
| .build(); |
| queue.add(new QueueItem(description, song.queueId)); |
| } |
| |
| if (queue.isEmpty()) { |
| return false; |
| } |
| |
| mQueue = queue; |
| mCurrentQueueIdx = foundIdx; // Resumes from beginning if last playing song was not found. |
| |
| return true; |
| } |
| |
| public boolean maybeRestoreState() { |
| String serialized = mSharedPrefs.getString(CURRENT_PLAYLIST_KEY, null); |
| if (serialized == null) { |
| return false; |
| } |
| |
| try { |
| Playlist playlist = Playlist.parseFrom(Base64.getDecoder().decode(serialized)); |
| if (!maybeRebuildQueue(playlist)) { |
| return false; |
| } |
| updateSessionQueueState(); |
| |
| requestAudioFocus(() -> { |
| try { |
| playCurrentQueueIndex(); |
| mMediaPlayer.seekTo(playlist.currentSongPosition); |
| updatePlaybackStatePlaying(); |
| } catch (IOException e) { |
| Log.e(TAG, "Restored queue, but couldn't resume playback."); |
| } |
| }); |
| } catch (IllegalArgumentException | InvalidProtocolBufferNanoException e) { |
| // Couldn't restore the playlist. Not the end of the world. |
| return false; |
| } |
| |
| return true; |
| } |
| |
| private void updateSessionQueueState() { |
| mSession.setQueueTitle(mContext.getString(R.string.playlist)); |
| mSession.setQueue(mQueue); |
| } |
| |
| private void startPlayback(String key) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "startPlayback()"); |
| } |
| |
| List<QueueItem> queue = mDataModel.getQueue(); |
| int idx = 0; |
| int foundIdx = -1; |
| for (QueueItem item : queue) { |
| if (item.getDescription().getMediaId().equals(key)) { |
| foundIdx = idx; |
| break; |
| } |
| idx++; |
| } |
| |
| if (foundIdx == -1) { |
| mSession.setPlaybackState(mErrorState); |
| return; |
| } |
| |
| mQueue = new ArrayList<>(queue); |
| mCurrentQueueIdx = foundIdx; |
| QueueItem current = mQueue.get(mCurrentQueueIdx); |
| String path = current.getDescription().getExtras().getString(DataModel.PATH_KEY); |
| MediaMetadata metadata = mDataModel.getMetadata(current.getDescription().getMediaId()); |
| updateSessionQueueState(); |
| |
| try { |
| play(path, metadata); |
| } catch (IOException e) { |
| Log.e(TAG, "Playback failed.", e); |
| mSession.setPlaybackState(mErrorState); |
| } |
| } |
| |
| private void resumePlayback() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "resumePlayback()"); |
| } |
| |
| updatePlaybackStatePlaying(); |
| |
| if (!mMediaPlayer.isPlaying()) { |
| mMediaPlayer.start(); |
| } |
| } |
| |
| private void postMediaNotification(Notification.Builder builder) { |
| if (mQueue == null) { |
| return; |
| } |
| |
| MediaDescription current = mQueue.get(mCurrentQueueIdx).getDescription(); |
| Notification notification = builder |
| .setStyle(new Notification.MediaStyle().setMediaSession(mSession.getSessionToken())) |
| .setContentTitle(current.getTitle()) |
| .setContentText(current.getSubtitle()) |
| .setShowWhen(false) |
| .build(); |
| notification.flags |= Notification.FLAG_NO_CLEAR; |
| mNotificationManager.notify(NOTIFICATION_ID, notification); |
| } |
| |
| private void updatePlaybackStatePlaying() { |
| if (!mSession.isActive()) { |
| mSession.setActive(true); |
| } |
| |
| // Update the state in the media session. |
| PlaybackState state = new PlaybackState.Builder() |
| .setState(PlaybackState.STATE_PLAYING, |
| mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED) |
| .setActions(PLAYING_ACTIONS) |
| .addCustomAction(mShuffle) |
| .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId()) |
| .build(); |
| mSession.setPlaybackState(state); |
| |
| // Update the media styled notification. |
| postMediaNotification(mPlayingNotificationBuilder); |
| } |
| |
| private void pausePlayback() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "pausePlayback()"); |
| } |
| |
| long currentPosition = 0; |
| if (mMediaPlayer.isPlaying()) { |
| currentPosition = mMediaPlayer.getCurrentPosition(); |
| mMediaPlayer.pause(); |
| } |
| |
| PlaybackState state = new PlaybackState.Builder() |
| .setState(PlaybackState.STATE_PAUSED, currentPosition, PLAYBACK_SPEED_STOPPED) |
| .setActions(PAUSED_ACTIONS) |
| .addCustomAction(mShuffle) |
| .build(); |
| mSession.setPlaybackState(state); |
| |
| // Update the media styled notification. |
| postMediaNotification(mPausedNotificationBuilder); |
| } |
| |
| private void stopPlayback() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "stopPlayback()"); |
| } |
| |
| if (mMediaPlayer.isPlaying()) { |
| mMediaPlayer.stop(); |
| } |
| |
| PlaybackState state = new PlaybackState.Builder() |
| .setState(PlaybackState.STATE_STOPPED, PLAYBACK_POSITION_STOPPED, |
| PLAYBACK_SPEED_STOPPED) |
| .setActions(STOPPED_ACTIONS) |
| .build(); |
| mSession.setPlaybackState(state); |
| } |
| |
| private void advance() throws IOException { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "advance()"); |
| } |
| // Go to the next song if one exists. Note that if you were to support gapless |
| // playback, you would have to change this code such that you had a currently |
| // playing and a loading MediaPlayer and juggled between them while also calling |
| // setNextMediaPlayer. |
| |
| if (mQueue != null && !mQueue.isEmpty()) { |
| // Keep looping around when we run off the end of our current queue. |
| mCurrentQueueIdx = (mCurrentQueueIdx + 1) % mQueue.size(); |
| playCurrentQueueIndex(); |
| } else { |
| stopPlayback(); |
| } |
| } |
| |
| private void retreat() throws IOException { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "retreat()"); |
| } |
| // Go to the next song if one exists. Note that if you were to support gapless |
| // playback, you would have to change this code such that you had a currently |
| // playing and a loading MediaPlayer and juggled between them while also calling |
| // setNextMediaPlayer. |
| if (mQueue != null) { |
| // Keep looping around when we run off the end of our current queue. |
| mCurrentQueueIdx--; |
| if (mCurrentQueueIdx < 0) { |
| mCurrentQueueIdx = mQueue.size() - 1; |
| } |
| playCurrentQueueIndex(); |
| } else { |
| stopPlayback(); |
| } |
| } |
| |
| private void playCurrentQueueIndex() throws IOException { |
| MediaDescription next = mQueue.get(mCurrentQueueIdx).getDescription(); |
| String path = next.getExtras().getString(DataModel.PATH_KEY); |
| MediaMetadata metadata = mDataModel.getMetadata(next.getMediaId()); |
| |
| play(path, metadata); |
| } |
| |
| private void play(String path, MediaMetadata metadata) throws IOException { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "play path=" + path + " metadata=" + metadata); |
| } |
| |
| mMediaPlayer.reset(); |
| mMediaPlayer.setDataSource(path); |
| mMediaPlayer.prepare(); |
| |
| if (metadata != null) { |
| mSession.setMetadata(metadata); |
| } |
| boolean wasGrantedAudio = requestAudioFocus(() -> { |
| mMediaPlayer.start(); |
| updatePlaybackStatePlaying(); |
| }); |
| if (!wasGrantedAudio) { |
| // player.pause() isn't needed since it should not actually be playing, the |
| // other steps like, updating the notification and play state are needed, thus we |
| // call the pause method. |
| pausePlayback(); |
| } |
| } |
| |
| private void safeAdvance() { |
| try { |
| advance(); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to advance.", e); |
| mSession.setPlaybackState(mErrorState); |
| } |
| } |
| |
| private void safeRetreat() { |
| try { |
| retreat(); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to advance.", e); |
| mSession.setPlaybackState(mErrorState); |
| } |
| } |
| |
| /** |
| * This is a naive implementation of shuffle, previously played songs may repeat after the |
| * shuffle operation. Only call this from the main thread. |
| */ |
| private void shuffle() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Shuffling"); |
| } |
| |
| // rebuild the the queue in a shuffled form. |
| if (mQueue != null && mQueue.size() > 2) { |
| QueueItem current = mQueue.remove(mCurrentQueueIdx); |
| Collections.shuffle(mQueue); |
| mQueue.add(0, current); |
| // A QueueItem contains a queue id that's used as the key for when the user selects |
| // the current play list. This means the QueueItems must be rebuilt to have their new |
| // id's set. |
| for (int i = 0; i < mQueue.size(); i++) { |
| mQueue.set(i, new QueueItem(mQueue.get(i).getDescription(), i)); |
| } |
| mCurrentQueueIdx = 0; |
| updateSessionQueueState(); |
| } |
| } |
| |
| @Override |
| public void onPlayFromMediaId(String mediaId, Bundle extras) { |
| super.onPlayFromMediaId(mediaId, extras); |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "onPlayFromMediaId mediaId" + mediaId + " extras=" + extras); |
| } |
| |
| requestAudioFocus(() -> startPlayback(mediaId)); |
| } |
| |
| @Override |
| public void onSkipToNext() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "onSkipToNext()"); |
| } |
| safeAdvance(); |
| } |
| |
| @Override |
| public void onSkipToPrevious() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "onSkipToPrevious()"); |
| } |
| safeRetreat(); |
| } |
| |
| @Override |
| public void onSkipToQueueItem(long id) { |
| try { |
| mCurrentQueueIdx = (int) id; |
| playCurrentQueueIndex(); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to play.", e); |
| mSession.setPlaybackState(mErrorState); |
| } |
| } |
| |
| @Override |
| public void onCustomAction(String action, Bundle extras) { |
| switch (action) { |
| case SHUFFLE: |
| shuffle(); |
| break; |
| default: |
| Log.e(TAG, "Unhandled custom action: " + action); |
| } |
| } |
| |
| private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() { |
| @Override |
| public void onAudioFocusChange(int focus) { |
| switch (focus) { |
| case AudioManager.AUDIOFOCUS_GAIN: |
| resumePlayback(); |
| break; |
| case AudioManager.AUDIOFOCUS_LOSS: |
| case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: |
| case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: |
| pausePlayback(); |
| break; |
| default: |
| Log.e(TAG, "Unhandled audio focus type: " + focus); |
| } |
| } |
| }; |
| |
| private OnCompletionListener mOnCompletionListener = new OnCompletionListener() { |
| @Override |
| public void onCompletion(MediaPlayer mediaPlayer) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "onCompletion()"); |
| } |
| safeAdvance(); |
| } |
| }; |
| } |