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