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