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 // 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 onPause()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 destroy()192 public void destroy() { 193 stopPlayback(); 194 mNotificationManager.cancelAll(); 195 mAudioManager.abandonAudioFocus(mAudioFocusListener); 196 mMediaPlayer.release(); 197 } 198 saveState()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 maybeRebuildQueue(Playlist playlist)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 maybeRestoreState()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 updateSessionQueueState()301 private void updateSessionQueueState() { 302 mSession.setQueueTitle(mContext.getString(R.string.playlist)); 303 mSession.setQueue(mQueue); 304 } 305 startPlayback(String key)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 resumePlayback()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 postMediaNotification(Notification.Builder builder)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 updatePlaybackStatePlaying()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 pausePlayback()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 .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId()) 405 .build(); 406 mSession.setPlaybackState(state); 407 408 // Update the media styled notification. 409 postMediaNotification(mPausedNotificationBuilder); 410 } 411 stopPlayback()412 private void stopPlayback() { 413 if (Log.isLoggable(TAG, Log.DEBUG)) { 414 Log.d(TAG, "stopPlayback()"); 415 } 416 417 if (mMediaPlayer.isPlaying()) { 418 mMediaPlayer.stop(); 419 } 420 421 PlaybackState state = new PlaybackState.Builder() 422 .setState(PlaybackState.STATE_STOPPED, PLAYBACK_POSITION_STOPPED, 423 PLAYBACK_SPEED_STOPPED) 424 .setActions(STOPPED_ACTIONS) 425 .build(); 426 mSession.setPlaybackState(state); 427 } 428 advance()429 private void advance() throws IOException { 430 if (Log.isLoggable(TAG, Log.DEBUG)) { 431 Log.d(TAG, "advance()"); 432 } 433 // Go to the next song if one exists. Note that if you were to support gapless 434 // playback, you would have to change this code such that you had a currently 435 // playing and a loading MediaPlayer and juggled between them while also calling 436 // setNextMediaPlayer. 437 438 if (mQueue != null && !mQueue.isEmpty()) { 439 // Keep looping around when we run off the end of our current queue. 440 mCurrentQueueIdx = (mCurrentQueueIdx + 1) % mQueue.size(); 441 playCurrentQueueIndex(); 442 } else { 443 stopPlayback(); 444 } 445 } 446 retreat()447 private void retreat() throws IOException { 448 if (Log.isLoggable(TAG, Log.DEBUG)) { 449 Log.d(TAG, "retreat()"); 450 } 451 // Go to the next song if one exists. Note that if you were to support gapless 452 // playback, you would have to change this code such that you had a currently 453 // playing and a loading MediaPlayer and juggled between them while also calling 454 // setNextMediaPlayer. 455 if (mQueue != null) { 456 // Keep looping around when we run off the end of our current queue. 457 mCurrentQueueIdx--; 458 if (mCurrentQueueIdx < 0) { 459 mCurrentQueueIdx = mQueue.size() - 1; 460 } 461 playCurrentQueueIndex(); 462 } else { 463 stopPlayback(); 464 } 465 } 466 playCurrentQueueIndex()467 private void playCurrentQueueIndex() throws IOException { 468 MediaDescription next = mQueue.get(mCurrentQueueIdx).getDescription(); 469 String path = next.getExtras().getString(DataModel.PATH_KEY); 470 MediaMetadata metadata = mDataModel.getMetadata(next.getMediaId()); 471 472 play(path, metadata); 473 } 474 play(String path, MediaMetadata metadata)475 private void play(String path, MediaMetadata metadata) throws IOException { 476 if (Log.isLoggable(TAG, Log.DEBUG)) { 477 Log.d(TAG, "play path=" + path + " metadata=" + metadata); 478 } 479 480 mMediaPlayer.reset(); 481 mMediaPlayer.setDataSource(path); 482 mMediaPlayer.prepare(); 483 484 if (metadata != null) { 485 mSession.setMetadata(metadata); 486 } 487 boolean wasGrantedAudio = requestAudioFocus(() -> { 488 mMediaPlayer.start(); 489 updatePlaybackStatePlaying(); 490 }); 491 if (!wasGrantedAudio) { 492 // player.pause() isn't needed since it should not actually be playing, the 493 // other steps like, updating the notification and play state are needed, thus we 494 // call the pause method. 495 pausePlayback(); 496 } 497 } 498 safeAdvance()499 private void safeAdvance() { 500 try { 501 advance(); 502 } catch (IOException e) { 503 Log.e(TAG, "Failed to advance.", e); 504 mSession.setPlaybackState(mErrorState); 505 } 506 } 507 safeRetreat()508 private void safeRetreat() { 509 try { 510 retreat(); 511 } catch (IOException e) { 512 Log.e(TAG, "Failed to advance.", e); 513 mSession.setPlaybackState(mErrorState); 514 } 515 } 516 517 /** 518 * This is a naive implementation of shuffle, previously played songs may repeat after the 519 * shuffle operation. Only call this from the main thread. 520 */ shuffle()521 private void shuffle() { 522 if (Log.isLoggable(TAG, Log.DEBUG)) { 523 Log.d(TAG, "Shuffling"); 524 } 525 526 // rebuild the the queue in a shuffled form. 527 if (mQueue != null && mQueue.size() > 2) { 528 QueueItem current = mQueue.remove(mCurrentQueueIdx); 529 Collections.shuffle(mQueue); 530 mQueue.add(0, current); 531 // A QueueItem contains a queue id that's used as the key for when the user selects 532 // the current play list. This means the QueueItems must be rebuilt to have their new 533 // id's set. 534 for (int i = 0; i < mQueue.size(); i++) { 535 mQueue.set(i, new QueueItem(mQueue.get(i).getDescription(), i)); 536 } 537 mCurrentQueueIdx = 0; 538 updateSessionQueueState(); 539 } 540 } 541 542 @Override onPlayFromMediaId(String mediaId, Bundle extras)543 public void onPlayFromMediaId(String mediaId, Bundle extras) { 544 super.onPlayFromMediaId(mediaId, extras); 545 if (Log.isLoggable(TAG, Log.DEBUG)) { 546 Log.d(TAG, "onPlayFromMediaId mediaId" + mediaId + " extras=" + extras); 547 } 548 549 requestAudioFocus(() -> startPlayback(mediaId)); 550 } 551 552 @Override onSkipToNext()553 public void onSkipToNext() { 554 if (Log.isLoggable(TAG, Log.DEBUG)) { 555 Log.d(TAG, "onSkipToNext()"); 556 } 557 safeAdvance(); 558 } 559 560 @Override onSkipToPrevious()561 public void onSkipToPrevious() { 562 if (Log.isLoggable(TAG, Log.DEBUG)) { 563 Log.d(TAG, "onSkipToPrevious()"); 564 } 565 safeRetreat(); 566 } 567 568 @Override onSkipToQueueItem(long id)569 public void onSkipToQueueItem(long id) { 570 try { 571 mCurrentQueueIdx = (int) id; 572 playCurrentQueueIndex(); 573 } catch (IOException e) { 574 Log.e(TAG, "Failed to play.", e); 575 mSession.setPlaybackState(mErrorState); 576 } 577 } 578 579 @Override onCustomAction(String action, Bundle extras)580 public void onCustomAction(String action, Bundle extras) { 581 switch (action) { 582 case SHUFFLE: 583 shuffle(); 584 break; 585 default: 586 Log.e(TAG, "Unhandled custom action: " + action); 587 } 588 } 589 590 private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() { 591 @Override 592 public void onAudioFocusChange(int focus) { 593 switch (focus) { 594 case AudioManager.AUDIOFOCUS_GAIN: 595 resumePlayback(); 596 break; 597 case AudioManager.AUDIOFOCUS_LOSS: 598 case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: 599 case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: 600 pausePlayback(); 601 break; 602 default: 603 Log.e(TAG, "Unhandled audio focus type: " + focus); 604 } 605 } 606 }; 607 608 private OnCompletionListener mOnCompletionListener = new OnCompletionListener() { 609 @Override 610 public void onCompletion(MediaPlayer mediaPlayer) { 611 if (Log.isLoggable(TAG, Log.DEBUG)) { 612 Log.d(TAG, "onCompletion()"); 613 } 614 safeAdvance(); 615 } 616 }; 617 } 618