diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ba5882631a..64ea6aabae 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -88,6 +88,11 @@ * Handle `IllegalArgumentException` thrown by devices of certain manufacturers when setting the broadcast receiver for media button intents ([#1730](https://github.com/androidx/media/issues/1730)). + * Add command buttons for media items. This adds the Media3 API for what + was known as `Custom browse actions` in the legacy world. No + interoperability with the legacy API is provided with this change. See + Custom + Browse actions of AAOS. * UI: * Make the stretched/cropped video in `PlayerView`-in-Compose-`AndroidView` workaround opt-in, due to issues diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java b/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java index feefac797d..f0006efcf6 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java @@ -30,6 +30,7 @@ import androidx.annotation.Nullable; 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.errorprone.annotations.CanIgnoreReturnValue; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -85,8 +86,11 @@ public final class MediaMetadata { @Nullable private CharSequence station; @Nullable private @MediaType Integer mediaType; @Nullable private Bundle extras; + private ImmutableList supportedCommands; - public Builder() {} + public Builder() { + supportedCommands = ImmutableList.of(); + } @SuppressWarnings("deprecation") // Assigning from deprecated fields. private Builder(MediaMetadata mediaMetadata) { @@ -123,6 +127,7 @@ public final class MediaMetadata { this.compilation = mediaMetadata.compilation; this.station = mediaMetadata.station; this.mediaType = mediaMetadata.mediaType; + this.supportedCommands = mediaMetadata.supportedCommands; this.extras = mediaMetadata.extras; } @@ -440,6 +445,17 @@ public final class MediaMetadata { return this; } + /** + * Sets the IDs of the supported commands (see for instance {@code + * CommandButton.sessionCommand.customAction} of the Media3 session module). + */ + @CanIgnoreReturnValue + @UnstableApi + public Builder setSupportedCommands(List supportedCommands) { + this.supportedCommands = ImmutableList.copyOf(supportedCommands); + return this; + } + /** * Sets all fields supported by the {@link Metadata.Entry entries} within the {@link Metadata}. * @@ -1123,6 +1139,12 @@ public final class MediaMetadata { */ @Nullable public final Bundle extras; + /** + * The IDs of the supported commands of this media item (see for instance {@code + * CommandButton.sessionCommand.customAction} of the Media3 session module). + */ + @UnstableApi public final ImmutableList supportedCommands; + @SuppressWarnings("deprecation") // Assigning deprecated fields. private MediaMetadata(Builder builder) { // Handle compatibility for deprecated fields. @@ -1175,6 +1197,7 @@ public final class MediaMetadata { this.compilation = builder.compilation; this.station = builder.station; this.mediaType = mediaType; + this.supportedCommands = builder.supportedCommands; this.extras = builder.extras; } 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 6ce24166c5..5c2eb43ad7 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java @@ -60,12 +60,15 @@ import java.util.List; @Nullable public final Token platformToken; + public final ImmutableList commandButtonsForMediaItems; + public ConnectionState( int libraryVersion, int sessionInterfaceVersion, IMediaSession sessionBinder, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, SessionCommands sessionCommands, Player.Commands playerCommandsFromSession, Player.Commands playerCommandsFromPlayer, @@ -78,6 +81,7 @@ import java.util.List; this.sessionBinder = sessionBinder; this.sessionActivity = sessionActivity; this.customLayout = customLayout; + this.commandButtonsForMediaItems = commandButtonsForMediaItems; this.sessionCommands = sessionCommands; this.playerCommandsFromSession = playerCommandsFromSession; this.playerCommandsFromPlayer = playerCommandsFromPlayer; @@ -91,6 +95,7 @@ import java.util.List; private static final String FIELD_SESSION_BINDER = Util.intToStringMaxRadix(1); private static final String FIELD_SESSION_ACTIVITY = Util.intToStringMaxRadix(2); private static final String FIELD_CUSTOM_LAYOUT = Util.intToStringMaxRadix(9); + private static final String FIELD_COMMAND_BUTTONS_FOR_MEDIA_ITEMS = Util.intToStringMaxRadix(13); private static final String FIELD_SESSION_COMMANDS = Util.intToStringMaxRadix(3); private static final String FIELD_PLAYER_COMMANDS_FROM_SESSION = Util.intToStringMaxRadix(4); private static final String FIELD_PLAYER_COMMANDS_FROM_PLAYER = Util.intToStringMaxRadix(5); @@ -101,7 +106,7 @@ import java.util.List; private static final String FIELD_IN_PROCESS_BINDER = Util.intToStringMaxRadix(10); private static final String FIELD_PLATFORM_TOKEN = Util.intToStringMaxRadix(12); - // Next field key = 13 + // Next field key = 14 public Bundle toBundleForRemoteProcess(int controllerInterfaceVersion) { Bundle bundle = new Bundle(); @@ -113,6 +118,12 @@ import java.util.List; FIELD_CUSTOM_LAYOUT, BundleCollectionUtil.toBundleArrayList(customLayout, CommandButton::toBundle)); } + if (!commandButtonsForMediaItems.isEmpty()) { + bundle.putParcelableArrayList( + FIELD_COMMAND_BUTTONS_FOR_MEDIA_ITEMS, + BundleCollectionUtil.toBundleArrayList( + commandButtonsForMediaItems, CommandButton::toBundle)); + } bundle.putBundle(FIELD_SESSION_COMMANDS, sessionCommands.toBundle()); bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_SESSION, playerCommandsFromSession.toBundle()); bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER, playerCommandsFromPlayer.toBundle()); @@ -161,6 +172,15 @@ import java.util.List; ? BundleCollectionUtil.fromBundleList( b -> CommandButton.fromBundle(b, sessionInterfaceVersion), commandButtonArrayList) : ImmutableList.of(); + @Nullable + List commandButtonsForMediaItemsArrayList = + bundle.getParcelableArrayList(FIELD_COMMAND_BUTTONS_FOR_MEDIA_ITEMS); + ImmutableList commandButtonsForMediaItems = + commandButtonsForMediaItemsArrayList != null + ? BundleCollectionUtil.fromBundleList( + b -> CommandButton.fromBundle(b, sessionInterfaceVersion), + commandButtonsForMediaItemsArrayList) + : ImmutableList.of(); @Nullable Bundle sessionCommandsBundle = bundle.getBundle(FIELD_SESSION_COMMANDS); SessionCommands sessionCommands = sessionCommandsBundle == null @@ -192,6 +212,7 @@ import java.util.List; IMediaSession.Stub.asInterface(sessionBinder), sessionActivity, customLayout, + commandButtonsForMediaItems, sessionCommands, playerCommandsFromSession, playerCommandsFromPlayer, diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaBrowserImplLegacy.java b/libraries/session/src/main/java/androidx/media3/session/MediaBrowserImplLegacy.java index 15996ef839..ce7ce491ab 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaBrowserImplLegacy.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaBrowserImplLegacy.java @@ -34,6 +34,7 @@ import androidx.media3.session.legacy.MediaBrowserCompat; import androidx.media3.session.legacy.MediaBrowserCompat.ItemCallback; import androidx.media3.session.legacy.MediaBrowserCompat.SubscriptionCallback; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; @@ -88,6 +89,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; return super.getAvailableSessionCommands(); } + @Override + public ImmutableMap getCommandButtonsForMediaItemsMap() { + return ImmutableMap.of(); + } + @Override public ListenableFuture> getLibraryRoot(@Nullable LibraryParams params) { if (!getInstance() diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaConstants.java b/libraries/session/src/main/java/androidx/media3/session/MediaConstants.java index b98cbbac5e..caeb9c19ed 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaConstants.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaConstants.java @@ -99,6 +99,11 @@ public final class MediaConstants { androidx.media3.session.legacy.MediaConstants .PLAYBACK_STATE_EXTRAS_KEY_ERROR_RESOLUTION_USING_CAR_APP_LIBRARY_INTENT; + /** {@link Bundle} key used for a media item ID. */ + @UnstableApi + public static final String EXTRA_KEY_MEDIA_ID = + androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_MEDIA_ITEM_ID; + /** * {@link Bundle} key to indicate a preference that a region of space for the skip to next control * should always be blocked out in the UI, even when the seek to next standard action is not diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaController.java b/libraries/session/src/main/java/androidx/media3/session/MediaController.java index fff4f1cd5f..59dafa8dfc 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaController.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaController.java @@ -62,6 +62,7 @@ import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSourceBitmapLoader; import androidx.media3.session.legacy.MediaBrowserCompat; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -636,6 +637,27 @@ public class MediaController implements Player { return impl.isConnected(); } + /** + * Returns the command buttons that are supported for the given {@link MediaItem}. + * + * @param mediaItem The media item for which to get command buttons. + * @return The {@linkplain CommandButton command buttons} that are supported for the given media + * item. + */ + @UnstableApi + public final ImmutableList getCommandButtonsForMediaItem(MediaItem mediaItem) { + ImmutableMap buttonMap = impl.getCommandButtonsForMediaItemsMap(); + ImmutableList supportedActions = mediaItem.mediaMetadata.supportedCommands; + ImmutableList.Builder commandButtonsForMediaItem = new ImmutableList.Builder<>(); + for (int i = 0; i < supportedActions.size(); i++) { + CommandButton commandButton = buttonMap.get(supportedActions.get(i)); + if (commandButton != null) { + commandButtonsForMediaItem.add(commandButton); + } + } + return commandButtonsForMediaItem.build(); + } + @Override public final void play() { verifyApplicationThread(); @@ -1022,6 +1044,35 @@ public class MediaController implements Player { return createDisconnectedFuture(); } + /** + * Sends a custom command to the session for the given {@linkplain MediaItem media item}. + * + *

Calling this method is equivalent to calling {@link #sendCustomCommand(SessionCommand, + * Bundle)} and including the {@linkplain MediaItem#mediaId media ID} in the argument bundle with + * key {@link MediaConstants#EXTRA_KEY_MEDIA_ID}. + * + *

A command is not accepted if it is not a custom command or the command is not in the list of + * {@linkplain #getAvailableSessionCommands() available session commands}. + * + *

Interoperability: When connected to {@code + * android.support.v4.media.session.MediaSessionCompat}, {@link SessionResult#resultCode} will + * return the custom result code from the {@code android.os.ResultReceiver#onReceiveResult(int, + * Bundle)} instead of the standard result codes defined in the {@link SessionResult}. + * + * @param command The custom command. + * @param mediaItem The media item for which the command is sent. + * @param args The additional arguments. May be empty. + * @return A {@link ListenableFuture} of {@link SessionResult} representing the pending + * completion. + */ + @UnstableApi + public final ListenableFuture sendCustomCommand( + SessionCommand command, MediaItem mediaItem, Bundle args) { + Bundle augnentedBundle = new Bundle(args); + augnentedBundle.putString(MediaConstants.EXTRA_KEY_MEDIA_ID, mediaItem.mediaId); + return sendCustomCommand(command, augnentedBundle); + } + /** * Returns the custom layout. * @@ -2091,6 +2142,8 @@ public class MediaController implements Player { ImmutableList getCustomLayout(); + ImmutableMap getCommandButtonsForMediaItemsMap(); + Bundle getSessionExtras(); Timeline getCurrentTimeline(); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java index 7c15be9bc0..1fd3ac59a5 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -86,6 +86,7 @@ import androidx.media3.session.MediaController.MediaControllerImpl; import androidx.media3.session.PlayerInfo.BundlingExclusions; import androidx.media3.session.legacy.MediaBrowserCompat; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -126,6 +127,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; @Nullable private PendingIntent sessionActivity; private ImmutableList customLayoutOriginal; private ImmutableList customLayoutWithUnavailableButtonsDisabled; + private ImmutableMap commandButtonsForMediaItemsMap; private SessionCommands sessionCommands; private Commands playerCommandsFromSession; private Commands playerCommandsFromPlayer; @@ -153,6 +155,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; sessionCommands = SessionCommands.EMPTY; customLayoutOriginal = ImmutableList.of(); customLayoutWithUnavailableButtonsDisabled = ImmutableList.of(); + commandButtonsForMediaItemsMap = ImmutableMap.of(); playerCommandsFromSession = Commands.EMPTY; playerCommandsFromPlayer = Commands.EMPTY; intersectedPlayerCommands = @@ -733,6 +736,11 @@ import org.checkerframework.checker.nullness.qual.NonNull; return customLayoutWithUnavailableButtonsDisabled; } + @Override + public ImmutableMap getCommandButtonsForMediaItemsMap() { + return commandButtonsForMediaItemsMap; + } + @Override public Bundle getSessionExtras() { return sessionExtras; @@ -2619,6 +2627,16 @@ import org.checkerframework.checker.nullness.qual.NonNull; customLayoutWithUnavailableButtonsDisabled = CommandButton.copyWithUnavailableButtonsDisabled( result.customLayout, sessionCommands, intersectedPlayerCommands); + ImmutableMap.Builder commandButtonsForMediaItems = + new ImmutableMap.Builder<>(); + for (int i = 0; i < result.commandButtonsForMediaItems.size(); i++) { + CommandButton commandButton = result.commandButtonsForMediaItems.get(i); + if (commandButton.sessionCommand != null + && commandButton.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) { + commandButtonsForMediaItems.put(commandButton.sessionCommand.customAction, commandButton); + } + } + commandButtonsForMediaItemsMap = commandButtonsForMediaItems.buildOrThrow(); playerInfo = result.playerInfo; MediaSession.Token platformToken = result.platformToken == null ? token.getPlatformToken() : result.platformToken; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java index 66cde26b68..24cdc6811a 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java @@ -76,6 +76,7 @@ import androidx.media3.session.legacy.PlaybackStateCompat; import androidx.media3.session.legacy.RatingCompat; import androidx.media3.session.legacy.VolumeProviderCompat; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; @@ -420,6 +421,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; return controllerInfo.customLayout; } + @Override + public ImmutableMap getCommandButtonsForMediaItemsMap() { + return ImmutableMap.of(); + } + @Override public Bundle getSessionExtras() { return controllerInfo.sessionExtras; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java index 26a19bdfda..554ed2f859 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java @@ -630,6 +630,18 @@ public abstract class MediaLibraryService extends MediaSessionService { return this; } + /** + * Sets {@link CommandButton command buttons} that can be added as {@link + * MediaMetadata.Builder#setSupportedCommands(List) supported media item commands}. + * + * @param commandButtons The command buttons. + */ + @UnstableApi + @Override + public Builder setCommandButtonsForMediaItems(List commandButtons) { + return super.setCommandButtonsForMediaItems(commandButtons); + } + /** * Builds a {@link MediaLibrarySession}. * @@ -648,6 +660,7 @@ public abstract class MediaLibraryService extends MediaSessionService { player, sessionActivity, customLayout, + commandButtonsForMediaItems, callback, tokenExtras, sessionExtras, @@ -664,6 +677,7 @@ public abstract class MediaLibraryService extends MediaSessionService { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, MediaSession.Callback callback, Bundle tokenExtras, Bundle sessionExtras, @@ -677,6 +691,7 @@ public abstract class MediaLibraryService extends MediaSessionService { player, sessionActivity, customLayout, + commandButtonsForMediaItems, callback, tokenExtras, sessionExtras, @@ -693,6 +708,7 @@ public abstract class MediaLibraryService extends MediaSessionService { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, MediaSession.Callback callback, Bundle tokenExtras, Bundle sessionExtras, @@ -707,6 +723,7 @@ public abstract class MediaLibraryService extends MediaSessionService { player, sessionActivity, customLayout, + commandButtonsForMediaItems, (Callback) callback, tokenExtras, sessionExtras, diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java index 6d288636f5..db2b779995 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java @@ -75,6 +75,7 @@ import java.util.concurrent.Future; Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, MediaLibrarySession.Callback callback, Bundle tokenExtras, Bundle sessionExtras, @@ -89,6 +90,7 @@ import java.util.concurrent.Future; player, sessionActivity, customLayout, + commandButtonsForMediaItems, callback, tokenExtras, sessionExtras, diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java index a56a304754..c7d1325739 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -425,6 +425,18 @@ public class MediaSession { return super.setShowPlayButtonIfPlaybackIsSuppressed(showPlayButtonIfPlaybackIsSuppressed); } + /** + * Sets {@link CommandButton command buttons} that can be added as {@linkplain + * MediaMetadata.Builder#setSupportedCommands(List) supported media item commands}. + * + * @param commandButtons The command buttons. + */ + @UnstableApi + @Override + public Builder setCommandButtonsForMediaItems(List commandButtons) { + return super.setCommandButtonsForMediaItems(commandButtons); + } + /** * Builds a {@link MediaSession}. * @@ -443,6 +455,7 @@ public class MediaSession { player, sessionActivity, customLayout, + commandButtonsForMediaItems, callback, tokenExtras, sessionExtras, @@ -654,6 +667,7 @@ public class MediaSession { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, Callback callback, Bundle tokenExtras, Bundle sessionExtras, @@ -674,6 +688,7 @@ public class MediaSession { player, sessionActivity, customLayout, + commandButtonsForMediaItems, callback, tokenExtras, sessionExtras, @@ -689,6 +704,7 @@ public class MediaSession { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, Callback callback, Bundle tokenExtras, Bundle sessionExtras, @@ -703,6 +719,7 @@ public class MediaSession { player, sessionActivity, customLayout, + commandButtonsForMediaItems, callback, tokenExtras, sessionExtras, @@ -2073,6 +2090,7 @@ public class MediaSession { /* package */ @MonotonicNonNull BitmapLoader bitmapLoader; /* package */ boolean playIfSuppressed; /* package */ ImmutableList customLayout; + /* package */ ImmutableList commandButtonsForMediaItems; /* package */ boolean isPeriodicPositionUpdateEnabled; public BuilderBase(Context context, Player player, CallbackT callback) { @@ -2086,6 +2104,7 @@ public class MediaSession { customLayout = ImmutableList.of(); playIfSuppressed = true; isPeriodicPositionUpdateEnabled = true; + commandButtonsForMediaItems = ImmutableList.of(); } @SuppressWarnings("unchecked") @@ -2140,6 +2159,12 @@ public class MediaSession { return (BuilderT) this; } + @SuppressWarnings("unchecked") + public BuilderT setCommandButtonsForMediaItems(List commandButtons) { + this.commandButtonsForMediaItems = ImmutableList.copyOf(commandButtons); + return (BuilderT) this; + } + @SuppressWarnings("unchecked") public BuilderT setPeriodicPositionUpdateEnabled(boolean isPeriodicPositionUpdateEnabled) { this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled; 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 52e0f918c2..9f9c0e045b 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -132,6 +132,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private final Handler mainHandler; private final boolean playIfSuppressed; private final boolean isPeriodicPositionUpdateEnabled; + private final ImmutableList commandButtonsForMediaItems; private PlayerInfo playerInfo; private PlayerWrapper playerWrapper; @@ -161,6 +162,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList commandButtonsForMediaItems, MediaSession.Callback callback, Bundle tokenExtras, Bundle sessionExtras, @@ -181,6 +183,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; sessionId = id; this.sessionActivity = sessionActivity; this.customLayout = customLayout; + this.commandButtonsForMediaItems = commandButtonsForMediaItems; this.callback = callback; this.sessionExtras = sessionExtras; this.bitmapLoader = bitmapLoader; @@ -513,6 +516,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return customLayout; } + /** Returns the command buttons for media items. */ + public ImmutableList getCommandButtonsForMediaItems() { + return commandButtonsForMediaItems; + } + public void setSessionExtras(Bundle sessionExtras) { this.sessionExtras = sessionExtras; dispatchRemoteControllerTaskWithoutReturn( 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 df9f731e90..8529282436 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -535,6 +535,7 @@ import java.util.concurrent.ExecutionException; connectionResult.customLayout != null ? connectionResult.customLayout : sessionImpl.getCustomLayout(), + sessionImpl.getCommandButtonsForMediaItems(), connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands, playerWrapper.getAvailableCommands(), diff --git a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java index 5ccbc12d44..5b75b7ea06 100644 --- a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java +++ b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java @@ -21,6 +21,8 @@ public class MediaSessionConstants { // Test method names public static final String TEST_GET_SESSION_ACTIVITY = "testGetSessionActivity"; public static final String TEST_GET_CUSTOM_LAYOUT = "testGetCustomLayout"; + public static final String TEST_GET_COMMAND_BUTTONS_FOR_MEDIA_ITEMS = + "testGetCommandButtonsForMediaItems"; public static final String TEST_WITH_CUSTOM_COMMANDS = "testWithCustomCommands"; public static final String TEST_CONTROLLER_LISTENER_SESSION_REJECTS = "connection_sessionRejects"; public static final String TEST_IS_SESSION_COMMAND_AVAILABLE = "testIsSessionCommandAvailable"; diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java index f33402c1db..dec5175b8d 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java @@ -21,6 +21,7 @@ import static androidx.media3.session.MediaUtils.createPlayerCommandsWithout; import static androidx.media3.test.session.common.CommonConstants.DEFAULT_TEST_NAME; import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME; import static androidx.media3.test.session.common.MediaSessionConstants.KEY_AVAILABLE_SESSION_COMMANDS; +import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_COMMAND_BUTTONS_FOR_MEDIA_ITEMS; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE; @@ -66,8 +67,10 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -547,6 +550,90 @@ public class MediaControllerTest { session.cleanUp(); } + @Test + public void getCommandButtonsForMediaItem() throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_COMMAND_BUTTONS_FOR_MEDIA_ITEMS, /* tokenExtras= */ null); + CommandButton playlistAddButton = + new CommandButton.Builder(CommandButton.ICON_PLAYLIST_ADD) + .setSessionCommand( + new SessionCommand("androidx.media3.actions.playlist_add", Bundle.EMPTY)) + .build(); + CommandButton radioButton = + new CommandButton.Builder(CommandButton.ICON_RADIO) + .setSessionCommand(new SessionCommand("androidx.media3.actions.radio", Bundle.EMPTY)) + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setMediaId("mediaId") + .setMediaMetadata( + new MediaMetadata.Builder() + .setSupportedCommands( + ImmutableList.of( + "androidx.media3.actions.playlist_add", + "androidx.media3.actions.radio", + "invalid")) + .build()) + .build(); + MediaController controller = controllerTestRule.createController(session.getToken()); + + ImmutableList commandButtons = + threadTestRule + .getHandler() + .postAndSync(() -> controller.getCommandButtonsForMediaItem(mediaItem)); + + assertThat(commandButtons).containsExactly(playlistAddButton, radioButton).inOrder(); + session.cleanUp(); + } + + @Test + public void sendCustomCommandForMediaItem() throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_COMMAND_BUTTONS_FOR_MEDIA_ITEMS, /* tokenExtras= */ null); + MediaItem mediaItem = + new MediaItem.Builder() + .setMediaId("mediaId-1") + .setMediaMetadata( + new MediaMetadata.Builder() + .setSupportedCommands(ImmutableList.of("androidx.media3.actions.playlist_add")) + .build()) + .build(); + CountDownLatch latch = new CountDownLatch(/* count= */ 1); + AtomicReference sessionResultRef = new AtomicReference<>(); + MediaController controller = controllerTestRule.createController(session.getToken()); + + Futures.addCallback( + threadTestRule + .getHandler() + .postAndSync( + () -> { + CommandButton commandButton = + controller.getCommandButtonsForMediaItem(mediaItem).get(0); + return controller.sendCustomCommand( + commandButton.sessionCommand, mediaItem, Bundle.EMPTY); + }), + new FutureCallback() { + @Override + public void onSuccess(SessionResult result) { + sessionResultRef.set(result); + latch.countDown(); + } + + @Override + public void onFailure(Throwable t) { + latch.countDown(); + } + }, + MoreExecutors.directExecutor()); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(sessionResultRef.get()).isNotNull(); + assertThat(sessionResultRef.get().resultCode).isEqualTo(SessionResult.RESULT_SUCCESS); + assertThat(sessionResultRef.get().extras.getString(MediaConstants.EXTRA_KEY_MEDIA_ID)) + .isEqualTo("mediaId-1"); + session.cleanUp(); + } + @Test public void getSessionExtras_includedInConnectionStateWhenConnecting() throws Exception { RemoteMediaSession session = diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java index 8dcf4f6a2f..22dd0f2ac2 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java @@ -63,6 +63,7 @@ import static androidx.media3.test.session.common.MediaSessionConstants.KEY_CONT import static androidx.media3.test.session.common.MediaSessionConstants.NOTIFICATION_CONTROLLER_KEY; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_COMMAND_GET_TRACKS; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CONTROLLER_LISTENER_SESSION_REJECTS; +import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_COMMAND_BUTTONS_FOR_MEDIA_ITEMS; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE; @@ -71,6 +72,7 @@ import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_ import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_VIDEO_SIZE_CHANGED; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_WITH_CUSTOM_COMMANDS; +import static com.google.common.util.concurrent.Futures.immediateFuture; import android.app.PendingIntent; import android.app.Service; @@ -233,6 +235,58 @@ public class MediaSessionProviderService extends Service { }); break; } + case TEST_GET_COMMAND_BUTTONS_FOR_MEDIA_ITEMS: + { + CommandButton playlistAddButton = + new CommandButton.Builder(CommandButton.ICON_PLAYLIST_ADD) + .setSessionCommand( + new SessionCommand("androidx.media3.actions.playlist_add", Bundle.EMPTY)) + .build(); + CommandButton radioButton = + new CommandButton.Builder(CommandButton.ICON_RADIO) + .setSessionCommand( + new SessionCommand("androidx.media3.actions.radio", Bundle.EMPTY)) + .build(); + builder.setCommandButtonsForMediaItems( + ImmutableList.of(playlistAddButton, radioButton)); + builder.setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + new SessionCommands.Builder() + .add(checkNotNull(playlistAddButton.sessionCommand)) + .add(checkNotNull(radioButton.sessionCommand)) + .build()) + .build(); + } + + @Override + public ListenableFuture onCustomCommand( + MediaSession session, + ControllerInfo controller, + SessionCommand customCommand, + Bundle args) { + SessionResult sessionResult = + new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED); + if (customCommand.equals(playlistAddButton.sessionCommand) + || customCommand.equals(radioButton.sessionCommand)) { + Bundle extras = new Bundle(); + String receivedMediaId = args.getString(MediaConstants.EXTRA_KEY_MEDIA_ID); + @SessionResult.Code int resultCode = SessionResult.RESULT_ERROR_BAD_VALUE; + if (receivedMediaId != null) { + extras.putString(MediaConstants.EXTRA_KEY_MEDIA_ID, receivedMediaId); + resultCode = SessionResult.RESULT_SUCCESS; + } + sessionResult = new SessionResult(resultCode, extras); + } + return immediateFuture(sessionResult); + } + }); + break; + } case TEST_CONTROLLER_LISTENER_SESSION_REJECTS: { builder.setCallback(