blob: 0aade153d43ce9a07f5573d6b39069f31db872b7 [file] [log] [blame]
/*
* 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();
}
};
}