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