diff --git a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java index 39b58d826e..8c1ab8256c 100644 --- a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java +++ b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java @@ -1173,6 +1173,63 @@ public final class CommandButton { } } + /** + * Converts a list of buttons defined as {@linkplain MediaSession#getMediaButtonPreferences media + * button preferences} to the list of buttons for a {@linkplain MediaSession#getCustomLayout + * custom layout} according to the implicit button placement rules applied for custom layouts. + * + * @param mediaButtonPreferences The list of buttons as media button preferences. + * @param reservationExtras A writable {@link Bundle} that receives the extras for slot + * reservations via {@link MediaConstants#EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT} or {@link + * MediaConstants#EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV} to match the returned custom + * layout. + * @return A list of buttons compatible with the placement rules of custom layouts. + */ + /* package */ static ImmutableList getCustomLayoutFromMediaButtonPreferences( + List mediaButtonPreferences, Bundle reservationExtras) { + if (mediaButtonPreferences.isEmpty()) { + reservationExtras.putBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, true); + reservationExtras.putBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, true); + return ImmutableList.of(); + } + int backButtonIndex = C.INDEX_UNSET; + int forwardButtonIndex = C.INDEX_UNSET; + for (int i = 0; i < mediaButtonPreferences.size(); i++) { + CommandButton button = mediaButtonPreferences.get(i); + for (int s = 0; s < button.slots.length(); s++) { + @Slot int slot = button.slots.get(s); + if (slot == SLOT_OVERFLOW) { + // Will go into overflow. + break; + } else if (backButtonIndex == C.INDEX_UNSET && slot == SLOT_BACK) { + backButtonIndex = i; + } else if (forwardButtonIndex == C.INDEX_UNSET && slot == SLOT_FORWARD) { + forwardButtonIndex = i; + } + } + } + boolean hasBackButton = backButtonIndex != C.INDEX_UNSET; + boolean hasForwardButton = forwardButtonIndex != C.INDEX_UNSET; + reservationExtras.putBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, !hasBackButton); + reservationExtras.putBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, !hasForwardButton); + ImmutableList.Builder customLayout = ImmutableList.builder(); + if (hasBackButton) { + customLayout.add(mediaButtonPreferences.get(backButtonIndex)); + } + if (hasForwardButton) { + customLayout.add(mediaButtonPreferences.get(forwardButtonIndex)); + } + for (int i = 0; i < mediaButtonPreferences.size(); i++) { + CommandButton button = mediaButtonPreferences.get(i); + if (i != backButtonIndex && i != forwardButtonIndex && button.slots.contains(SLOT_OVERFLOW)) { + customLayout.add(button); + } + } + return customLayout.build(); + } + /** * Converts a list of buttons defined according to the implicit button placement rules for * {@linkplain MediaSession#getCustomLayout custom layouts} to {@linkplain diff --git a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java index cd0bf10fd8..756c891cbf 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java @@ -131,11 +131,13 @@ import java.util.List; mediaButtonPreferences, CommandButton::toBundle)); } else { // Controller doesn't support media button preferences, send the list as a custom layout. - // TODO: b/332877990 - More accurately reflect media button preferences as custom layout. + // Ignore reservation extras as they were not directly supported in older controllers. + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, /* reservationExtras= */ new Bundle()); bundle.putParcelableArrayList( FIELD_CUSTOM_LAYOUT, - BundleCollectionUtil.toBundleArrayList( - mediaButtonPreferences, CommandButton::toBundle)); + BundleCollectionUtil.toBundleArrayList(customLayout, CommandButton::toBundle)); } } if (!commandButtonsForMediaItems.isEmpty()) { diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index c5a72d9669..d4efae6ee2 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -46,6 +46,7 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.session.MediaStyleNotificationHelper.MediaStyle; import com.google.common.collect.ImmutableList; +import com.google.common.primitives.ImmutableIntArray; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -303,7 +304,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi Callback onNotificationChangedCallback) { ensureNotificationChannel(); - // TODO: b/332877990 - More accurately reflect media button preferences in the notification. ImmutableList.Builder mediaButtonPreferencesWithEnabledCommandButtonsOnly = new ImmutableList.Builder<>(); for (int i = 0; i < mediaButtonPreferences.size(); i++) { @@ -445,9 +445,24 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi Player.Commands playerCommands, ImmutableList mediaButtonPreferences, boolean showPauseButton) { - // Skip to previous action. + Bundle reservations = new Bundle(); + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, reservations); + boolean hasCustomBackButton = + !reservations.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV); + boolean hasCustomForwardButton = + !reservations.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT); + int nextCustomLayoutIndex = 0; + ImmutableList.Builder commandButtons = new ImmutableList.Builder<>(); - if (playerCommands.containsAny(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) { + if (hasCustomBackButton) { + commandButtons.add( + customLayout + .get(nextCustomLayoutIndex++) + .copyWithSlots(ImmutableIntArray.of(CommandButton.SLOT_BACK))); + } else if (playerCommands.containsAny( + COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) { commandButtons.add( new CommandButton.Builder(CommandButton.ICON_PREVIOUS) .setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) @@ -470,20 +485,21 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi .build()); } } - // Skip to next action. - if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) { + if (hasCustomForwardButton) { + commandButtons.add( + customLayout + .get(nextCustomLayoutIndex++) + .copyWithSlots(ImmutableIntArray.of(CommandButton.SLOT_FORWARD))); + } else if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) { commandButtons.add( new CommandButton.Builder(CommandButton.ICON_NEXT) .setPlayerCommand(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description)) .build()); } - for (int i = 0; i < mediaButtonPreferences.size(); i++) { - CommandButton button = mediaButtonPreferences.get(i); - if (button.sessionCommand != null - && button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) { - commandButtons.add(button); - } + for (int i = nextCustomLayoutIndex; i < customLayout.size(); i++) { + commandButtons.add( + customLayout.get(i).copyWithSlots(ImmutableIntArray.of(CommandButton.SLOT_OVERFLOW))); } return commandButtons.build(); } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java index b3127736c7..a8ebabe83f 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -526,7 +526,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public ListenableFuture setMediaButtonPreferences( ControllerInfo controller, ImmutableList mediaButtonPreferences) { if (isMediaNotificationController(controller)) { - playerWrapper.setMediaButtonPreferences(mediaButtonPreferences); + setLegacyMediaButtonPreferences(mediaButtonPreferences); sessionLegacyStub.updateLegacySessionPlaybackState(playerWrapper); } return dispatchRemoteControllerTask( @@ -540,7 +540,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public void setMediaButtonPreferences(ImmutableList mediaButtonPreferences) { this.mediaButtonPreferences = mediaButtonPreferences; - playerWrapper.setMediaButtonPreferences(mediaButtonPreferences); + setLegacyMediaButtonPreferences(mediaButtonPreferences); dispatchRemoteControllerTaskWithoutReturn( (controller, seq) -> controller.setMediaButtonPreferences(seq, mediaButtonPreferences)); } @@ -722,14 +722,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; "Callback.onConnect must return non-null future"); if (isMediaNotificationController(controller) && connectionResult.isAccepted) { isMediaNotificationControllerConnected = true; - playerWrapper.setCustomLayout( - connectionResult.customLayout != null - ? connectionResult.customLayout - : instance.getCustomLayout()); - playerWrapper.setMediaButtonPreferences( + ImmutableList mediaButtonPreferences = connectionResult.mediaButtonPreferences != null ? connectionResult.mediaButtonPreferences - : instance.getMediaButtonPreferences()); + : instance.getMediaButtonPreferences(); + if (mediaButtonPreferences.isEmpty()) { + playerWrapper.setCustomLayout( + connectionResult.customLayout != null + ? connectionResult.customLayout + : instance.getCustomLayout()); + } else { + setLegacyMediaButtonPreferences(mediaButtonPreferences); + } setAvailableFrameworkControllerCommands( connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands); } @@ -1037,6 +1041,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } } + private void setLegacyMediaButtonPreferences( + ImmutableList mediaButtonPreferences) { + boolean extrasChanged = playerWrapper.setMediaButtonPreferences(mediaButtonPreferences); + if (extrasChanged) { + sessionLegacyStub.getSessionCompat().setExtras(playerWrapper.getLegacyExtras()); + } + } + private void setAvailableFrameworkControllerCommands( SessionCommands sessionCommands, Player.Commands playerCommands) { boolean commandGetTimelineChanged = diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 662b0cb36b..6daba01ee5 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -1085,10 +1085,13 @@ import org.checkerframework.checker.initialization.qual.Initialized; onRepeatModeChanged(seq, newPlayerWrapper.getRepeatMode()); } - // Forcefully update playback info to update VolumeProviderCompat attached to the - // old player. + // Forcefully update device info to update VolumeProviderCompat attached to the old player. onDeviceInfoChanged(seq, newPlayerWrapper.getDeviceInfo()); + if (hasChangedSlotReservationExtras(oldPlayerWrapper, newPlayerWrapper)) { + sessionCompat.setExtras(newPlayerWrapper.getLegacyExtras()); + } + // Rest of changes are all notified via PlaybackStateCompat. maybeUpdateFlags(newPlayerWrapper); @Nullable MediaItem newMediaItem = newPlayerWrapper.getCurrentMediaItemWithCommandCheck(); @@ -1122,8 +1125,9 @@ import org.checkerframework.checker.initialization.qual.Initialized; @Override public void onSessionExtrasChanged(int seq, Bundle sessionExtras) { - sessionCompat.setExtras(sessionExtras); - sessionImpl.getPlayerWrapper().setLegacyExtras(sessionExtras); + PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper(); + playerWrapper.setLegacyExtras(sessionExtras); + sessionCompat.setExtras(playerWrapper.getLegacyExtras()); sessionCompat.setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat()); } @@ -1421,6 +1425,28 @@ import org.checkerframework.checker.initialization.qual.Initialized; } } + private static boolean hasChangedSlotReservationExtras( + @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { + if (oldPlayerWrapper == null) { + return true; + } + Bundle oldExtras = oldPlayerWrapper.getLegacyExtras(); + boolean oldPrevReservation = + oldExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultVale= */ false); + boolean oldNextReservation = + oldExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultVale= */ false); + Bundle newExtras = newPlayerWrapper.getLegacyExtras(); + boolean newPrevReservation = + newExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultVale= */ false); + boolean newNextReservation = + newExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultVale= */ false); + return (oldPrevReservation != newPrevReservation) || (oldNextReservation != newNextReservation); + } + private static class ConnectionTimeoutHandler extends Handler { private static final int MSG_CONNECTION_TIMED_OUT = 1001; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java index 7d3ab92733..1a374eb6cc 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -2085,10 +2085,13 @@ import java.util.concurrent.ExecutionException; BundleCollectionUtil.toBundleList(mediaButtonPreferences, CommandButton::toBundle)); } else { // Controller doesn't support media button preferences, send the list as a custom layout. - // TODO: b/332877990 - More accurately reflect media button preferences as custom layout. + // Ignore reservation extras as they were not directly supported in older controllers. + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, /* reservationExtras= */ new Bundle()); iController.onSetCustomLayout( sequenceNumber, - BundleCollectionUtil.toBundleList(mediaButtonPreferences, CommandButton::toBundle)); + BundleCollectionUtil.toBundleList(customLayout, CommandButton::toBundle)); } } diff --git a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java index d0592c71d6..e41b6a2332 100644 --- a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java +++ b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java @@ -106,6 +106,11 @@ import java.util.List; this.availableSessionCommands = availableSessionCommands; this.availablePlayerCommands = availablePlayerCommands; this.legacyExtras = legacyExtras; + if (!mediaButtonPreferences.isEmpty()) { + this.customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, this.legacyExtras); + } } public void setAvailableCommands( @@ -126,8 +131,31 @@ import java.util.List; this.customLayout = customLayout; } - public void setMediaButtonPreferences(ImmutableList mediaButtonPreferences) { + /** + * Sets new media button preferences. + * + * @param mediaButtonPreferences The list of {@link CommandButton} defining the media button + * preferences. + * @return Whether the {@linkplain #getLegacyExtras platform session extras} were updated as a + * result of this change. + */ + public boolean setMediaButtonPreferences(ImmutableList mediaButtonPreferences) { this.mediaButtonPreferences = mediaButtonPreferences; + boolean hadPrevReservation = + legacyExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultVale= */ false); + boolean hadNextReservation = + legacyExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultVale= */ false); + this.customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, legacyExtras); + return (legacyExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultVale= */ false) + != hadPrevReservation) + || (legacyExtras.getBoolean( + MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultVale= */ false) + != hadNextReservation); } /* package */ ImmutableList getCustomLayout() { @@ -141,6 +169,11 @@ import java.util.List; public void setLegacyExtras(Bundle extras) { checkArgument(!extras.containsKey(EXTRAS_KEY_PLAYBACK_SPEED_COMPAT)); checkArgument(!extras.containsKey(EXTRAS_KEY_MEDIA_ID_COMPAT)); + if (!mediaButtonPreferences.isEmpty()) { + // Re-calculate custom layout in case we have to set any additional extras. + this.customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences(mediaButtonPreferences, extras); + } this.legacyExtras = extras; } @@ -1040,6 +1073,14 @@ import java.util.List; for (int i = 0; i < availableCommands.size(); i++) { actions |= convertCommandToPlaybackStateActions(availableCommands.get(i)); } + if (!mediaButtonPreferences.isEmpty() + && !legacyExtras.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) { + actions &= ~PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; + } + if (!mediaButtonPreferences.isEmpty() + && !legacyExtras.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT)) { + actions &= ~PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + } long queueItemId = isCommandAvailable(COMMAND_GET_TIMELINE) ? LegacyConversions.convertToQueueItemId(getCurrentMediaItemIndex()) @@ -1064,18 +1105,14 @@ import java.util.List; .setActiveQueueItemId(queueItemId) .setBufferedPosition(compatBufferedPosition) .setExtras(extras); - - // TODO: b/332877990 - More accurately reflect media button preferences as custom actions. - List buttonsForCustomActions = - mediaButtonPreferences.isEmpty() ? customLayout : mediaButtonPreferences; - for (int i = 0; i < buttonsForCustomActions.size(); i++) { - CommandButton commandButton = buttonsForCustomActions.get(i); + for (int i = 0; i < customLayout.size(); i++) { + CommandButton commandButton = customLayout.get(i); SessionCommand sessionCommand = commandButton.sessionCommand; if (sessionCommand != null && commandButton.isEnabled && sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM && CommandButton.isButtonCommandAvailable( - commandButton, availableSessionCommands, availablePlayerCommands)) { + commandButton, availableSessionCommands, availableCommands)) { Bundle actionExtras = sessionCommand.customExtras; if (commandButton.icon != CommandButton.ICON_UNDEFINED) { actionExtras = new Bundle(sessionCommand.customExtras); diff --git a/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java b/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java index d2f4394833..50c750930d 100644 --- a/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java @@ -544,6 +544,203 @@ public class CommandButtonTest { assertThat(restoredButtonAssumingOldSessionInterface.isEnabled).isTrue(); } + @Test + public void getCustomLayoutFromMediaButtonPreferences_noBackForwardSlots_returnsCorrectButtons() { + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()); + Bundle reservationBundle = new Bundle(); + + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, reservationBundle); + + assertThat(customLayout) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()) + .inOrder(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) + .isTrue(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT)) + .isTrue(); + } + + @Test + public void getCustomLayoutFromMediaButtonPreferences_withBackSlot_returnsCorrectButtons() { + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()); + Bundle reservationBundle = new Bundle(); + + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, reservationBundle); + + assertThat(customLayout) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()) + .inOrder(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) + .isFalse(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT)) + .isTrue(); + } + + @Test + public void getCustomLayoutFromMediaButtonPreferences_withForwardSlot_returnsCorrectButtons() { + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()); + Bundle reservationBundle = new Bundle(); + + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, reservationBundle); + + assertThat(customLayout) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()) + .inOrder(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) + .isTrue(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT)) + .isFalse(); + } + + @Test + public void + getCustomLayoutFromMediaButtonPreferences_withForwardAndBackSlot_returnsCorrectButtons() { + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_CENTRAL, CommandButton.SLOT_BACK) + .build()); + Bundle reservationBundle = new Bundle(); + + ImmutableList customLayout = + CommandButton.getCustomLayoutFromMediaButtonPreferences( + mediaButtonPreferences, reservationBundle); + + assertThat(customLayout) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_CENTRAL, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK_SECONDARY, CommandButton.SLOT_OVERFLOW) + .build()) + .inOrder(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV)) + .isFalse(); + assertThat( + reservationBundle.getBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT)) + .isFalse(); + } + @Test public void getMediaButtonPreferencesFromCustomLayout_withPrevAndNextCommands_returnsCorrectSlots() { diff --git a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java index 7828f02c0d..a39d097cb6 100644 --- a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java @@ -196,6 +196,60 @@ public class DefaultMediaNotificationProviderTest { assertThat(mediaButtons).containsExactly(customCommandButton); } + @Test + public void getMediaButtons_customButtonsForPrevNextSlots_overridesDefaultPrevNextButtons() { + DefaultMediaNotificationProvider defaultMediaNotificationProvider = + new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext()) + .build(); + Commands commands = new Commands.Builder().addAllCommands().build(); + SessionCommand customSessionCommand = new SessionCommand("", Bundle.EMPTY); + CommandButton customCommandButton = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("displayName") + .setIconResId(R.drawable.media3_icon_circular_play) + .setSessionCommand(customSessionCommand) + .build(); + CommandButton customBackButton = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setSlots(CommandButton.SLOT_BACK) + .setDisplayName("displayName") + .setIconResId(R.drawable.media3_icon_circular_play) + .setSessionCommand(customSessionCommand) + .build(); + CommandButton customForwardButton = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setSlots(CommandButton.SLOT_FORWARD) + .setDisplayName("displayName") + .setIconResId(R.drawable.media3_icon_circular_play) + .setSessionCommand(customSessionCommand) + .build(); + CommandButton customCentralButton = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setSlots(CommandButton.SLOT_CENTRAL) + .setDisplayName("displayName") + .setIconResId(R.drawable.media3_icon_circular_play) + .setSessionCommand(customSessionCommand) + .build(); + Player player = new TestExoPlayerBuilder(context).build(); + MediaSession mediaSession = new MediaSession.Builder(context, player).build(); + + ImmutableList mediaButtons = + defaultMediaNotificationProvider.getMediaButtons( + mediaSession, + commands, + ImmutableList.of( + customCommandButton, customForwardButton, customBackButton, customCentralButton), + /* showPauseButton= */ true); + mediaSession.release(); + player.release(); + + assertThat(mediaButtons).hasSize(4); + assertThat(mediaButtons.get(0)).isEqualTo(customBackButton); + assertThat(mediaButtons.get(1).playerCommand).isEqualTo(Player.COMMAND_PLAY_PAUSE); + assertThat(mediaButtons.get(2)).isEqualTo(customForwardButton); + assertThat(mediaButtons.get(3)).isEqualTo(customCommandButton); + } + @Test public void addNotificationActions_customCompactViewDeclarations_correctCompactViewIndices() { DefaultMediaNotificationProvider defaultMediaNotificationProvider = diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java index f3bfc56b8c..eef19dcaf4 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java @@ -1640,15 +1640,18 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest new CommandButton.Builder(CommandButton.ICON_PLAY) .setDisplayName("button1") .setSessionCommand(command1) + .setSlots(CommandButton.SLOT_OVERFLOW) .build(), new CommandButton.Builder(CommandButton.ICON_PAUSE) .setDisplayName("button2") .setSessionCommand(command2) + .setSlots(CommandButton.SLOT_OVERFLOW) .build(), new CommandButton.Builder(CommandButton.ICON_PAUSE) .setDisplayName("button3") .setEnabled(false) .setSessionCommand(command3) + .setSlots(CommandButton.SLOT_OVERFLOW) .build()); MediaSession.Callback callback = new MediaSession.Callback() { @@ -1702,10 +1705,12 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest new CommandButton.Builder(CommandButton.ICON_PLAY) .setDisplayName("button1") .setSessionCommand(command1) + .setSlots(CommandButton.SLOT_OVERFLOW) .build(), new CommandButton.Builder(CommandButton.ICON_PAUSE) .setDisplayName("button2") .setSessionCommand(command2) + .setSlots(CommandButton.SLOT_OVERFLOW) .build()); MediaSession.Callback callback = new MediaSession.Callback() { @@ -1773,10 +1778,12 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest new CommandButton.Builder(CommandButton.ICON_PLAY) .setDisplayName("button1") .setSessionCommand(command1) + .setSlots(CommandButton.SLOT_OVERFLOW) .build(), new CommandButton.Builder(CommandButton.ICON_PAUSE) .setDisplayName("button2") .setSessionCommand(command2) + .setSlots(CommandButton.SLOT_OVERFLOW) .build()); MediaSession.Callback callback = new MediaSession.Callback() { @@ -1831,6 +1838,148 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest releasePlayer(player); } + @Test + public void + playerWithMediaButtonPreferences_withBackForwardSlots_overridesPrevNextActionsWhenNeeded() + throws Exception { + Player player = + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample.wav"))); + createdPlayer.seekToDefaultPosition(/* mediaItemIndex= */ 1); + }); + SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY); + SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY); + SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY); + SessionCommand commandIgnored = new SessionCommand("shouldBeIgnored", Bundle.EMPTY); + ImmutableList mediaButtonPreferencesWithBackForward = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("button1") + .setSessionCommand(command1) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("shouldBeIgnored") + .setSessionCommand(commandIgnored) + .setSlots(CommandButton.SLOT_BACK_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button2") + .setSessionCommand(command2) + .setSlots(CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button3") + .setSessionCommand(command3) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("shouldBeIgnored") + .setSessionCommand(commandIgnored) + .setSlots(CommandButton.SLOT_BACK) + .build()); + ImmutableList mediaButtonPreferencesWithoutBackForward = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("button1") + .setSessionCommand(command1) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("shouldBeIgnored") + .setSessionCommand(commandIgnored) + .setSlots(CommandButton.SLOT_BACK_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button2") + .setSessionCommand(command2) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build()); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(command1) + .add(command2) + .add(command3) + .add(commandIgnored) + .build()) + .build(); + } + }; + MediaSession mediaSession = + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) + .setCallback(callback) + .setMediaButtonPreferences(mediaButtonPreferencesWithBackForward) + .build(); + connectMediaNotificationController(mediaSession); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + CountDownLatch controllerUpdatedLatch = new CountDownLatch(1); + MediaControllerCompat.Callback controllerCallback = + new MediaControllerCompat.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + controllerUpdatedLatch.countDown(); + } + }; + + List customActions1 = + controllerCompat.getPlaybackState().getCustomActions(); + Bundle extras1 = controllerCompat.getExtras(); + long actions1 = controllerCompat.getPlaybackState().getActions(); + controllerCompat.registerCallback(controllerCallback, threadTestRule.getHandler()); + mediaSession.setMediaButtonPreferences(mediaButtonPreferencesWithoutBackForward); + controllerUpdatedLatch.await(TIMEOUT_MS, MILLISECONDS); + List customActions2 = + controllerCompat.getPlaybackState().getCustomActions(); + Bundle extras2 = controllerCompat.getExtras(); + long actions2 = controllerCompat.getPlaybackState().getActions(); + mediaSession.release(); + releasePlayer(player); + + assertThat(customActions1).hasSize(3); + assertThat(customActions1.get(0).getAction()).isEqualTo("command3"); + assertThat(customActions1.get(1).getAction()).isEqualTo("command2"); + assertThat(customActions1.get(2).getAction()).isEqualTo("command1"); + assertThat( + extras1.getBoolean( + androidx.media.utils.MediaConstants + .SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV)) + .isFalse(); + assertThat( + extras1.getBoolean( + androidx.media.utils.MediaConstants + .SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT)) + .isFalse(); + assertThat(actions1 & PlaybackStateCompat.ACTION_SKIP_TO_NEXT).isEqualTo(0); + assertThat(actions1 & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS).isEqualTo(0); + assertThat(customActions2).hasSize(2); + assertThat(customActions2.get(0).getAction()).isEqualTo("command1"); + assertThat(customActions2.get(1).getAction()).isEqualTo("command2"); + assertThat( + extras2.getBoolean( + androidx.media.utils.MediaConstants + .SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV)) + .isTrue(); + assertThat( + extras2.getBoolean( + androidx.media.utils.MediaConstants + .SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT)) + .isTrue(); + assertThat(actions2 & PlaybackStateCompat.ACTION_SKIP_TO_NEXT).isNotEqualTo(0); + assertThat(actions2 & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS).isNotEqualTo(0); + } + /** * Connect a controller that mimics the media notification controller that is connected by {@link * MediaNotificationManager} when the session is running in the service.