MediaPlayerWrapper.java revision 09b958725cbdb2a19ef0ac667c6e62145141404c
1/* 2 * Copyright 2018 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 17package com.android.bluetooth.avrcp; 18 19import android.annotation.Nullable; 20import android.media.MediaMetadata; 21import android.media.session.MediaSession; 22import android.media.session.PlaybackState; 23import android.os.Handler; 24import android.os.Looper; 25import android.os.Message; 26import android.support.annotation.GuardedBy; 27import android.support.annotation.VisibleForTesting; 28import android.util.Log; 29 30import java.util.List; 31import java.util.Objects; 32 33/* 34 * A class to synchronize Media Controller Callbacks and only pass through 35 * an update once all the relevant information is current. 36 * 37 * TODO (apanicke): Once MediaPlayer2 is supported better, replace this class 38 * with that. 39 */ 40class MediaPlayerWrapper { 41 private static final String TAG = "NewAvrcpMediaPlayerWrapper"; 42 private static final boolean DEBUG = true; 43 static boolean sTesting = false; 44 45 private MediaController mMediaController; 46 private String mPackageName; 47 private Looper mLooper; 48 49 private MediaData mCurrentData; 50 51 @GuardedBy("mCallbackLock") 52 private MediaControllerListener mControllerCallbacks = null; 53 private final Object mCallbackLock = new Object(); 54 private Callback mRegisteredCallback = null; 55 56 57 protected MediaPlayerWrapper() { 58 mCurrentData = new MediaData(null, null, null); 59 } 60 61 public interface Callback { 62 void mediaUpdatedCallback(MediaData data); 63 } 64 65 boolean isReady() { 66 if (getPlaybackState() == null) { 67 d("isReady(): PlaybackState is null"); 68 return false; 69 } 70 71 if (getMetadata() == null) { 72 d("isReady(): Metadata is null"); 73 return false; 74 } 75 76 return true; 77 } 78 79 // TODO (apanicke): Implement a factory to make testing and creating interop wrappers easier 80 static MediaPlayerWrapper wrap(MediaController controller, Looper looper) { 81 if (controller == null || looper == null) { 82 e("MediaPlayerWrapper.wrap(): Null parameter - Controller: " + controller 83 + " | Looper: " + looper); 84 return null; 85 } 86 87 MediaPlayerWrapper newWrapper; 88 if (controller.getPackageName().equals("com.google.android.music")) { 89 Log.v(TAG, "Creating compatibility wrapper for Google Play Music"); 90 newWrapper = new GPMWrapper(); 91 } else { 92 newWrapper = new MediaPlayerWrapper(); 93 } 94 95 newWrapper.mMediaController = controller; 96 newWrapper.mPackageName = controller.getPackageName(); 97 newWrapper.mLooper = looper; 98 99 newWrapper.mCurrentData.queue = Util.toMetadataList(newWrapper.getQueue()); 100 newWrapper.mCurrentData.metadata = Util.toMetadata(newWrapper.getMetadata()); 101 newWrapper.mCurrentData.state = newWrapper.getPlaybackState(); 102 return newWrapper; 103 } 104 105 void cleanup() { 106 unregisterCallback(); 107 108 mMediaController = null; 109 mLooper = null; 110 } 111 112 String getPackageName() { 113 return mPackageName; 114 } 115 116 protected List<MediaSession.QueueItem> getQueue() { 117 return mMediaController.getQueue(); 118 } 119 120 protected MediaMetadata getMetadata() { 121 return mMediaController.getMetadata(); 122 } 123 124 Metadata getCurrentMetadata() { 125 // Try to use the now playing list if the information exists. 126 if (getActiveQueueID() != -1) { 127 for (Metadata data : getCurrentQueue()) { 128 if (data.mediaId.equals(Util.NOW_PLAYING_PREFIX + getActiveQueueID())) { 129 d("getCurrentMetadata: Using playlist data: " + data.toString()); 130 return data.clone(); 131 } 132 } 133 } 134 135 return Util.toMetadata(getMetadata()); 136 } 137 138 PlaybackState getPlaybackState() { 139 return mMediaController.getPlaybackState(); 140 } 141 142 long getActiveQueueID() { 143 if (mMediaController.getPlaybackState() == null) return -1; 144 return mMediaController.getPlaybackState().getActiveQueueItemId(); 145 } 146 147 List<Metadata> getCurrentQueue() { 148 return mCurrentData.queue; 149 } 150 151 // We don't return the cached info here in order to always provide the freshest data. 152 MediaData getCurrentMediaData() { 153 MediaData data = new MediaData( 154 getCurrentMetadata(), 155 getPlaybackState(), 156 getCurrentQueue()); 157 return data; 158 } 159 160 void playItemFromQueue(long qid) { 161 // Return immediately if no queue exists. 162 if (getQueue() == null) { 163 Log.w(TAG, "playItemFromQueue: Trying to play item for player that has no queue: " 164 + mPackageName); 165 return; 166 } 167 168 MediaController.TransportControls controller = mMediaController.getTransportControls(); 169 controller.skipToQueueItem(qid); 170 } 171 172 // TODO (apanicke): Implement shuffle and repeat support. Right now these use custom actions 173 // and it may only be possible to do this with Google Play Music 174 boolean isShuffleSupported() { 175 return false; 176 } 177 178 boolean isRepeatSupported() { 179 return false; 180 } 181 182 void toggleShuffle(boolean on) { 183 return; 184 } 185 186 void toggleRepeat(boolean on) { 187 return; 188 } 189 190 /** 191 * Return whether the queue, metadata, and queueID are all in sync. 192 */ 193 boolean isMetadataSynced() { 194 if (getQueue() != null && getActiveQueueID() != -1) { 195 // Check if currentPlayingQueueId is in the current Queue 196 MediaSession.QueueItem currItem = null; 197 198 for (MediaSession.QueueItem item : getQueue()) { 199 if (item.getQueueId() 200 == getActiveQueueID()) { // The item exists in the current queue 201 currItem = item; 202 break; 203 } 204 } 205 206 // Check if current playing song in Queue matches current Metadata 207 Metadata qitem = Util.toMetadata(currItem); 208 Metadata mdata = Util.toMetadata(getMetadata()); 209 if (currItem == null || !qitem.equals(mdata)) { 210 if (DEBUG) { 211 Log.d(TAG, "Metadata currently out of sync for " + mPackageName); 212 Log.d(TAG, " └ Current queueItem: " + qitem); 213 Log.d(TAG, " └ Current metadata : " + mdata); 214 } 215 return false; 216 } 217 } 218 219 return true; 220 } 221 222 /** 223 * Register a callback which gets called when media updates happen. The callbacks are 224 * called on the same Looper that was passed in to create this object. 225 */ 226 void registerCallback(Callback callback) { 227 if (callback == null) { 228 e("Cannot register null callbacks for " + mPackageName); 229 return; 230 } 231 232 synchronized (mCallbackLock) { 233 mRegisteredCallback = callback; 234 } 235 236 // Update the current data since it could have changed while we weren't registered for 237 // updates 238 mCurrentData = new MediaData( 239 Util.toMetadata(getMetadata()), 240 getPlaybackState(), 241 Util.toMetadataList(getQueue())); 242 243 mControllerCallbacks = new MediaControllerListener(mLooper); 244 } 245 246 /** 247 * Unregisters from updates. Note, this doesn't require the looper to be shut down. 248 */ 249 void unregisterCallback() { 250 // Prevent a race condition where a callback could be called while shutting down 251 synchronized (mCallbackLock) { 252 mRegisteredCallback = null; 253 } 254 255 if (mControllerCallbacks == null) return; 256 mControllerCallbacks.cleanup(); 257 mControllerCallbacks = null; 258 } 259 260 void updateMediaController(MediaController newController) { 261 if (newController == mMediaController) return; 262 263 synchronized (mCallbackLock) { 264 if (mRegisteredCallback == null || mControllerCallbacks == null) { 265 return; 266 } 267 } 268 269 mControllerCallbacks.cleanup(); 270 mMediaController = newController; 271 272 // Update the current data since it could be different on the new controller for the player 273 mCurrentData = new MediaData( 274 Util.toMetadata(getMetadata()), 275 getPlaybackState(), 276 Util.toMetadataList(getQueue())); 277 278 mControllerCallbacks = new MediaControllerListener(mLooper); 279 d("Controller for " + mPackageName + " was updated."); 280 } 281 282 private void sendMediaUpdate() { 283 MediaData newData = new MediaData( 284 Util.toMetadata(getMetadata()), 285 getPlaybackState(), 286 Util.toMetadataList(getQueue())); 287 288 if (newData.equals(mCurrentData)) { 289 // This may happen if the controller is fully synced by the time the 290 // first update is completed 291 Log.v(TAG, "Trying to update with last sent metadata"); 292 return; 293 } 294 295 synchronized (mCallbackLock) { 296 if (mRegisteredCallback == null) { 297 Log.e(TAG, mPackageName 298 + "Trying to send an update with no registered callback"); 299 return; 300 } 301 302 Log.v(TAG, "trySendMediaUpdate(): Metadata has been updated for " + mPackageName); 303 mRegisteredCallback.mediaUpdatedCallback(newData); 304 } 305 306 mCurrentData = newData; 307 } 308 309 class TimeoutHandler extends Handler { 310 private static final int MSG_TIMEOUT = 0; 311 private static final long CALLBACK_TIMEOUT_MS = 2000; 312 313 TimeoutHandler(Looper looper) { 314 super(looper); 315 } 316 317 @Override 318 public void handleMessage(Message msg) { 319 if (msg.what != MSG_TIMEOUT) { 320 Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what); 321 return; 322 } 323 324 Log.e(TAG, "Timeout while waiting for metadata to sync for " + mPackageName); 325 Log.e(TAG, " └ Current Metadata: " + Util.toMetadata(getMetadata())); 326 Log.e(TAG, " └ Current Playstate: " + getPlaybackState()); 327 List<Metadata> current_queue = Util.toMetadataList(getQueue()); 328 for (int i = 0; i < current_queue.size(); i++) { 329 Log.e(TAG, " └ QueueItem(" + i + "): " + current_queue.get(i)); 330 } 331 332 sendMediaUpdate(); 333 334 // TODO(apanicke): Add metric collection here. 335 336 if (sTesting) Log.wtfStack(TAG, "Crashing the stack"); 337 } 338 } 339 340 class MediaControllerListener extends MediaController.Callback { 341 private final Object mTimeoutHandlerLock = new Object(); 342 private Handler mTimeoutHandler; 343 344 MediaControllerListener(Looper newLooper) { 345 synchronized (mTimeoutHandlerLock) { 346 mTimeoutHandler = new TimeoutHandler(newLooper); 347 348 // Register the callbacks to execute on the same thread as the timeout thread. This 349 // prevents a race condition where a timeout happens at the same time as an update. 350 mMediaController.registerCallback(this, mTimeoutHandler); 351 } 352 } 353 354 void cleanup() { 355 synchronized (mTimeoutHandlerLock) { 356 mMediaController.unregisterCallback(this); 357 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 358 mTimeoutHandler = null; 359 } 360 } 361 362 void trySendMediaUpdate() { 363 synchronized (mTimeoutHandlerLock) { 364 if (mTimeoutHandler == null) return; 365 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 366 367 if (!isMetadataSynced()) { 368 d("trySendMediaUpdate(): Starting media update timeout"); 369 mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT, 370 TimeoutHandler.CALLBACK_TIMEOUT_MS); 371 return; 372 } 373 } 374 375 sendMediaUpdate(); 376 } 377 378 @Override 379 public void onMetadataChanged(@Nullable MediaMetadata metadata) { 380 if (!isReady()) { 381 Log.v(TAG, "onMetadataChanged(): " + mPackageName 382 + " tried to update with no queue"); 383 return; 384 } 385 386 Log.v(TAG, "onMetadataChanged(): " + mPackageName + " : " + Util.toMetadata(metadata)); 387 388 if (!Objects.equals(metadata, getMetadata())) { 389 e("The callback metadata doesn't match controller metadata"); 390 } 391 392 // TODO: Certain players update different metadata fields as they load, such as Album 393 // Art. For track changed updates we only care about the song information like title 394 // and album and duration. In the future we can use this to know when Album art is 395 // loaded. 396 397 // TODO: Spotify needs a metadata update debouncer as it sometimes updates the metadata 398 // twice in a row with the only difference being that the song duration is rounded to 399 // the nearest second. 400 if (Objects.equals(metadata, mCurrentData.metadata)) { 401 Log.w(TAG, "onMetadataChanged(): " + mPackageName 402 + " tried to update with no new data"); 403 return; 404 } 405 406 trySendMediaUpdate(); 407 } 408 409 @Override 410 public void onPlaybackStateChanged(@Nullable PlaybackState state) { 411 if (!isReady()) { 412 Log.v(TAG, "onPlaybackStateChanged(): " + mPackageName 413 + " tried to update with no queue"); 414 return; 415 } 416 417 Log.v(TAG, "onPlaybackStateChanged(): " + mPackageName + " : " + state.toString()); 418 419 if (!playstateEquals(state, getPlaybackState())) { 420 e("The callback playback state doesn't match the current state"); 421 } 422 423 if (playstateEquals(state, mCurrentData.state)) { 424 Log.w(TAG, "onPlaybackStateChanged(): " + mPackageName 425 + " tried to update with no new data"); 426 return; 427 } 428 429 // If there is no playstate, ignore the update. 430 if (state.getState() == PlaybackState.STATE_NONE) { 431 Log.v(TAG, "Waiting to send update as controller has no playback state"); 432 return; 433 } 434 435 trySendMediaUpdate(); 436 } 437 438 @Override 439 public void onQueueChanged(@Nullable List<MediaSession.QueueItem> queue) { 440 if (!isReady()) { 441 Log.v(TAG, "onQueueChanged(): " + mPackageName 442 + " tried to update with no queue"); 443 return; 444 } 445 446 Log.v(TAG, "onQueueChanged(): " + mPackageName); 447 448 if (!Objects.equals(queue, getQueue())) { 449 e("The callback queue isn't the current queue"); 450 } 451 452 List<Metadata> current_queue = Util.toMetadataList(queue); 453 if (current_queue.equals(mCurrentData.queue)) { 454 Log.w(TAG, "onQueueChanged(): " + mPackageName 455 + " tried to update with no new data"); 456 return; 457 } 458 459 if (DEBUG) { 460 for (int i = 0; i < current_queue.size(); i++) { 461 Log.d(TAG, " └ QueueItem(" + i + "): " + current_queue.get(i)); 462 } 463 } 464 465 trySendMediaUpdate(); 466 } 467 468 @Override 469 public void onSessionDestroyed() { 470 Log.w(TAG, "The session was destroyed " + mPackageName); 471 } 472 473 @VisibleForTesting 474 Handler getTimeoutHandler() { 475 return mTimeoutHandler; 476 } 477 } 478 479 /** 480 * Checks wheter the core information of two PlaybackStates match. This function allows a 481 * certain amount of deviation between the position fields of the PlaybackStates. This is to 482 * prevent matches from failing when updates happen in quick succession. 483 * 484 * The maximum allowed deviation is defined by PLAYSTATE_BOUNCE_IGNORE_PERIOD and is measured 485 * in milliseconds. 486 */ 487 private static final long PLAYSTATE_BOUNCE_IGNORE_PERIOD = 500; 488 static boolean playstateEquals(PlaybackState a, PlaybackState b) { 489 if (a == b) return true; 490 491 if (a != null && b != null 492 && a.getState() == b.getState() 493 && a.getActiveQueueItemId() == b.getActiveQueueItemId() 494 && Math.abs(a.getPosition() - b.getPosition()) < PLAYSTATE_BOUNCE_IGNORE_PERIOD) { 495 return true; 496 } 497 498 return false; 499 } 500 501 private static void e(String message) { 502 if (sTesting) { 503 Log.wtfStack(TAG, message); 504 } else { 505 Log.e(TAG, message); 506 } 507 } 508 509 private void d(String message) { 510 if (DEBUG) Log.d(TAG, mPackageName + ": " + message); 511 } 512 513 @VisibleForTesting 514 Handler getTimeoutHandler() { 515 if (mControllerCallbacks == null) return null; 516 return mControllerCallbacks.getTimeoutHandler(); 517 } 518 519 @Override 520 public String toString() { 521 StringBuilder sb = new StringBuilder(); 522 sb.append(mMediaController.toString() + "\n"); 523 sb.append("Current Data:\n"); 524 sb.append(" Song: " + mCurrentData.metadata + "\n"); 525 sb.append(" PlayState: " + mCurrentData.state + "\n"); 526 sb.append(" Queue: size=" + mCurrentData.queue.size() + "\n"); 527 for (Metadata data : mCurrentData.queue) { 528 sb.append(" " + data + "\n"); 529 } 530 return sb.toString(); 531 } 532} 533