Skip to content

Commit

Permalink
Use MediaSessionImpl.onMediaButtonEvent() to dispatch key events
Browse files Browse the repository at this point in the history
This change moves the handling of any media button event into
`MediaSessionImpl.onMediaButtonEvent(intent)`. This includes
the double click handling from `MediaSessionLegacyStub`.

The advantage is that everything is in one place which allows
to offer `MediaSession.Callback.onMediaButtonEvent` with which
an app can override the default implementation and handle media
buttons in a custom way.

Media button events can originate from various places:

- Delivered to `MediaSessionService.onStartCommand(Intent)`
  - A `PendingIntent` from the notification below API 33
  - An `Intent` sent to the `MediaButtonReceiver` by the system dispatched
    to the service
- Delivered to `MediaSessionCompat.Callback.onMediaButtonEvent(Intent)`
  implemented by `MediaSessionLegacyStub` during the session is active
  - Bluetooth (headset/remote control)
  - Apps/system using `AudioManager.dispatchKeyEvent(KeyEvent)`
  - Apps/system using `MediaControllerCompat.dispatchKeyEvent(keyEvent)`

Issue: #12
Issue: #159
Issue: #216
Issue: #249

#minor-release

PiperOrigin-RevId: 575231251
(cherry picked from commit a79d44e)
  • Loading branch information
marcbaechinger authored and rohitjoins committed Oct 20, 2023
1 parent 47a451a commit f2cf43c
Show file tree
Hide file tree
Showing 8 changed files with 746 additions and 177 deletions.
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
(([#339](https://github.com/androidx/media/issues/339)).
* Use `DataSourceBitmapLoader` by default instead of `SimpleBitmapLoader`
([#271](https://github.com/androidx/media/issues/271),[#327](https://github.com/androidx/media/issues/327)).
* Add `MediaSession.Callback.onMediaButtonEvent(Intent)` that allows apps
to override the default media button event handling.
* UI:
* Downloads:
* OkHttp Extension:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
Expand Down Expand Up @@ -1444,6 +1445,32 @@ default ListenableFuture<MediaItemsWithStartPosition> onPlaybackResumption(
MediaSession mediaSession, ControllerInfo controller) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}

/**
* Called when a media button event has been received by the session.
*
* <p>Media3 handles media button events internally. An app can override the default behaviour
* by overriding this method.
*
* <p>Return true to stop propagating the event any further. When false is returned, Media3
* handles the event and calls {@linkplain MediaSession#getPlayer() the session player}
* accordingly.
*
* <p>Apps normally don't need to override this method. When overriding this method, an app
* can/needs to handle all API-level specifics on its own. The intent passed to this method can
* come directly from the system that routed a media key event (for instance sent by Bluetooth)
* to your session.
*
* @param session The session that received the media button event.
* @param controllerInfo The controller to which the media button event is attributed to.
* @param intent The media button intent.
* @return True if the event was handled, false otherwise.
*/
@UnstableApi
default boolean onMediaButtonEvent(
MediaSession session, ControllerInfo controllerInfo, Intent intent) {
return false;
}
}

/** Representation of a list of {@linkplain MediaItem media items} and where to start playing. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import static androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEM;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.SDK_INT;
import static androidx.media3.common.util.Util.postOrRun;
import static androidx.media3.session.MediaSessionStub.UNKNOWN_SEQUENCE_NUMBER;
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
Expand All @@ -52,6 +51,7 @@
import android.os.SystemClock;
import android.support.v4.media.session.MediaSessionCompat;
import android.view.KeyEvent;
import android.view.ViewConfiguration;
import androidx.annotation.CheckResult;
import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy;
Expand Down Expand Up @@ -116,6 +116,7 @@

private final Uri sessionUri;
private final PlayerInfoChangedHandler onPlayerInfoChangedHandler;
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSession.Callback callback;
private final Context context;
private final MediaSessionStub sessionStub;
Expand Down Expand Up @@ -161,28 +162,30 @@ public MediaSessionImpl(
BitmapLoader bitmapLoader,
boolean playIfSuppressed,
boolean isPeriodicPositionUpdateEnabled) {
this.context = context;
this.instance = instance;
this.context = context;
sessionId = id;
this.sessionActivity = sessionActivity;
this.customLayout = customLayout;
this.callback = callback;
this.bitmapLoader = bitmapLoader;
this.playIfSuppressed = playIfSuppressed;
this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled;

@SuppressWarnings("nullness:assignment")
@Initialized
MediaSessionImpl thisRef = this;

sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity;
this.customLayout = customLayout;

mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper());
this.callback = callback;
this.bitmapLoader = bitmapLoader;
this.playIfSuppressed = playIfSuppressed;
this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled;
Looper applicationLooper = player.getApplicationLooper();
applicationHandler = new Handler(applicationLooper);

playerInfo = PlayerInfo.DEFAULT;
onPlayerInfoChangedHandler = new PlayerInfoChangedHandler(player.getApplicationLooper());
onPlayerInfoChangedHandler = new PlayerInfoChangedHandler(applicationLooper);
mediaPlayPauseKeyHandler = new MediaPlayPauseKeyHandler(applicationLooper);

sessionId = id;
// Build Uri that differentiate sessions across the creation/destruction in PendingIntent.
// Here's the reason why Session ID / SessionToken aren't suitable here.
// - Session ID
Expand Down Expand Up @@ -280,6 +283,7 @@ public void release() {
}
closed = true;
}
mediaPlayPauseKeyHandler.clearPendingPlayPauseTask();
applicationHandler.removeCallbacksAndMessages(null);
try {
postOrRun(
Expand Down Expand Up @@ -1080,7 +1084,16 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma
(callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo));
}

/* package */ boolean onMediaButtonEvent(Intent intent) {
/**
* Returns true if the media button event was handled, false otherwise.
*
* <p>Must be called on the application thread of the session.
*
* @param callerInfo The calling {@link ControllerInfo}.
* @param intent The media button intent.
* @return True if the event was handled, false otherwise.
*/
/* package */ boolean onMediaButtonEvent(ControllerInfo callerInfo, Intent intent) {
KeyEvent keyEvent = DefaultActionFactory.getKeyEvent(intent);
ComponentName intentComponent = intent.getComponent();
if (!Objects.equals(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)
Expand All @@ -1090,18 +1103,66 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma
|| keyEvent.getAction() != KeyEvent.ACTION_DOWN) {
return false;
}
ControllerInfo controllerInfo = getMediaNotificationControllerInfo();
if (controllerInfo == null) {
if (intentComponent != null) {
// Fallback to legacy if this is a media button event sent to one of our components.
return getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent)
|| SDK_INT < 21;

verifyApplicationThread();
if (callback.onMediaButtonEvent(instance, callerInfo, intent)) {
// Event handled by app callback.
return true;
}
// Double tap detection.
int keyCode = keyEvent.getKeyCode();
boolean doubleTapCompleted = false;
switch (keyCode) {
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
case KeyEvent.KEYCODE_HEADSETHOOK:
if (callerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION
|| keyEvent.getRepeatCount() != 0) {
// Double tap detection is only for media button events from external sources
// (for instance Bluetooth) and excluding long press (repeatCount > 0).
mediaPlayPauseKeyHandler.flush();
} else if (mediaPlayPauseKeyHandler.hasPendingPlayPauseTask()) {
// A double tap arrived. Clear the pending playPause task.
mediaPlayPauseKeyHandler.clearPendingPlayPauseTask();
doubleTapCompleted = true;
} else {
// Handle event with a delayed callback that's run if no double tap arrives in time.
mediaPlayPauseKeyHandler.setPendingPlayPauseTask(callerInfo, keyEvent);
return true;
}
break;
default:
// If another key is pressed within double tap timeout, make play/pause as a single tap to
// handle media keys in order.
mediaPlayPauseKeyHandler.flush();
break;
}

if (!isMediaNotificationControllerConnected()) {
if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE && doubleTapCompleted) {
// Double tap completion for legacy when media notification controller is disabled.
sessionLegacyStub.onSkipToNext();
return true;
} else if (callerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION) {
sessionLegacyStub.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
return true;
}
// This is an unhandled framework event. Return false to let the framework resolve by calling
// `MediaSessionCompat.Callback.onXyz()`.
return false;
}
// Send from media notification controller.
return applyMediaButtonKeyEvent(keyEvent, doubleTapCompleted);
}

private boolean applyMediaButtonKeyEvent(KeyEvent keyEvent, boolean doubleTapCompleted) {
ControllerInfo controllerInfo = checkNotNull(instance.getMediaNotificationControllerInfo());
Runnable command;
switch (keyEvent.getKeyCode()) {
int keyCode = keyEvent.getKeyCode();
if ((keyCode == KEYCODE_MEDIA_PLAY_PAUSE || keyCode == KEYCODE_MEDIA_PLAY)
&& doubleTapCompleted) {
keyCode = KEYCODE_MEDIA_NEXT;
}
switch (keyCode) {
case KEYCODE_MEDIA_PLAY_PAUSE:
command =
getPlayerWrapper().getPlayWhenReady()
Expand Down Expand Up @@ -1653,6 +1714,56 @@ private MediaSessionImpl getSession() {
}
}

/**
* A handler for double click detection.
*
* <p>All methods must be called on the application thread.
*/
private class MediaPlayPauseKeyHandler extends Handler {

@Nullable private Runnable playPauseTask;

public MediaPlayPauseKeyHandler(Looper applicationLooper) {
super(applicationLooper);
}

public void setPendingPlayPauseTask(ControllerInfo controllerInfo, KeyEvent keyEvent) {
playPauseTask =
() -> {
if (isMediaNotificationController(controllerInfo)) {
applyMediaButtonKeyEvent(keyEvent, /* doubleTapCompleted= */ false);
} else {
sessionLegacyStub.handleMediaPlayPauseOnHandler(
checkNotNull(controllerInfo.getRemoteUserInfo()));
}
playPauseTask = null;
};
postDelayed(playPauseTask, ViewConfiguration.getDoubleTapTimeout());
}

@Nullable
public Runnable clearPendingPlayPauseTask() {
if (playPauseTask != null) {
removeCallbacks(playPauseTask);
Runnable task = playPauseTask;
playPauseTask = null;
return task;
}
return null;
}

public boolean hasPendingPlayPauseTask() {
return playPauseTask != null;
}

public void flush() {
@Nullable Runnable task = clearPendingPlayPauseTask();
if (task != null) {
postOrRun(this, task);
}
}
}

private class PlayerInfoChangedHandler extends Handler {

private static final int MSG_PLAYER_INFO_CHANGED = 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
import android.support.v4.media.session.PlaybackStateCompat;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.ViewConfiguration;
import androidx.annotation.DoNotInline;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
Expand Down Expand Up @@ -126,9 +125,7 @@
private final MediaSessionManager sessionManager;
private final ControllerLegacyCbForBroadcast controllerLegacyCbForBroadcast;
private final ConnectionTimeoutHandler connectionTimeoutHandler;
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSessionCompat sessionCompat;
private final String appPackageName;
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
@Nullable private final ComponentName broadcastReceiverComponentName;
@Nullable private VolumeProviderCompat volumeProviderCompat;
Expand All @@ -141,11 +138,8 @@
public MediaSessionLegacyStub(MediaSessionImpl session, Uri sessionUri, Handler handler) {
sessionImpl = session;
Context context = sessionImpl.getContext();
appPackageName = context.getPackageName();
sessionManager = MediaSessionManager.getSessionManager(context);
controllerLegacyCbForBroadcast = new ControllerLegacyCbForBroadcast();
mediaPlayPauseKeyHandler =
new MediaPlayPauseKeyHandler(session.getApplicationHandler().getLooper());
connectedControllersManager = new ConnectedControllersManager<>(session);
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS;
connectionTimeoutHandler =
Expand Down Expand Up @@ -318,41 +312,16 @@ public void onCustomAction(String action, @Nullable Bundle args) {
}

@Override
public boolean onMediaButtonEvent(Intent mediaButtonEvent) {
@Nullable KeyEvent keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (keyEvent == null || keyEvent.getAction() != KeyEvent.ACTION_DOWN) {
return false;
}
RemoteUserInfo remoteUserInfo = sessionCompat.getCurrentControllerInfo();
int keyCode = keyEvent.getKeyCode();
switch (keyCode) {
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
case KeyEvent.KEYCODE_HEADSETHOOK:
// Double tap detection only for media button events from external sources (for instance
// Bluetooth). Media button events from the app package are coming from the notification
// below targetApiLevel 33.
if (!appPackageName.equals(remoteUserInfo.getPackageName())
&& keyEvent.getRepeatCount() == 0) {
if (mediaPlayPauseKeyHandler.hasPendingMediaPlayPauseKey()) {
mediaPlayPauseKeyHandler.clearPendingMediaPlayPauseKey();
onSkipToNext();
} else {
mediaPlayPauseKeyHandler.addPendingMediaPlayPauseKey(remoteUserInfo);
}
} else {
// Consider long-press as a single tap. Handle immediately.
handleMediaPlayPauseOnHandler(remoteUserInfo);
}
return true;
default:
// If another key is pressed within double tap timeout, consider the pending
// pending play/pause as a single tap to handle media keys in order.
if (mediaPlayPauseKeyHandler.hasPendingMediaPlayPauseKey()) {
handleMediaPlayPauseOnHandler(remoteUserInfo);
}
break;
}
return false;
public boolean onMediaButtonEvent(Intent intent) {
return sessionImpl.onMediaButtonEvent(
new ControllerInfo(
sessionCompat.getCurrentControllerInfo(),
ControllerInfo.LEGACY_CONTROLLER_VERSION,
ControllerInfo.LEGACY_CONTROLLER_INTERFACE_VERSION,
/* trusted= */ false,
/* cb= */ null,
/* connectionHints= */ Bundle.EMPTY),
intent);
}

private void maybeUpdateFlags(PlayerWrapper playerWrapper) {
Expand All @@ -366,8 +335,7 @@ private void maybeUpdateFlags(PlayerWrapper playerWrapper) {
}
}

private void handleMediaPlayPauseOnHandler(RemoteUserInfo remoteUserInfo) {
mediaPlayPauseKeyHandler.clearPendingMediaPlayPauseKey();
/* package */ void handleMediaPlayPauseOnHandler(RemoteUserInfo remoteUserInfo) {
dispatchSessionTaskWithPlayerCommand(
COMMAND_PLAY_PAUSE,
controller ->
Expand Down Expand Up @@ -1435,34 +1403,6 @@ public void disconnectControllerAfterTimeout(
}
}

private class MediaPlayPauseKeyHandler extends Handler {

private static final int MSG_DOUBLE_TAP_TIMED_OUT = 1002;

public MediaPlayPauseKeyHandler(Looper looper) {
super(looper);
}

@Override
public void handleMessage(Message msg) {
RemoteUserInfo remoteUserInfo = (RemoteUserInfo) msg.obj;
handleMediaPlayPauseOnHandler(remoteUserInfo);
}

public void addPendingMediaPlayPauseKey(RemoteUserInfo remoteUserInfo) {
Message msg = obtainMessage(MSG_DOUBLE_TAP_TIMED_OUT, remoteUserInfo);
sendMessageDelayed(msg, ViewConfiguration.getDoubleTapTimeout());
}

public void clearPendingMediaPlayPauseKey() {
removeMessages(MSG_DOUBLE_TAP_TIMED_OUT);
}

public boolean hasPendingMediaPlayPauseKey() {
return hasMessages(MSG_DOUBLE_TAP_TIMED_OUT);
}
}

private static String getBitmapLoadErrorMessage(Throwable throwable) {
return "Failed to load bitmap: " + throwable.getMessage();
}
Expand Down
Loading

0 comments on commit f2cf43c

Please sign in to comment.