Refine logic to set and interpret ACTION_PAUSE/ACTION_PLAY

We currently set both actions depending on the playWhenReady state
and we require both actions when converting a platform session to
a Media3 session (if ACTION_PLAY_PAUSE isn't set anyway).

This causes problems in two situations:
 - A controller using the platform ACTION_PAUSE/ACTION_PLAY to
   determine which button to show in a UI. This needs to be aligned
   to the existing Util.shouldShowPlayButton we already use when
   setting the PlaybackStateCompat state.
 - A session only setting either ACTION_PAUSE or ACTION_PLAY
   depending on its state. We should check if the action triggered
   by setPlayWhenReady(...) is possible and allow COMMAND_PLAY_PAUSE
   accordingly.

PiperOrigin-RevId: 726916720
This commit is contained in:
tonihei 2025-02-14 07:12:12 -08:00 committed by Copybara-Service
parent 982403a0cc
commit 28bfb27fb5
7 changed files with 154 additions and 25 deletions

View File

@ -38,6 +38,9 @@
* Keep notification visible when playback enters an error or stopped * Keep notification visible when playback enters an error or stopped
state. The notification is only removed if the playlist is cleared or state. The notification is only removed if the playlist is cleared or
the player is released. the player is released.
* Improve handling of Android platform MediaSession actions ACTION_PLAY
and ACTION_PAUSE to only set one of them according to the available
commands and also accept if only one of them is set.
* UI: * UI:
* Downloads: * Downloads:
* OkHttp Extension: * OkHttp Extension:

View File

@ -77,7 +77,6 @@ import androidx.media3.common.Timeline;
import androidx.media3.common.Timeline.Period; import androidx.media3.common.Timeline.Period;
import androidx.media3.common.Timeline.Window; import androidx.media3.common.Timeline.Window;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaLibraryService.LibraryParams; import androidx.media3.session.MediaLibraryService.LibraryParams;
import androidx.media3.session.legacy.AudioAttributesCompat; import androidx.media3.session.legacy.AudioAttributesCompat;
import androidx.media3.session.legacy.MediaBrowserCompat; import androidx.media3.session.legacy.MediaBrowserCompat;
@ -1027,12 +1026,11 @@ import java.util.concurrent.TimeoutException;
/** Converts {@link Player}' states to state of {@link PlaybackStateCompat}. */ /** Converts {@link Player}' states to state of {@link PlaybackStateCompat}. */
@PlaybackStateCompat.State @PlaybackStateCompat.State
public static int convertToPlaybackStateCompatState(Player player, boolean playIfSuppressed) { public static int convertToPlaybackStateCompatState(Player player, boolean shouldShowPlayButton) {
if (player.getPlayerError() != null) { if (player.getPlayerError() != null) {
return PlaybackStateCompat.STATE_ERROR; return PlaybackStateCompat.STATE_ERROR;
} }
@Player.State int playbackState = player.getPlaybackState(); @Player.State int playbackState = player.getPlaybackState();
boolean shouldShowPlayButton = Util.shouldShowPlayButton(player, playIfSuppressed);
switch (playbackState) { switch (playbackState) {
case Player.STATE_IDLE: case Player.STATE_IDLE:
return PlaybackStateCompat.STATE_NONE; return PlaybackStateCompat.STATE_NONE;
@ -1370,8 +1368,9 @@ import java.util.concurrent.TimeoutException;
boolean isSessionReady) { boolean isSessionReady) {
Player.Commands.Builder playerCommandsBuilder = new Player.Commands.Builder(); Player.Commands.Builder playerCommandsBuilder = new Player.Commands.Builder();
long actions = playbackStateCompat == null ? 0 : playbackStateCompat.getActions(); long actions = playbackStateCompat == null ? 0 : playbackStateCompat.getActions();
if ((hasAction(actions, PlaybackStateCompat.ACTION_PLAY) boolean playWhenReady = convertToPlayWhenReady(playbackStateCompat);
&& hasAction(actions, PlaybackStateCompat.ACTION_PAUSE)) if ((hasAction(actions, PlaybackStateCompat.ACTION_PLAY) && !playWhenReady)
|| (hasAction(actions, PlaybackStateCompat.ACTION_PAUSE) && playWhenReady)
|| hasAction(actions, PlaybackStateCompat.ACTION_PLAY_PAUSE)) { || hasAction(actions, PlaybackStateCompat.ACTION_PLAY_PAUSE)) {
playerCommandsBuilder.add(COMMAND_PLAY_PAUSE); playerCommandsBuilder.add(COMMAND_PLAY_PAUSE);
} }

View File

@ -50,6 +50,7 @@ import androidx.media3.common.VideoSize;
import androidx.media3.common.text.CueGroup; import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.Size; import androidx.media3.common.util.Size;
import androidx.media3.common.util.Util;
import androidx.media3.session.legacy.MediaSessionCompat; import androidx.media3.session.legacy.MediaSessionCompat;
import androidx.media3.session.legacy.PlaybackStateCompat; import androidx.media3.session.legacy.PlaybackStateCompat;
import androidx.media3.session.legacy.VolumeProviderCompat; import androidx.media3.session.legacy.VolumeProviderCompat;
@ -1084,13 +1085,16 @@ import java.util.List;
.build(); .build();
} }
@Nullable PlaybackException playerError = getPlayerError(); @Nullable PlaybackException playerError = getPlayerError();
boolean shouldShowPlayButton = Util.shouldShowPlayButton(/* player= */ this, playIfSuppressed);
int state = int state =
LegacyConversions.convertToPlaybackStateCompatState(/* player= */ this, playIfSuppressed); LegacyConversions.convertToPlaybackStateCompatState(
/* player= */ this, shouldShowPlayButton);
// Always advertise ACTION_SET_RATING. // Always advertise ACTION_SET_RATING.
long actions = PlaybackStateCompat.ACTION_SET_RATING; long actions = PlaybackStateCompat.ACTION_SET_RATING;
Commands availableCommands = intersect(availablePlayerCommands, getAvailableCommands()); Commands availableCommands = intersect(availablePlayerCommands, getAvailableCommands());
for (int i = 0; i < availableCommands.size(); i++) { for (int i = 0; i < availableCommands.size(); i++) {
actions |= convertCommandToPlaybackStateActions(availableCommands.get(i)); actions |=
convertCommandToPlaybackStateActions(availableCommands.get(i), shouldShowPlayButton);
} }
if (!mediaButtonPreferences.isEmpty() if (!mediaButtonPreferences.isEmpty()
&& !legacyExtras.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) { && !legacyExtras.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) {
@ -1346,12 +1350,13 @@ import java.util.List;
} }
@SuppressWarnings("deprecation") // Uses deprecated PlaybackStateCompat actions. @SuppressWarnings("deprecation") // Uses deprecated PlaybackStateCompat actions.
private static long convertCommandToPlaybackStateActions(@Command int command) { private static long convertCommandToPlaybackStateActions(
@Command int command, boolean shouldShowPlayButton) {
switch (command) { switch (command) {
case Player.COMMAND_PLAY_PAUSE: case Player.COMMAND_PLAY_PAUSE:
return PlaybackStateCompat.ACTION_PAUSE return shouldShowPlayButton
| PlaybackStateCompat.ACTION_PLAY ? PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PLAY_PAUSE
| PlaybackStateCompat.ACTION_PLAY_PAUSE; : PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_PLAY_PAUSE;
case Player.COMMAND_PREPARE: case Player.COMMAND_PREPARE:
return PlaybackStateCompat.ACTION_PREPARE; return PlaybackStateCompat.ACTION_PREPARE;
case Player.COMMAND_SEEK_BACK: case Player.COMMAND_SEEK_BACK:

View File

@ -712,9 +712,31 @@ public final class LegacyConversionsTest {
} }
@Test @Test
public void convertToPlayerCommands_withJustPlayAction_playPauseCommandNotAvailable() { public void convertToPlayerCommands_withJustPlayActionWhileNotReady_playPauseCommandAvailable() {
PlaybackStateCompat playbackStateCompat = PlaybackStateCompat playbackStateCompat =
new PlaybackStateCompat.Builder().setActions(PlaybackStateCompat.ACTION_PLAY).build(); new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_ERROR, /* position= */ 0, /* playbackSpeed= */ 1f)
.setActions(PlaybackStateCompat.ACTION_PLAY)
.build();
Player.Commands playerCommands =
LegacyConversions.convertToPlayerCommands(
playbackStateCompat,
/* volumeControlType= */ VolumeProviderCompat.VOLUME_CONTROL_FIXED,
/* sessionFlags= */ 0,
/* isSessionReady= */ true);
assertThat(getCommandsAsList(playerCommands)).contains(Player.COMMAND_PLAY_PAUSE);
}
@Test
public void convertToPlayerCommands_withJustPlayActionWhileReady_playPauseCommandNotAvailable() {
PlaybackStateCompat playbackStateCompat =
new PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_BUFFERING, /* position= */ 0, /* playbackSpeed= */ 1f)
.setActions(PlaybackStateCompat.ACTION_PLAY)
.build();
Player.Commands playerCommands = Player.Commands playerCommands =
LegacyConversions.convertToPlayerCommands( LegacyConversions.convertToPlayerCommands(
@ -727,9 +749,13 @@ public final class LegacyConversionsTest {
} }
@Test @Test
public void convertToPlayerCommands_withJustPauseAction_playPauseCommandNotAvailable() { public void
convertToPlayerCommands_withJustPauseActionWhileNotReady_playPauseCommandNotAvailable() {
PlaybackStateCompat playbackStateCompat = PlaybackStateCompat playbackStateCompat =
new PlaybackStateCompat.Builder().setActions(PlaybackStateCompat.ACTION_PAUSE).build(); new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_ERROR, /* position= */ 0, /* playbackSpeed= */ 1f)
.setActions(PlaybackStateCompat.ACTION_PAUSE)
.build();
Player.Commands playerCommands = Player.Commands playerCommands =
LegacyConversions.convertToPlayerCommands( LegacyConversions.convertToPlayerCommands(
@ -741,6 +767,25 @@ public final class LegacyConversionsTest {
assertThat(getCommandsAsList(playerCommands)).doesNotContain(Player.COMMAND_PLAY_PAUSE); assertThat(getCommandsAsList(playerCommands)).doesNotContain(Player.COMMAND_PLAY_PAUSE);
} }
@Test
public void convertToPlayerCommands_withJustPauseActionWhileReady_playPauseCommandAvailable() {
PlaybackStateCompat playbackStateCompat =
new PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_BUFFERING, /* position= */ 0, /* playbackSpeed= */ 1f)
.setActions(PlaybackStateCompat.ACTION_PAUSE)
.build();
Player.Commands playerCommands =
LegacyConversions.convertToPlayerCommands(
playbackStateCompat,
/* volumeControlType= */ VolumeProviderCompat.VOLUME_CONTROL_FIXED,
/* sessionFlags= */ 0,
/* isSessionReady= */ true);
assertThat(getCommandsAsList(playerCommands)).contains(Player.COMMAND_PLAY_PAUSE);
}
@Test @Test
public void convertToPlayerCommands_withPlayAndPauseAction_playPauseCommandAvailable() { public void convertToPlayerCommands_withPlayAndPauseAction_playPauseCommandAvailable() {
PlaybackStateCompat playbackStateCompat = PlaybackStateCompat playbackStateCompat =

View File

@ -78,10 +78,14 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
@Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG); @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG);
@Test @Test
public void playerWithCommandPlayPause_actionsPlayAndPauseAndPlayPauseAdvertised() public void playerWithCommandPlayPauseAndShouldShowPlayButton_actionsPlayAndPlayPauseAdvertised()
throws Exception { throws Exception {
Player player = Player player =
createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_PLAY_PAUSE); createPlayerWithAvailableCommand(
createPlayer(
/* onPostCreationTask= */ createdPlayer ->
createdPlayer.setMediaItem(MediaItem.fromUri("asset://media/wav/sample.wav"))),
Player.COMMAND_PLAY_PAUSE);
MediaSession mediaSession = createMediaSession(player); MediaSession mediaSession = createMediaSession(player);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
@ -89,7 +93,7 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
assertThat(actions & PlaybackStateCompat.ACTION_PLAY_PAUSE).isNotEqualTo(0); assertThat(actions & PlaybackStateCompat.ACTION_PLAY_PAUSE).isNotEqualTo(0);
assertThat(actions & PlaybackStateCompat.ACTION_PLAY).isNotEqualTo(0); assertThat(actions & PlaybackStateCompat.ACTION_PLAY).isNotEqualTo(0);
assertThat(actions & PlaybackStateCompat.ACTION_PAUSE).isNotEqualTo(0); assertThat(actions & PlaybackStateCompat.ACTION_PAUSE).isEqualTo(0);
CountDownLatch latch = new CountDownLatch(2); CountDownLatch latch = new CountDownLatch(2);
List<Boolean> receivedPlayWhenReady = new ArrayList<>(); List<Boolean> receivedPlayWhenReady = new ArrayList<>();
@ -114,6 +118,51 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
releasePlayer(player); releasePlayer(player);
} }
@Test
public void
playerWithCommandPlayPauseAndShouldShowPauseButton_actionsPauseAndPlayPauseAdvertised()
throws Exception {
Player player =
createPlayerWithAvailableCommand(
createPlayer(
/* onPostCreationTask= */ createdPlayer -> {
createdPlayer.setMediaItem(MediaItem.fromUri("asset://media/wav/sample.wav"));
createdPlayer.prepare();
createdPlayer.play();
}),
Player.COMMAND_PLAY_PAUSE);
MediaSession mediaSession = createMediaSession(player);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
long actions = controllerCompat.getPlaybackState().getActions();
assertThat(actions & PlaybackStateCompat.ACTION_PLAY_PAUSE).isNotEqualTo(0);
assertThat(actions & PlaybackStateCompat.ACTION_PLAY).isEqualTo(0);
assertThat(actions & PlaybackStateCompat.ACTION_PAUSE).isNotEqualTo(0);
CountDownLatch latch = new CountDownLatch(2);
List<Boolean> receivedPlayWhenReady = new ArrayList<>();
Player.Listener listener =
new Player.Listener() {
@Override
public void onPlayWhenReadyChanged(
boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
receivedPlayWhenReady.add(playWhenReady);
latch.countDown();
}
};
player.addListener(listener);
controllerCompat.getTransportControls().pause();
controllerCompat.getTransportControls().play();
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(receivedPlayWhenReady).containsExactly(false, true).inOrder();
mediaSession.release();
releasePlayer(player);
}
@Test @Test
public void playerWithoutCommandPlayPause_actionsPlayAndPauseAndPlayPauseNotAdvertised() public void playerWithoutCommandPlayPause_actionsPlayAndPauseAndPlayPauseNotAdvertised()
throws Exception { throws Exception {

View File

@ -136,6 +136,14 @@ public class MediaSessionKeyEventTest {
@Test @Test
public void playKeyEvent() throws Exception { public void playKeyEvent() throws Exception {
handler.postAndSync(
() -> {
// Update state to allow play event to be triggered.
player.notifyPlayWhenReadyChanged(
/* playWhenReady= */ false,
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
Player.PLAYBACK_SUPPRESSION_REASON_NONE);
});
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY, false);
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
@ -143,6 +151,15 @@ public class MediaSessionKeyEventTest {
@Test @Test
public void pauseKeyEvent() throws Exception { public void pauseKeyEvent() throws Exception {
handler.postAndSync(
() -> {
// Update state to allow pause event to be triggered.
player.notifyPlayWhenReadyChanged(
/* playWhenReady= */ true,
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
Player.PLAYBACK_SUPPRESSION_REASON_NONE);
player.notifyPlaybackStateChanged(Player.STATE_READY);
});
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PAUSE, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PAUSE, false);
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);

View File

@ -672,16 +672,29 @@ public class MediaSessionTest {
}) })
.build())); .build()));
MediaSessionImpl impl = session.get().getImpl(); MediaSessionImpl impl = session.get().getImpl();
ControllerInfo controllerInfo = createMediaButtonCaller();
threadTestRule threadTestRule
.getHandler() .getHandler()
.postAndSync( .postAndSync(
() -> { () -> {
ControllerInfo controllerInfo = createMediaButtonCaller();
assertThat( assertThat(
impl.onMediaButtonEvent( impl.onMediaButtonEvent(
controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PLAY))) controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PLAY)))
.isTrue(); .isTrue();
});
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
threadTestRule
.getHandler()
.postAndSync(
() -> {
// Update state to allow pause event to be triggered.
player.notifyPlaybackStateChanged(Player.STATE_READY);
player.notifyPlayWhenReadyChanged(
/* playWhenReady= */ true,
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
Player.PLAYBACK_SUPPRESSION_REASON_NONE);
assertThat( assertThat(
impl.onMediaButtonEvent( impl.onMediaButtonEvent(
controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))) controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PAUSE)))
@ -717,12 +730,10 @@ public class MediaSessionTest {
player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS);
assertThat(callerCollectorPlayer.callingControllers).hasSize(7); assertThat(callerCollectorPlayer.callingControllers).hasSize(7);
for (ControllerInfo controllerInfo : callerCollectorPlayer.callingControllers) { for (ControllerInfo info : callerCollectorPlayer.callingControllers) {
assertThat(session.get().isMediaNotificationController(controllerInfo)).isFalse(); assertThat(session.get().isMediaNotificationController(info)).isFalse();
assertThat(controllerInfo.getControllerVersion()) assertThat(info.getControllerVersion()).isEqualTo(ControllerInfo.LEGACY_CONTROLLER_VERSION);
.isEqualTo(ControllerInfo.LEGACY_CONTROLLER_VERSION); assertThat(info.getPackageName()).isEqualTo(getControllerCallerPackageName(info));
assertThat(controllerInfo.getPackageName())
.isEqualTo(getControllerCallerPackageName(controllerInfo));
} }
} }