From e851a1419d35d8ade1c58237d8167eb8459d32a5 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 17 Oct 2024 09:14:07 -0700 Subject: [PATCH] Add slots to CommandButton These allow to define preferences for where a button should be displayed. PiperOrigin-RevId: 686938126 --- .../media3/session/CommandButton.java | 178 +++++++++++++++++- .../media3/session/CommandButtonTest.java | 133 +++++++++++++ 2 files changed, 304 insertions(+), 7 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 aa58a5bbb6..1ec8be9cd0 100644 --- a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java +++ b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java @@ -32,6 +32,7 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; +import com.google.common.primitives.ImmutableIntArray; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CheckReturnValue; import java.lang.annotation.Documented; @@ -411,17 +412,68 @@ public final class CommandButton { */ @UnstableApi public static final int ICON_FEED = 0xe0e5; + // TODO: b/332877990 - Stabilize these constants and other slot APIs + /** + * A slot at which a button can be displayed in a UI surface. Must be one of the {@code + * CommandButton.SLOT_} constants. + */ + @UnstableApi + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({ + SLOT_CENTRAL, + SLOT_BACK, + SLOT_FORWARD, + SLOT_BACK_SECONDARY, + SLOT_FORWARD_SECONDARY, + SLOT_OVERFLOW + }) + public @interface Slot {} + + /** A central slot in a playback control UI, most commonly used for play or pause actions. */ + @UnstableApi public static final int SLOT_CENTRAL = 1; + + /** + * A slot in a playback control UI for backward-directed playback actions, most commonly used for + * previous or rewind actions. + */ + @UnstableApi public static final int SLOT_BACK = 2; + + /** + * A slot in a playback control UI for forward-directed playback actions, most commonly used for + * next or fast-forward actions. + */ + @UnstableApi public static final int SLOT_FORWARD = 3; + + /** + * A slot in a playback control UI for secondary backward-directed playback actions, most commonly + * used for previous or rewind actions. + */ + @UnstableApi public static final int SLOT_BACK_SECONDARY = 4; + + /** + * A slot in a playback control UI for secondary forward-directed playback actions, most commonly + * used for next or fast-forward actions. + */ + @UnstableApi public static final int SLOT_FORWARD_SECONDARY = 5; + + /** A slot in a playback control UI for additional actions that don't fit into other slots. */ + @UnstableApi public static final int SLOT_OVERFLOW = 6; + /** A builder for {@link CommandButton}. */ public static final class Builder { + private final @Icon int icon; + @Nullable private SessionCommand sessionCommand; private @Player.Command int playerCommand; - private @Icon int icon; @DrawableRes private int iconResId; @Nullable private Uri iconUri; private CharSequence displayName; private Bundle extras; private boolean enabled; + @Nullable private ImmutableIntArray slots; /** * [will be deprecated] Use {@link #Builder(int)} instead to define the {@link Icon} for this @@ -451,7 +503,6 @@ public final class CommandButton { displayName = ""; extras = Bundle.EMPTY; playerCommand = Player.COMMAND_INVALID; - icon = ICON_UNDEFINED; enabled = true; } @@ -581,13 +632,63 @@ public final class CommandButton { return this; } + /** + * Sets the allowed {@link Slot} positions for this button. + * + *

The button is only allowed in the defined slots. If none of the slots can display the + * button, either because the slots do not exist, are already occupied or the UI surface does + * not allow the specific type of button in these slots, the button will not be displayed at + * all. + * + *

When multiple slots are provided, they define a preference order. The button will be + * placed in the first slot in the list that exists, isn't already occupied and that allows this + * type of button. + * + *

When not specified, the default value depends on the associated {@link #setPlayerCommand + * player command} and the {@link Icon} set in the constructor: + * + *

+ * + * @param slots The list of allowed {@link Slot} positions. Must not be empty. + * @return This builder for chaining. + */ + @UnstableApi + @CanIgnoreReturnValue + public Builder setSlots(@Slot int... slots) { + checkArgument(slots.length != 0); + this.slots = ImmutableIntArray.copyOf(slots); + return this; + } + /** Builds a {@link CommandButton}. */ public CommandButton build() { checkState( (sessionCommand == null) != (playerCommand == Player.COMMAND_INVALID), "Exactly one of sessionCommand and playerCommand should be set"); + if (slots == null) { + slots = ImmutableIntArray.of(getDefaultSlot(playerCommand, icon)); + } return new CommandButton( - sessionCommand, playerCommand, icon, iconResId, iconUri, displayName, extras, enabled); + sessionCommand, + playerCommand, + icon, + iconResId, + iconUri, + displayName, + extras, + enabled, + slots); } } @@ -630,6 +731,19 @@ public final class CommandButton { */ @UnstableApi public final Bundle extras; + /** + * The allowed {@link Slot} positions for this button. + * + *

The button is only allowed in the defined slots. If none of the slots can display the + * button, either because the slots do not exist, are already occupied or the UI surface does not + * allow the specific type of button in these slots, the button will not be displayed at all. + * + *

When multiple slots are provided, they define a preference order. The button will be placed + * in the first slot in the list that exists, isn't already occupied and that allows this type of + * button. + */ + @UnstableApi public final ImmutableIntArray slots; + /** * Whether the button is enabled. * @@ -647,7 +761,8 @@ public final class CommandButton { @Nullable Uri iconUri, CharSequence displayName, Bundle extras, - boolean enabled) { + boolean enabled, + ImmutableIntArray slots) { this.sessionCommand = sessionCommand; this.playerCommand = playerCommand; this.icon = icon; @@ -656,6 +771,7 @@ public final class CommandButton { this.displayName = displayName; this.extras = new Bundle(extras); this.isEnabled = enabled; + this.slots = slots; } /** Returns a copy with the new {@link #isEnabled} flag. */ @@ -675,7 +791,8 @@ public final class CommandButton { iconUri, displayName, new Bundle(extras), - isEnabled); + isEnabled, + slots); } /** Checks the given command button for equality while ignoring {@link #extras}. */ @@ -694,13 +811,14 @@ public final class CommandButton { && iconResId == button.iconResId && Objects.equal(iconUri, button.iconUri) && TextUtils.equals(displayName, button.displayName) - && isEnabled == button.isEnabled; + && isEnabled == button.isEnabled + && slots.equals(button.slots); } @Override public int hashCode() { return Objects.hashCode( - sessionCommand, playerCommand, icon, iconResId, displayName, isEnabled, iconUri); + sessionCommand, playerCommand, icon, iconResId, displayName, isEnabled, iconUri, slots); } /** @@ -747,6 +865,7 @@ public final class CommandButton { private static final String FIELD_ENABLED = Util.intToStringMaxRadix(5); private static final String FIELD_ICON_URI = Util.intToStringMaxRadix(6); private static final String FIELD_ICON = Util.intToStringMaxRadix(7); + private static final String FIELD_SLOTS = Util.intToStringMaxRadix(8); @UnstableApi public Bundle toBundle() { @@ -775,6 +894,9 @@ public final class CommandButton { if (!isEnabled) { bundle.putBoolean(FIELD_ENABLED, isEnabled); } + if (slots.length() != 1 || slots.get(0) != SLOT_OVERFLOW) { + bundle.putIntArray(FIELD_SLOTS, slots.toArray()); + } return bundle; } @@ -806,6 +928,9 @@ public final class CommandButton { sessionInterfaceVersion < 3 || bundle.getBoolean(FIELD_ENABLED, /* defaultValue= */ true); @Nullable Uri iconUri = bundle.getParcelable(FIELD_ICON_URI); @Icon int icon = bundle.getInt(FIELD_ICON, /* defaultValue= */ ICON_UNDEFINED); + @Nullable + @Slot + int[] slots = bundle.getIntArray(FIELD_SLOTS); Builder builder = new Builder(icon, iconResId); if (sessionCommand != null) { builder.setSessionCommand(sessionCommand); @@ -820,6 +945,7 @@ public final class CommandButton { .setDisplayName(displayName) .setExtras(extras == null ? Bundle.EMPTY : extras) .setEnabled(enabled) + .setSlots(slots == null ? new int[] {SLOT_OVERFLOW} : slots) .build(); } @@ -983,4 +1109,42 @@ public final class CommandButton { return 0; } } + + /** + * Returns the default {@link Slot} for a button. + * + * @param playerCommand The {@link Player.Command} associated with this button. + * @param icon The {@link Icon} of this button. + * @return The default {@link Slot} for this button. + */ + @UnstableApi + public static @Slot int getDefaultSlot(@Player.Command int playerCommand, @Icon int icon) { + if (playerCommand == Player.COMMAND_PLAY_PAUSE || icon == ICON_PLAY || icon == ICON_PAUSE) { + return SLOT_CENTRAL; + } else if (playerCommand == Player.COMMAND_SEEK_BACK + || playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS + || playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM + || icon == ICON_PREVIOUS + || icon == ICON_REWIND + || icon == ICON_SKIP_BACK + || icon == ICON_SKIP_BACK_5 + || icon == ICON_SKIP_BACK_10 + || icon == ICON_SKIP_BACK_15 + || icon == ICON_SKIP_BACK_30) { + return SLOT_BACK; + } else if (playerCommand == Player.COMMAND_SEEK_FORWARD + || playerCommand == Player.COMMAND_SEEK_TO_NEXT + || playerCommand == Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM + || icon == ICON_NEXT + || icon == ICON_FAST_FORWARD + || icon == ICON_SKIP_FORWARD + || icon == ICON_SKIP_FORWARD_5 + || icon == ICON_SKIP_FORWARD_10 + || icon == ICON_SKIP_FORWARD_15 + || icon == ICON_SKIP_FORWARD_30) { + return SLOT_FORWARD; + } else { + return 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 c37dc10653..8e6745e832 100644 --- a/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/CommandButtonTest.java @@ -23,6 +23,7 @@ import android.os.Bundle; import androidx.media3.common.Player; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; +import com.google.common.primitives.ImmutableIntArray; import org.junit.Test; import org.junit.runner.RunWith; @@ -172,6 +173,7 @@ public class CommandButtonTest { .setIconResId(R.drawable.media3_notification_small_icon) .setIconUri(Uri.parse("content://test")) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_CENTRAL) .build()) .isEqualTo( new CommandButton.Builder(CommandButton.ICON_UNDEFINED) @@ -179,6 +181,7 @@ public class CommandButtonTest { .setIconResId(R.drawable.media3_notification_small_icon) .setIconUri(Uri.parse("content://test")) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD, CommandButton.SLOT_CENTRAL) .build()); } @@ -189,6 +192,7 @@ public class CommandButtonTest { .setDisplayName("button") .setIconResId(R.drawable.media3_notification_small_icon) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_BACK) .build(); assertThat(button) @@ -199,6 +203,7 @@ public class CommandButtonTest { .setDisplayName("button2") .setIconResId(R.drawable.media3_notification_small_icon) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_BACK) .build()); assertThat(button) .isNotEqualTo( @@ -206,6 +211,7 @@ public class CommandButtonTest { .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) .setDisplayName("button") .setIconResId(R.drawable.media3_notification_small_icon) + .setSlots(CommandButton.SLOT_BACK) .build()); assertThat(button) .isNotEqualTo( @@ -213,6 +219,7 @@ public class CommandButtonTest { .setIconResId(R.drawable.media3_icon_play) .setDisplayName("button") .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_BACK) .build()); assertThat(button) .isNotEqualTo( @@ -221,6 +228,7 @@ public class CommandButtonTest { .setDisplayName("button") .setIconResId(R.drawable.media3_notification_small_icon) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_BACK) .build()); assertThat(button) .isNotEqualTo( @@ -228,6 +236,7 @@ public class CommandButtonTest { .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM)) .setDisplayName("button") .setIconResId(R.drawable.media3_notification_small_icon) + .setSlots(CommandButton.SLOT_BACK) .build()); assertThat(button) .isNotEqualTo( @@ -236,6 +245,7 @@ public class CommandButtonTest { .setIconResId(R.drawable.media3_notification_small_icon) .setIconUri(Uri.parse("content://test")) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_BACK) .build()); assertThat(button) .isNotEqualTo( @@ -243,6 +253,15 @@ public class CommandButtonTest { .setDisplayName("button") .setIconResId(R.drawable.media3_notification_small_icon) .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_BACK) + .build()); + assertThat(button) + .isNotEqualTo( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setSlots(CommandButton.SLOT_FORWARD) .build()); } @@ -355,6 +374,118 @@ public class CommandButtonTest { assertThrows(IllegalStateException.class, builder::build); } + @Test + public void build_withoutSlots_assignsDefaultSlots() { + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_CENTRAL)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setPlayerCommand(Player.COMMAND_PREPARE) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_CENTRAL)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setPlayerCommand(Player.COMMAND_STOP) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_CENTRAL)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_SEEK_BACK) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_PREVIOUS) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_SKIP_BACK) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_SKIP_BACK_10) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_REWIND) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_BACK)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_SEEK_FORWARD) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_10) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_FAST_FORWARD) + .setPlayerCommand(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_FORWARD)); + assertThat( + new CommandButton.Builder(CommandButton.ICON_SHUFFLE_ON) + .setPlayerCommand(Player.COMMAND_SET_SHUFFLE_MODE) + .build() + .slots) + .isEqualTo(ImmutableIntArray.of(CommandButton.SLOT_OVERFLOW)); + } + @Test public void fromBundle_afterToBundle_returnsEqualInstance() { Bundle extras = new Bundle(); @@ -367,6 +498,7 @@ public class CommandButtonTest { .setIconUri(Uri.parse("content://test")) .setExtras(extras) .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)) + .setSlots(CommandButton.SLOT_OVERFLOW, CommandButton.SLOT_BACK) .build(); CommandButton buttonWithPlayerCommand = new CommandButton.Builder(CommandButton.ICON_CLOSED_CAPTIONS) @@ -376,6 +508,7 @@ public class CommandButtonTest { .setIconUri(Uri.parse("content://test")) .setExtras(extras) .setPlayerCommand(Player.COMMAND_GET_METADATA) + .setSlots(CommandButton.SLOT_CENTRAL) .build(); CommandButton buttonWithDefaultValues = new CommandButton.Builder(CommandButton.ICON_UNDEFINED)