blob: 9eba036aa6f5bfefd5e498ee54313b0d6364141d [file] [log] [blame]
/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.bluetooth.avrcp;
import android.annotation.Nullable;
import android.media.MediaMetadata;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import java.util.List;
import java.util.Objects;
/*
* A class to synchronize Media Controller Callbacks and only pass through
* an update once all the relevant information is current.
*
* TODO (apanicke): Once MediaPlayer2 is supported better, replace this class
* with that.
*/
class MediaPlayerWrapper {
private static final String TAG = "AvrcpMediaPlayerWrapper";
private static final boolean DEBUG = false;
static boolean sTesting = false;
private MediaController mMediaController;
private String mPackageName;
private Looper mLooper;
private MediaData mCurrentData;
@GuardedBy("mCallbackLock")
private MediaControllerListener mControllerCallbacks = null;
private final Object mCallbackLock = new Object();
private Callback mRegisteredCallback = null;
public interface Callback {
void mediaUpdatedCallback(MediaData data);
}
boolean isPlaybackStateReady() {
if (getPlaybackState() == null) {
d("isPlaybackStateReady(): PlaybackState is null");
return false;
}
return true;
}
boolean isMetadataReady() {
if (getMetadata() == null) {
d("isMetadataReady(): Metadata is null");
return false;
}
return true;
}
MediaPlayerWrapper(MediaController controller, Looper looper) {
mMediaController = controller;
mPackageName = controller.getPackageName();
mLooper = looper;
mCurrentData = new MediaData(null, null, null);
mCurrentData.queue = Util.toMetadataList(getQueue());
mCurrentData.metadata = Util.toMetadata(getMetadata());
mCurrentData.state = getPlaybackState();
}
void cleanup() {
unregisterCallback();
mMediaController = null;
mLooper = null;
}
String getPackageName() {
return mPackageName;
}
protected List<MediaSession.QueueItem> getQueue() {
return mMediaController.getQueue();
}
protected MediaMetadata getMetadata() {
return mMediaController.getMetadata();
}
Metadata getCurrentMetadata() {
return Util.toMetadata(getMetadata());
}
PlaybackState getPlaybackState() {
return mMediaController.getPlaybackState();
}
long getActiveQueueID() {
if (mMediaController.getPlaybackState() == null) return -1;
return mMediaController.getPlaybackState().getActiveQueueItemId();
}
List<Metadata> getCurrentQueue() {
return mCurrentData.queue;
}
// We don't return the cached info here in order to always provide the freshest data.
MediaData getCurrentMediaData() {
MediaData data = new MediaData(
getCurrentMetadata(),
getPlaybackState(),
getCurrentQueue());
return data;
}
void playItemFromQueue(long qid) {
// Return immediately if no queue exists.
if (getQueue() == null) {
Log.w(TAG, "playItemFromQueue: Trying to play item for player that has no queue: "
+ mPackageName);
return;
}
MediaController.TransportControls controller = mMediaController.getTransportControls();
controller.skipToQueueItem(qid);
}
// TODO (apanicke): Implement shuffle and repeat support. Right now these use custom actions
// and it may only be possible to do this with Google Play Music
boolean isShuffleSupported() {
return false;
}
boolean isRepeatSupported() {
return false;
}
void toggleShuffle(boolean on) {
return;
}
void toggleRepeat(boolean on) {
return;
}
/**
* Return whether the queue, metadata, and queueID are all in sync.
*/
boolean isMetadataSynced() {
if (getQueue() != null && getActiveQueueID() != -1) {
// Check if currentPlayingQueueId is in the current Queue
MediaSession.QueueItem currItem = null;
for (MediaSession.QueueItem item : getQueue()) {
if (item.getQueueId()
== getActiveQueueID()) { // The item exists in the current queue
currItem = item;
break;
}
}
// Check if current playing song in Queue matches current Metadata
Metadata qitem = Util.toMetadata(currItem);
Metadata mdata = Util.toMetadata(getMetadata());
if (currItem == null || !qitem.equals(mdata)) {
if (DEBUG) {
Log.d(TAG, "Metadata currently out of sync for " + mPackageName);
Log.d(TAG, " └ Current queueItem: " + qitem);
Log.d(TAG, " └ Current metadata : " + mdata);
}
return false;
}
}
return true;
}
/**
* Register a callback which gets called when media updates happen. The callbacks are
* called on the same Looper that was passed in to create this object.
*/
void registerCallback(Callback callback) {
if (callback == null) {
e("Cannot register null callbacks for " + mPackageName);
return;
}
synchronized (mCallbackLock) {
mRegisteredCallback = callback;
}
// Update the current data since it could have changed while we weren't registered for
// updates
mCurrentData = new MediaData(
Util.toMetadata(getMetadata()),
getPlaybackState(),
Util.toMetadataList(getQueue()));
mControllerCallbacks = new MediaControllerListener(mMediaController, mLooper);
}
/**
* Unregisters from updates. Note, this doesn't require the looper to be shut down.
*/
void unregisterCallback() {
// Prevent a race condition where a callback could be called while shutting down
synchronized (mCallbackLock) {
mRegisteredCallback = null;
}
if (mControllerCallbacks == null) return;
mControllerCallbacks.cleanup();
mControllerCallbacks = null;
}
void updateMediaController(MediaController newController) {
if (newController == mMediaController) return;
mMediaController = newController;
synchronized (mCallbackLock) {
if (mRegisteredCallback == null || mControllerCallbacks == null) {
d("Controller for " + mPackageName + " maybe is not activated.");
return;
}
}
mControllerCallbacks.cleanup();
// Update the current data since it could be different on the new controller for the player
mCurrentData = new MediaData(
Util.toMetadata(getMetadata()),
getPlaybackState(),
Util.toMetadataList(getQueue()));
mControllerCallbacks = new MediaControllerListener(mMediaController, mLooper);
d("Controller for " + mPackageName + " was updated.");
}
private void sendMediaUpdate() {
MediaData newData = new MediaData(
Util.toMetadata(getMetadata()),
getPlaybackState(),
Util.toMetadataList(getQueue()));
if (newData.equals(mCurrentData)) {
// This may happen if the controller is fully synced by the time the
// first update is completed
Log.v(TAG, "Trying to update with last sent metadata");
return;
}
synchronized (mCallbackLock) {
if (mRegisteredCallback == null) {
Log.e(TAG, mPackageName
+ ": Trying to send an update with no registered callback");
return;
}
Log.v(TAG, "trySendMediaUpdate(): Metadata has been updated for " + mPackageName);
mRegisteredCallback.mediaUpdatedCallback(newData);
}
mCurrentData = newData;
}
class TimeoutHandler extends Handler {
private static final int MSG_TIMEOUT = 0;
private static final long CALLBACK_TIMEOUT_MS = 2000;
TimeoutHandler(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message msg) {
if (msg.what != MSG_TIMEOUT) {
Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what);
return;
}
Log.e(TAG, "Timeout while waiting for metadata to sync for " + mPackageName);
Log.e(TAG, " └ Current Metadata: " + Util.toMetadata(getMetadata()));
Log.e(TAG, " └ Current Playstate: " + getPlaybackState());
List<Metadata> current_queue = Util.toMetadataList(getQueue());
for (int i = 0; i < current_queue.size(); i++) {
Log.e(TAG, " └ QueueItem(" + i + "): " + current_queue.get(i));
}
sendMediaUpdate();
// TODO(apanicke): Add metric collection here.
if (sTesting) Log.wtfStack(TAG, "Crashing the stack");
}
}
class MediaControllerListener extends MediaController.Callback {
private final Object mTimeoutHandlerLock = new Object();
private Handler mTimeoutHandler;
private MediaController mController;
MediaControllerListener(MediaController controller, Looper newLooper) {
synchronized (mTimeoutHandlerLock) {
mTimeoutHandler = new TimeoutHandler(newLooper);
mController = controller;
// Register the callbacks to execute on the same thread as the timeout thread. This
// prevents a race condition where a timeout happens at the same time as an update.
mController.registerCallback(this, mTimeoutHandler);
}
}
void cleanup() {
synchronized (mTimeoutHandlerLock) {
mController.unregisterCallback(this);
mController = null;
mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT);
mTimeoutHandler = null;
}
}
void trySendMediaUpdate() {
synchronized (mTimeoutHandlerLock) {
if (mTimeoutHandler == null) return;
mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT);
if (!isMetadataSynced()) {
d("trySendMediaUpdate(): Starting media update timeout");
mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT,
TimeoutHandler.CALLBACK_TIMEOUT_MS);
return;
}
}
sendMediaUpdate();
}
@Override
public void onMetadataChanged(@Nullable MediaMetadata metadata) {
if (!isMetadataReady()) {
Log.v(TAG, "onMetadataChanged(): " + mPackageName
+ " tried to update with no queue");
return;
}
if (DEBUG) {
Log.v(TAG, "onMetadataChanged(): " + mPackageName + " : "
+ Util.toMetadata(metadata));
}
if (!Objects.equals(metadata, getMetadata())) {
e("The callback metadata doesn't match controller metadata");
}
// TODO: Certain players update different metadata fields as they load, such as Album
// Art. For track changed updates we only care about the song information like title
// and album and duration. In the future we can use this to know when Album art is
// loaded.
// TODO: Spotify needs a metadata update debouncer as it sometimes updates the metadata
// twice in a row with the only difference being that the song duration is rounded to
// the nearest second.
if (Objects.equals(metadata, mCurrentData.metadata)) {
Log.w(TAG, "onMetadataChanged(): " + mPackageName
+ " tried to update with no new data");
return;
}
trySendMediaUpdate();
}
@Override
public void onPlaybackStateChanged(@Nullable PlaybackState state) {
if (!isPlaybackStateReady()) {
Log.v(TAG, "onPlaybackStateChanged(): " + mPackageName
+ " tried to update with no queue");
return;
}
Log.v(TAG, "onPlaybackStateChanged(): " + mPackageName + " : " + state.toString());
if (!playstateEquals(state, getPlaybackState())) {
e("The callback playback state doesn't match the current state");
}
if (playstateEquals(state, mCurrentData.state)) {
Log.w(TAG, "onPlaybackStateChanged(): " + mPackageName
+ " tried to update with no new data");
return;
}
// If there is no playstate, ignore the update.
if (state.getState() == PlaybackState.STATE_NONE) {
Log.v(TAG, "Waiting to send update as controller has no playback state");
return;
}
trySendMediaUpdate();
}
@Override
public void onQueueChanged(@Nullable List<MediaSession.QueueItem> queue) {
if (!isPlaybackStateReady() || !isMetadataReady()) {
Log.v(TAG, "onQueueChanged(): " + mPackageName
+ " tried to update with no queue");
return;
}
Log.v(TAG, "onQueueChanged(): " + mPackageName);
if (!Objects.equals(queue, getQueue())) {
e("The callback queue isn't the current queue");
}
List<Metadata> current_queue = Util.toMetadataList(queue);
if (current_queue.equals(mCurrentData.queue)) {
Log.w(TAG, "onQueueChanged(): " + mPackageName
+ " tried to update with no new data");
return;
}
if (DEBUG) {
for (int i = 0; i < current_queue.size(); i++) {
Log.d(TAG, " └ QueueItem(" + i + "): " + current_queue.get(i));
}
}
trySendMediaUpdate();
}
@Override
public void onSessionDestroyed() {
Log.w(TAG, "The session was destroyed " + mPackageName);
}
@VisibleForTesting
Handler getTimeoutHandler() {
return mTimeoutHandler;
}
}
/**
* Checks wheter the core information of two PlaybackStates match. This function allows a
* certain amount of deviation between the position fields of the PlaybackStates. This is to
* prevent matches from failing when updates happen in quick succession.
*
* The maximum allowed deviation is defined by PLAYSTATE_BOUNCE_IGNORE_PERIOD and is measured
* in milliseconds.
*/
private static final long PLAYSTATE_BOUNCE_IGNORE_PERIOD = 500;
static boolean playstateEquals(PlaybackState a, PlaybackState b) {
if (a == b) return true;
if (a != null && b != null
&& a.getState() == b.getState()
&& a.getActiveQueueItemId() == b.getActiveQueueItemId()
&& Math.abs(a.getPosition() - b.getPosition()) < PLAYSTATE_BOUNCE_IGNORE_PERIOD) {
return true;
}
return false;
}
private static void e(String message) {
if (sTesting) {
Log.wtfStack(TAG, message);
} else {
Log.e(TAG, message);
}
}
private void d(String message) {
if (DEBUG) Log.d(TAG, mPackageName + ": " + message);
}
@VisibleForTesting
Handler getTimeoutHandler() {
if (mControllerCallbacks == null) return null;
return mControllerCallbacks.getTimeoutHandler();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(mMediaController.toString() + "\n");
sb.append("Current Data:\n");
sb.append(" Song: " + mCurrentData.metadata + "\n");
sb.append(" PlayState: " + mCurrentData.state + "\n");
sb.append(" Queue: size=" + mCurrentData.queue.size() + "\n");
for (Metadata data : mCurrentData.queue) {
sb.append(" " + data + "\n");
}
return sb.toString();
}
}