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