Add media item command buttons for Media3 controllers

Note that unlike the legacy implementation, custom media items
commands can be used for any media items with Media3 API. This
includes `MediaItem` instances that are received from sources
different to `MediaLibraryService` methods.

Hence when connected against a Media3 session these custom commands
can be used with a `MediaController` as well as with a `MediaBrowser`.

Interoperability with `MediaBrowserServiceCompat` will
be added in a follow up CL.

Issue: androidx/media#1474
#cherrypick
PiperOrigin-RevId: 678782860
This commit is contained in:
bachinger 2024-09-25 11:32:46 -07:00 committed by Copybara-Service
parent 0ea63e3fa6
commit 686c3fe7f5
16 changed files with 335 additions and 2 deletions

View File

@ -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
<a href="https://developer.android.com/training/cars/media#custom_browse_actions">Custom
Browse actions of AAOS</a>.
* UI:
* Make the stretched/cropped video in
`PlayerView`-in-Compose-`AndroidView` workaround opt-in, due to issues

View File

@ -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<String> 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<String> 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<String> 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;
}

View File

@ -60,12 +60,15 @@ import java.util.List;
@Nullable public final Token platformToken;
public final ImmutableList<CommandButton> commandButtonsForMediaItems;
public ConnectionState(
int libraryVersion,
int sessionInterfaceVersion,
IMediaSession sessionBinder,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
ImmutableList<CommandButton> 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<Bundle> commandButtonsForMediaItemsArrayList =
bundle.getParcelableArrayList(FIELD_COMMAND_BUTTONS_FOR_MEDIA_ITEMS);
ImmutableList<CommandButton> 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,

View File

@ -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<String, CommandButton> getCommandButtonsForMediaItemsMap() {
return ImmutableMap.of();
}
@Override
public ListenableFuture<LibraryResult<MediaItem>> getLibraryRoot(@Nullable LibraryParams params) {
if (!getInstance()

View File

@ -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

View File

@ -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<CommandButton> getCommandButtonsForMediaItem(MediaItem mediaItem) {
ImmutableMap<String, CommandButton> buttonMap = impl.getCommandButtonsForMediaItemsMap();
ImmutableList<String> supportedActions = mediaItem.mediaMetadata.supportedCommands;
ImmutableList.Builder<CommandButton> 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}.
*
* <p>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}.
*
* <p>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}.
*
* <p>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<SessionResult> 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<CommandButton> getCustomLayout();
ImmutableMap<String, CommandButton> getCommandButtonsForMediaItemsMap();
Bundle getSessionExtras();
Timeline getCurrentTimeline();

View File

@ -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<CommandButton> customLayoutOriginal;
private ImmutableList<CommandButton> customLayoutWithUnavailableButtonsDisabled;
private ImmutableMap<String, CommandButton> 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<String, CommandButton> 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<String, CommandButton> 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;

View File

@ -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<String, CommandButton> getCommandButtonsForMediaItemsMap() {
return ImmutableMap.of();
}
@Override
public Bundle getSessionExtras() {
return controllerInfo.sessionExtras;

View File

@ -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<CommandButton> 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<CommandButton> customLayout,
ImmutableList<CommandButton> 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<CommandButton> customLayout,
ImmutableList<CommandButton> 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,

View File

@ -75,6 +75,7 @@ import java.util.concurrent.Future;
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
ImmutableList<CommandButton> commandButtonsForMediaItems,
MediaLibrarySession.Callback callback,
Bundle tokenExtras,
Bundle sessionExtras,
@ -89,6 +90,7 @@ import java.util.concurrent.Future;
player,
sessionActivity,
customLayout,
commandButtonsForMediaItems,
callback,
tokenExtras,
sessionExtras,

View File

@ -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<CommandButton> 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<CommandButton> customLayout,
ImmutableList<CommandButton> 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<CommandButton> customLayout,
ImmutableList<CommandButton> 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<CommandButton> customLayout;
/* package */ ImmutableList<CommandButton> 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<CommandButton> commandButtons) {
this.commandButtonsForMediaItems = ImmutableList.copyOf(commandButtons);
return (BuilderT) this;
}
@SuppressWarnings("unchecked")
public BuilderT setPeriodicPositionUpdateEnabled(boolean isPeriodicPositionUpdateEnabled) {
this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled;

View File

@ -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<CommandButton> commandButtonsForMediaItems;
private PlayerInfo playerInfo;
private PlayerWrapper playerWrapper;
@ -161,6 +162,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
ImmutableList<CommandButton> 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<CommandButton> getCommandButtonsForMediaItems() {
return commandButtonsForMediaItems;
}
public void setSessionExtras(Bundle sessionExtras) {
this.sessionExtras = sessionExtras;
dispatchRemoteControllerTaskWithoutReturn(

View File

@ -535,6 +535,7 @@ import java.util.concurrent.ExecutionException;
connectionResult.customLayout != null
? connectionResult.customLayout
: sessionImpl.getCustomLayout(),
sessionImpl.getCommandButtonsForMediaItems(),
connectionResult.availableSessionCommands,
connectionResult.availablePlayerCommands,
playerWrapper.getAvailableCommands(),

View File

@ -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";

View File

@ -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<CommandButton> 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<SessionResult> 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<SessionResult>() {
@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 =

View File

@ -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<SessionResult> 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(