From 684273e4e10c6ba273663d34230b3a2dd58ec7b4 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 12 Dec 2024 07:05:03 -0800 Subject: [PATCH] Add utilities to resolve button preferences to display constraints PiperOrigin-RevId: 705491402 --- .../media3/session/CommandButton.java | 391 +++++++++++- .../media3/session/CommandButtonTest.java | 596 ++++++++++++++++++ 2 files changed, 985 insertions(+), 2 deletions(-) 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 ea27fda10c..14a946eed7 100644 --- a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java +++ b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java @@ -18,17 +18,22 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; +import static androidx.media3.session.SessionCommand.COMMAND_CODE_CUSTOM; import static java.lang.annotation.ElementType.TYPE_USE; import android.content.ContentResolver; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import android.util.SparseIntArray; import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Player; +import androidx.media3.common.util.NullableType; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.base.Objects; @@ -701,6 +706,388 @@ public final class CommandButton { } } + /** + * Constraints for displaying a list of {@link CommandButton} instances with utilities to resolve + * these constraints for a given list of buttons. + */ + @UnstableApi + public static final class DisplayConstraints { + + /** A builder for {@link DisplayConstraints}. */ + public static final class Builder { + + private final SparseIntArray maxButtonsPerSlot; + private final SparseArray allowedPlayerCommandsPerSlot; + private final SparseArray<@NullableType SessionCommands> allowedSessionCommandsPerSlot; + private final SparseBooleanArray areCustomCommandsAllowedPerSlot; + private boolean buildCalled; + + /** Creates the builder. */ + public Builder() { + maxButtonsPerSlot = new SparseIntArray(); + maxButtonsPerSlot.put(SLOT_CENTRAL, 1); + maxButtonsPerSlot.put(SLOT_BACK, 1); + maxButtonsPerSlot.put(SLOT_FORWARD, 1); + maxButtonsPerSlot.put(SLOT_OVERFLOW, Integer.MAX_VALUE); + allowedPlayerCommandsPerSlot = new SparseArray<>(); + allowedSessionCommandsPerSlot = new SparseArray<>(); + areCustomCommandsAllowedPerSlot = new SparseBooleanArray(); + } + + /** + * Sets the maximum number of buttons that can be displayed in a slot. + * + *

The default values are: + * + *

+ * + * @param slot The {@link Slot}. + * @param maxButtons The maximum number of buttons that can be displayed in this slot. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMaxButtonsForSlot(@Slot int slot, int maxButtons) { + checkArgument(maxButtons >= 0); + maxButtonsPerSlot.put(slot, maxButtons); + return this; + } + + /** + * Sets the allowed {@link Player.Commands} for buttons in the given slot. + * + *

The default value ({@code null}) does not restrict the allowed {@link Player.Commands}. + * + * @param slot The {@link Slot}. + * @param allowedPlayerCommands The allowed {@link Player.Commands} for buttons in this slot, + * or null to allow all {@link Player.Commands} . + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAllowedPlayerCommandsForSlot( + @Slot int slot, @Nullable Player.Commands allowedPlayerCommands) { + allowedPlayerCommandsPerSlot.put(slot, allowedPlayerCommands); + return this; + } + + /** + * Sets the allowed non-custom {@link SessionCommands} for buttons in the given slot. + * + *

The default value ({@code null}) does not restrict the allowed {@link SessionCommands}. + * + *

This setting has no effect on whether {@linkplain SessionCommand#COMMAND_CODE_CUSTOM + * custom session commands} are allowed. Use {@link #setAllowCustomCommandsForSlot} instead. + * + * @param slot The {@link Slot}. + * @param allowedSessionCommands The allowed {@link SessionCommands} for buttons in this slot, + * or null to allow all {@link SessionCommands}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAllowedSessionCommandsForSlot( + @Slot int slot, @Nullable SessionCommands allowedSessionCommands) { + allowedSessionCommandsPerSlot.put(slot, allowedSessionCommands); + return this; + } + + /** + * Sets whether {@linkplain SessionCommand#COMMAND_CODE_CUSTOM custom session commands} are + * allowed for buttons in the given slot. + * + *

The default value is {@code true}. + * + * @param slot The {@link Slot}. + * @param allowCustomCommands Whether {@linkplain SessionCommand#COMMAND_CODE_CUSTOM custom + * session commands} are allowed for buttons in this slot. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAllowCustomCommandsForSlot(@Slot int slot, boolean allowCustomCommands) { + areCustomCommandsAllowedPerSlot.put(slot, allowCustomCommands); + return this; + } + + /** Builds the display constraints. */ + public DisplayConstraints build() { + checkState(!buildCalled); + buildCalled = true; + return new DisplayConstraints(this); + } + } + + private final SparseIntArray maxButtonsPerSlot; + private final SparseArray allowedPlayerCommandsPerSlot; + private final SparseArray<@NullableType SessionCommands> allowedSessionCommandsPerSlot; + private final SparseBooleanArray areCustomCommandsAllowedPerSlot; + + private DisplayConstraints(Builder builder) { + this.maxButtonsPerSlot = builder.maxButtonsPerSlot; + this.allowedPlayerCommandsPerSlot = builder.allowedPlayerCommandsPerSlot; + this.allowedSessionCommandsPerSlot = builder.allowedSessionCommandsPerSlot; + this.areCustomCommandsAllowedPerSlot = builder.areCustomCommandsAllowedPerSlot; + } + + /** + * Resolves a list of {@linkplain MediaController#getMediaButtonPreferences media button + * preferences} according to these display constraints and returns the list of buttons to be + * displayed. + * + *

Note that the result of this resolution can change whenever the {@code + * mediaButtonPreferences} change, or the {@code player} reports any of the following listener + * events: + * + *

+ * + * @param mediaButtonPreferences The list of {@linkplain + * MediaController#getMediaButtonPreferences media button preferences}. + * @param player The {@link Player} used to determine default buttons for empty slots. + * @return The resolved list of {@linkplain CommandButton buttons} to be displayed. Each button + * will have a single {@linkplain CommandButton#slots slot} defined. + */ + public ImmutableList resolve( + List mediaButtonPreferences, Player player) { + SparseIntArray availableButtonsPerSlot = maxButtonsPerSlot.clone(); + ImmutableList.Builder resolvedButtons = ImmutableList.builder(); + @Nullable CommandButton firstBackButton = null; + @Nullable CommandButton firstForwardButton = null; + for (int i = 0; i < mediaButtonPreferences.size(); i++) { + CommandButton button = mediaButtonPreferences.get(i); + for (int j = 0; j < button.slots.length(); j++) { + @Slot int slot = button.slots.get(j); + if (!reserveSlotForButton(button, slot, availableButtonsPerSlot)) { + continue; + } + resolvedButtons.add(button.copyWithSlots(ImmutableIntArray.of(slot))); + if (firstForwardButton == null && slot == SLOT_FORWARD) { + firstForwardButton = button; + } else if (firstBackButton == null && slot == SLOT_BACK) { + firstBackButton = button; + } + break; + } + } + Player.Commands availableCommands = player.getAvailableCommands(); + boolean centralSlotEmpty = + maxButtonsPerSlot.get(SLOT_CENTRAL) == availableButtonsPerSlot.get(SLOT_CENTRAL); + if (centralSlotEmpty) { + CommandButton defaultCentralButton = + createButton( + Util.shouldShowPlayButton(player) ? ICON_PLAY : ICON_PAUSE, + Player.COMMAND_PLAY_PAUSE, + availableCommands); + if (reserveSlotForButton(defaultCentralButton, SLOT_CENTRAL, availableButtonsPerSlot)) { + resolvedButtons.add(defaultCentralButton); + } + } + boolean backSlotEmpty = firstBackButton == null && maxButtonsPerSlot.get(SLOT_BACK) > 0; + boolean forwardSlotEmpty = + firstForwardButton == null && maxButtonsPerSlot.get(SLOT_FORWARD) > 0; + if (backSlotEmpty && forwardSlotEmpty) { + @Player.Command + int firstAvailableCommand = + getFirstAvailableOrFirstCommand( + availableCommands, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD); + CommandButton button = + createButton( + getIconForPlayerCommand(firstAvailableCommand, player), + firstAvailableCommand, + availableCommands); + @Slot int buttonSlot = button.slots.get(0); + if (reserveSlotForButton(button, buttonSlot, availableButtonsPerSlot)) { + resolvedButtons.add(button); + } + @Slot int oppositeSlot = buttonSlot == SLOT_BACK ? SLOT_FORWARD : SLOT_BACK; + CommandButton oppositeButton = createOppositeButton(button, oppositeSlot, player); + if (reserveSlotForButton(oppositeButton, oppositeSlot, availableButtonsPerSlot)) { + resolvedButtons.add(oppositeButton); + } + } else if (backSlotEmpty) { + CommandButton oppositeButton = createOppositeButton(firstForwardButton, SLOT_BACK, player); + if (reserveSlotForButton(oppositeButton, SLOT_BACK, availableButtonsPerSlot)) { + resolvedButtons.add(oppositeButton); + } + } else if (forwardSlotEmpty) { + CommandButton oppositeButton = createOppositeButton(firstBackButton, SLOT_FORWARD, player); + if (reserveSlotForButton(oppositeButton, SLOT_FORWARD, availableButtonsPerSlot)) { + resolvedButtons.add(oppositeButton); + } + } + return resolvedButtons.build(); + } + + private boolean reserveSlotForButton( + CommandButton button, @Slot int slot, SparseIntArray availableButtonsPerSlot) { + if (availableButtonsPerSlot.get(slot) == 0) { + return false; + } + boolean canReserveSlot; + if (button.playerCommand != Player.COMMAND_INVALID) { + @Nullable Player.Commands allowedCommands = allowedPlayerCommandsPerSlot.get(slot); + canReserveSlot = allowedCommands == null || allowedCommands.contains(button.playerCommand); + } else if (checkNotNull(button.sessionCommand).commandCode == COMMAND_CODE_CUSTOM) { + canReserveSlot = areCustomCommandsAllowedPerSlot.get(slot, /* valueIfKeyNotFound= */ true); + } else { + @Nullable SessionCommands allowedCommands = allowedSessionCommandsPerSlot.get(slot); + canReserveSlot = allowedCommands == null || allowedCommands.contains(button.sessionCommand); + } + if (canReserveSlot) { + availableButtonsPerSlot.put(slot, availableButtonsPerSlot.get(slot) - 1); + } + return canReserveSlot; + } + + private static CommandButton createOppositeButton( + @Nullable CommandButton button, @Slot int targetSlot, Player player) { + Player.Commands availablePlayerCommands = player.getAvailableCommands(); + @Player.Command + int oppositePlayerCommand = + getOppositePlayerCommand(button, targetSlot, availablePlayerCommands); + @Icon int oppositeIcon = getOppositeIcon(button); + if (oppositeIcon == ICON_UNDEFINED) { + oppositeIcon = getIconForPlayerCommand(oppositePlayerCommand, player); + } + return createButton(oppositeIcon, oppositePlayerCommand, availablePlayerCommands); + } + + private static CommandButton createButton( + @Icon int icon, + @Player.Command int playerCommand, + Player.Commands availablePlayerCommands) { + return new CommandButton.Builder(icon) + .setPlayerCommand(playerCommand) + .setEnabled(availablePlayerCommands.contains(playerCommand)) + .build(); + } + + private static @Player.Command int getFirstAvailableOrFirstCommand( + Player.Commands availableCommands, @Player.Command int... commands) { + for (int command : commands) { + if (availableCommands.contains(command)) { + return command; + } + } + return commands[0]; + } + + private static @Player.Command int getOppositePlayerCommand( + @Nullable CommandButton button, + @Slot int targetSlot, + Player.Commands availablePlayerCommands) { + if (button != null) { + switch (button.playerCommand) { + case Player.COMMAND_SEEK_TO_PREVIOUS: + return Player.COMMAND_SEEK_TO_NEXT; + case Player.COMMAND_SEEK_TO_NEXT: + return Player.COMMAND_SEEK_TO_PREVIOUS; + case Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM: + return Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; + case Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM: + return Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM; + case Player.COMMAND_SEEK_BACK: + return Player.COMMAND_SEEK_FORWARD; + case Player.COMMAND_SEEK_FORWARD: + return Player.COMMAND_SEEK_BACK; + default: + // Fall through. + } + } + if (targetSlot == SLOT_BACK) { + return getFirstAvailableOrFirstCommand( + availablePlayerCommands, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_BACK); + } else { + return getFirstAvailableOrFirstCommand( + availablePlayerCommands, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_FORWARD); + } + } + + private static @Icon int getOppositeIcon(@Nullable CommandButton button) { + if (button == null) { + return ICON_UNDEFINED; + } + switch (button.icon) { + case ICON_PREVIOUS: + return ICON_NEXT; + case ICON_REWIND: + return ICON_FAST_FORWARD; + case ICON_SKIP_BACK: + return ICON_SKIP_FORWARD; + case ICON_NEXT: + return ICON_PREVIOUS; + case ICON_FAST_FORWARD: + return ICON_REWIND; + case ICON_SKIP_FORWARD: + return ICON_SKIP_BACK; + default: + // Intentionally don't match numbered SKIP_BACK/FORWARD icons to let + // getIconForPlayerCommand determine the best matching icon based on actual skip amount. + return ICON_UNDEFINED; + } + } + + private static @Icon int getIconForPlayerCommand( + @Player.Command int playerCommand, Player player) { + switch (playerCommand) { + case Player.COMMAND_SEEK_TO_PREVIOUS: + case Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM: + return ICON_PREVIOUS; + case Player.COMMAND_SEEK_TO_NEXT: + case Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM: + return ICON_NEXT; + case Player.COMMAND_SEEK_BACK: + long seekBackIncrement = player.getSeekBackIncrement(); + if (seekBackIncrement >= 2500 && seekBackIncrement < 7500) { + return ICON_SKIP_BACK_5; + } else if (seekBackIncrement >= 7500 && seekBackIncrement < 12500) { + return ICON_SKIP_BACK_10; + } else if (seekBackIncrement >= 12500 && seekBackIncrement < 20000) { + return ICON_SKIP_BACK_15; + } else if (seekBackIncrement >= 20000 && seekBackIncrement < 40000) { + return ICON_SKIP_BACK_30; + } else { + return ICON_SKIP_BACK; + } + case Player.COMMAND_SEEK_FORWARD: + long seekForwardIncrement = player.getSeekForwardIncrement(); + if (seekForwardIncrement >= 2500 && seekForwardIncrement < 7500) { + return ICON_SKIP_FORWARD_5; + } else if (seekForwardIncrement >= 7500 && seekForwardIncrement < 12500) { + return ICON_SKIP_FORWARD_10; + } else if (seekForwardIncrement >= 12500 && seekForwardIncrement < 20000) { + return ICON_SKIP_FORWARD_15; + } else if (seekForwardIncrement >= 20000 && seekForwardIncrement < 40000) { + return ICON_SKIP_FORWARD_30; + } else { + return ICON_SKIP_FORWARD; + } + default: + throw new UnsupportedOperationException(); + } + } + } + /** The session command of the button. Will be {@code null} if {@link #playerCommand} is set. */ @Nullable public final SessionCommand sessionCommand; @@ -1211,7 +1598,7 @@ public final class CommandButton { CommandButton button = mediaButtonPreferences.get(i); if (!button.isEnabled || button.sessionCommand == null - || button.sessionCommand.commandCode != SessionCommand.COMMAND_CODE_CUSTOM) { + || button.sessionCommand.commandCode != COMMAND_CODE_CUSTOM) { continue; } for (int s = 0; s < button.slots.length(); s++) { @@ -1247,7 +1634,7 @@ public final class CommandButton { CommandButton button = mediaButtonPreferences.get(i); if (!button.isEnabled || button.sessionCommand == null - || button.sessionCommand.commandCode != SessionCommand.COMMAND_CODE_CUSTOM) { + || button.sessionCommand.commandCode != COMMAND_CODE_CUSTOM) { continue; } if (i != backButtonIndex && i != forwardButtonIndex && button.slots.contains(SLOT_OVERFLOW)) { 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 410a0fd8cd..094b8358e5 100644 --- a/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java @@ -20,7 +20,9 @@ import static org.junit.Assert.assertThrows; import android.net.Uri; import android.os.Bundle; +import android.os.Looper; import androidx.media3.common.Player; +import androidx.media3.common.SimpleBasePlayer; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; import com.google.common.primitives.ImmutableIntArray; @@ -1246,4 +1248,598 @@ public class CommandButtonTest { .build()) .inOrder(); } + + @Test + public void displayConstraintsResolve_withMaxButtonsPerSlot_limitsToDefinedMaximum() { + // Define preferences that match, exceed or are below the allowed number of buttons per slot. + // Also provide fallback slots to check they are used if the first preference is not available. + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_CENTRAL, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_BACK_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command4", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_BACK, CommandButton.SLOT_BACK_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command5", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_CENTRAL) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command6", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()); + // Including edge cases of 0 and max integer number of slots. + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + .setMaxButtonsForSlot(CommandButton.SLOT_CENTRAL, /* maxButtons= */ 2) + .setMaxButtonsForSlot(CommandButton.SLOT_FORWARD, /* maxButtons= */ 0) + .setMaxButtonsForSlot(CommandButton.SLOT_BACK, /* maxButtons= */ 1) + .setMaxButtonsForSlot(CommandButton.SLOT_BACK_SECONDARY, /* maxButtons= */ 2) + .setMaxButtonsForSlot(CommandButton.SLOT_OVERFLOW, /* maxButtons= */ Integer.MAX_VALUE) + .build(); + Player player = createFixedStatePlayer(); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_CENTRAL) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command4", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_BACK_SECONDARY) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("command5", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()) + .inOrder(); + } + + @Test + public void + displayConstraintsResolve_withAllowedSessionCommandsPerSlot_limitsToAllowedCommands() { + // Define preferences and constraints with no, single or multiple matches. + // Also provide fallback slots to check they are used if the first preference is not available. + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)) + .setSlots(CommandButton.SLOT_CENTRAL, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM)) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_SEARCH)) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN)) + .setSlots(CommandButton.SLOT_BACK, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT)) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + .setAllowedSessionCommandsForSlot( + CommandButton.SLOT_CENTRAL, + new SessionCommands.Builder() + .add(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT)) + .build()) + .setAllowedSessionCommandsForSlot( + CommandButton.SLOT_BACK, + new SessionCommands.Builder() + .add(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)) + .add(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN)) + .build()) + .setAllowedSessionCommandsForSlot( + CommandButton.SLOT_FORWARD, + new SessionCommands.Builder() + .add(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM)) + .build()) + .setAllowedSessionCommandsForSlot(CommandButton.SLOT_OVERFLOW, SessionCommands.EMPTY) + .build(); + Player player = createFixedStatePlayer(); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM)) + .setSlots(CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT)) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()) + .inOrder(); + } + + @Test + public void displayConstraintsResolve_withAllowCustomCommandsPerSlot_limitsToAllowedCommands() { + // Define some custom commands, but also a non-custom one to check it's used if custom commands + // are not allowed. + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("custom1", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_CENTRAL, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("custom2", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("custom3", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_STOP) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + // Leave out SLOT_FORWARD to test default value of "true" + .setAllowCustomCommandsForSlot(CommandButton.SLOT_BACK, /* allowCustomCommands= */ true) + .setAllowCustomCommandsForSlot( + CommandButton.SLOT_CENTRAL, /* allowCustomCommands= */ false) + .setAllowCustomCommandsForSlot( + CommandButton.SLOT_OVERFLOW, /* allowCustomCommands= */ false) + .build(); + Player player = createFixedStatePlayer(); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("custom1", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setSessionCommand(new SessionCommand("custom2", Bundle.EMPTY)) + .setSlots(CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_STOP) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()) + .inOrder(); + } + + @Test + public void + displayConstraintsResolve_withAllowedPlayerCommandsPerSlot_limitsToAllowedCustomCommands() { + // Define preferences and constraints with no, single or multiple matches. + // Also provide fallback slots to check they are used if the first preference is not available. + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_CENTRAL, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_STOP) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_GET_TRACKS) + .setSlots(CommandButton.SLOT_BACK, CommandButton.SLOT_OVERFLOW) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + .setAllowedPlayerCommandsForSlot( + CommandButton.SLOT_CENTRAL, + new Player.Commands.Builder().add(Player.COMMAND_CHANGE_MEDIA_ITEMS).build()) + .setAllowedPlayerCommandsForSlot( + CommandButton.SLOT_BACK, + new Player.Commands.Builder() + .addAll(Player.COMMAND_PREPARE, Player.COMMAND_GET_TRACKS) + .build()) + .setAllowedPlayerCommandsForSlot( + CommandButton.SLOT_FORWARD, + new Player.Commands.Builder().add(Player.COMMAND_STOP).build()) + .setAllowedPlayerCommandsForSlot(CommandButton.SLOT_OVERFLOW, Player.Commands.EMPTY) + .build(); + Player player = createFixedStatePlayer(); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_PREPARE) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_STOP) + .setSlots(CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .setSlots(CommandButton.SLOT_CENTRAL) + .build()) + .inOrder(); + } + + @Test + public void displayConstraintsResolve_defaultConstraintsNoPreferences_createsDefaultButtons() { + ImmutableList mediaButtonPreferences = ImmutableList.of(); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder().build(); + // Allow multiple forward/back operations to check the preferred one is used. + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD) + .build(), + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setSlots(CommandButton.SLOT_CENTRAL) + .build(), + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .setSlots(CommandButton.SLOT_FORWARD) + .build()) + .inOrder(); + } + + @Test + public void + displayConstraintsResolve_defaultConstraintsNoPreferencesWithoutAvailableCommands_createsDisabledDefaultButtons() { + ImmutableList mediaButtonPreferences = ImmutableList.of(); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder().build(); + // Add a single available command to test the combination of available/unavailable seek actions. + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_NEXT) + .build(), + /* playWhenReady= */ false, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setSlots(CommandButton.SLOT_CENTRAL) + .setEnabled(false) + .build(), + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK) + .setEnabled(false) + .build()) + .inOrder(); + } + + @Test + public void displayConstraintsResolve_noSpaceForDefaultButtons_createsNoDefaultButtons() { + // Block slots by a mix of button preferences and setting max buttons to zero. + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_SET_SPEED_AND_PITCH) + .setSlots(CommandButton.SLOT_FORWARD) + .build()); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + .setMaxButtonsForSlot(CommandButton.SLOT_CENTRAL, /* maxButtons= */ 0) + .setMaxButtonsForSlot(CommandButton.SLOT_BACK, /* maxButtons= */ 0) + .build(); + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_NEXT) + .build(), + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_ALBUM) + .setPlayerCommand(Player.COMMAND_SET_SPEED_AND_PITCH) + .setSlots(CommandButton.SLOT_FORWARD) + .build()); + } + + @Test + public void + displayConstraintsResolve_onlySpaceForDefaultForwardButton_createsDefaultForwardButton() { + ImmutableList mediaButtonPreferences = ImmutableList.of(); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + .setMaxButtonsForSlot(CommandButton.SLOT_CENTRAL, /* maxButtons= */ 0) + .setMaxButtonsForSlot(CommandButton.SLOT_BACK, /* maxButtons= */ 0) + .build(); + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_NEXT) + .build(), + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD) + .build()); + } + + @Test + public void displayConstraintsResolve_onlySpaceForDefaultBackButton_createsDefaultBackButton() { + ImmutableList mediaButtonPreferences = ImmutableList.of(); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder() + .setMaxButtonsForSlot(CommandButton.SLOT_CENTRAL, /* maxButtons= */ 0) + .setMaxButtonsForSlot(CommandButton.SLOT_FORWARD, /* maxButtons= */ 0) + .build(); + Player player = + createFixedStatePlayer( + /* availableCommands= */ Player.Commands.EMPTY, + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK) + .setEnabled(false) + .build()); + } + + @Test + public void + displayConstraintsResolve_defaultConstraintsNoPreferencesWithBackForwardCommands_createsDefaultButtonsWithMatchingIncrement() { + ImmutableList mediaButtonPreferences = ImmutableList.of(); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder().build(); + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD) + .build(), + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY, + /* seekBackIncrementMs= */ 5500, + /* seekForwardIncrementMs= */ 14000); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setSlots(CommandButton.SLOT_CENTRAL) + .build(), + new CommandButton.Builder(CommandButton.ICON_SKIP_BACK_5) + .setPlayerCommand(Player.COMMAND_SEEK_BACK) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_15) + .setPlayerCommand(Player.COMMAND_SEEK_FORWARD) + .setSlots(CommandButton.SLOT_FORWARD) + .build()) + .inOrder(); + } + + @Test + public void + displayConstraintsResolve_withForwardButtonPreference_createsMatchingDefaultBackButton() { + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_FAST_FORWARD) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .setSlots(CommandButton.SLOT_FORWARD) + .build()); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder().build(); + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build(), + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_FAST_FORWARD) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .setSlots(CommandButton.SLOT_FORWARD) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setSlots(CommandButton.SLOT_CENTRAL) + .build(), + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .setSlots(CommandButton.SLOT_BACK) + .build()) + .inOrder(); + } + + @Test + public void + displayConstraintsResolve_withBackButtonPreference_createsMatchingDefaultForwardButton() { + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_SKIP_BACK) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK) + .build()); + CommandButton.DisplayConstraints displayConstraints = + new CommandButton.DisplayConstraints.Builder().build(); + Player player = + createFixedStatePlayer( + /* availableCommands= */ new Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build(), + /* playWhenReady= */ true, + /* playbackState= */ Player.STATE_READY); + + ImmutableList resolvedButtons = + displayConstraints.resolve(mediaButtonPreferences, player); + + assertThat(resolvedButtons) + .containsExactly( + new CommandButton.Builder(CommandButton.ICON_SKIP_BACK) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setSlots(CommandButton.SLOT_BACK) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setSlots(CommandButton.SLOT_CENTRAL) + .build(), + new CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD) + .build()) + .inOrder(); + } + + private static Player createFixedStatePlayer() { + return createFixedStatePlayer( + /* availableCommands= */ Player.Commands.EMPTY, + /* playWhenReady= */ false, + /* playbackState= */ Player.STATE_IDLE); + } + + private static Player createFixedStatePlayer( + Player.Commands availableCommands, boolean playWhenReady, @Player.State int playbackState) { + return createFixedStatePlayer( + availableCommands, + playWhenReady, + playbackState, + /* seekBackIncrementMs= */ 5500, + /* seekForwardIncrementMs= */ 14000); + } + + private static Player createFixedStatePlayer( + Player.Commands availableCommands, + boolean playWhenReady, + @Player.State int playbackState, + long seekBackIncrementMs, + long seekForwardIncrementMs) { + return new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return new State.Builder() + .setAvailableCommands(availableCommands) + .setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(playbackState) + .setPlaylist(ImmutableList.of(new MediaItemData.Builder("uid").build())) + .setSeekBackIncrementMs(seekBackIncrementMs) + .setSeekForwardIncrementMs(seekForwardIncrementMs) + .build(); + } + }; + } }