diff --git a/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl b/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl index 21914d90c7..f5895f1c85 100644 --- a/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl +++ b/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl @@ -49,7 +49,8 @@ oneway interface IMediaController { void onExtrasChanged(int seq, in Bundle extras) = 3011; void onSessionActivityChanged(int seq, in PendingIntent pendingIntent) = 3013; void onError(int seq, in Bundle sessionError) = 3014; - // Next Id for MediaController: 3015 + void onSetMediaButtonPreferences(int seq, in List commandButtonList) = 3015; + // Next Id for MediaController: 3016 void onChildrenChanged( int seq, String parentId, int itemCount, in @nullable Bundle libraryParams) = 4000; 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 5c2eb43ad7..cd0bf10fd8 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java @@ -58,6 +58,8 @@ import java.util.List; public final ImmutableList customLayout; + public final ImmutableList mediaButtonPreferences; + @Nullable public final Token platformToken; public final ImmutableList commandButtonsForMediaItems; @@ -68,6 +70,7 @@ import java.util.List; IMediaSession sessionBinder, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, SessionCommands sessionCommands, Player.Commands playerCommandsFromSession, @@ -81,6 +84,7 @@ import java.util.List; this.sessionBinder = sessionBinder; this.sessionActivity = sessionActivity; this.customLayout = customLayout; + this.mediaButtonPreferences = mediaButtonPreferences; this.commandButtonsForMediaItems = commandButtonsForMediaItems; this.sessionCommands = sessionCommands; this.playerCommandsFromSession = playerCommandsFromSession; @@ -95,6 +99,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_MEDIA_BUTTON_PREFERENCES = Util.intToStringMaxRadix(14); 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); @@ -106,7 +111,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 = 14 + // Next field key = 15 public Bundle toBundleForRemoteProcess(int controllerInterfaceVersion) { Bundle bundle = new Bundle(); @@ -118,6 +123,21 @@ import java.util.List; FIELD_CUSTOM_LAYOUT, BundleCollectionUtil.toBundleArrayList(customLayout, CommandButton::toBundle)); } + if (!mediaButtonPreferences.isEmpty()) { + if (controllerInterfaceVersion >= 7) { + bundle.putParcelableArrayList( + FIELD_MEDIA_BUTTON_PREFERENCES, + BundleCollectionUtil.toBundleArrayList( + mediaButtonPreferences, CommandButton::toBundle)); + } else { + // Controller doesn't support media button preferences, send the list as a custom layout. + // TODO: b/332877990 - More accurately reflect media button preferences as custom layout. + bundle.putParcelableArrayList( + FIELD_CUSTOM_LAYOUT, + BundleCollectionUtil.toBundleArrayList( + mediaButtonPreferences, CommandButton::toBundle)); + } + } if (!commandButtonsForMediaItems.isEmpty()) { bundle.putParcelableArrayList( FIELD_COMMAND_BUTTONS_FOR_MEDIA_ITEMS, @@ -166,11 +186,20 @@ import java.util.List; IBinder sessionBinder = checkNotNull(BundleCompat.getBinder(bundle, FIELD_SESSION_BINDER)); @Nullable PendingIntent sessionActivity = bundle.getParcelable(FIELD_SESSION_ACTIVITY); @Nullable - List commandButtonArrayList = bundle.getParcelableArrayList(FIELD_CUSTOM_LAYOUT); + List customLayoutArrayList = bundle.getParcelableArrayList(FIELD_CUSTOM_LAYOUT); ImmutableList customLayout = - commandButtonArrayList != null + customLayoutArrayList != null ? BundleCollectionUtil.fromBundleList( - b -> CommandButton.fromBundle(b, sessionInterfaceVersion), commandButtonArrayList) + b -> CommandButton.fromBundle(b, sessionInterfaceVersion), customLayoutArrayList) + : ImmutableList.of(); + @Nullable + List mediaButtonPreferencesArrayList = + bundle.getParcelableArrayList(FIELD_MEDIA_BUTTON_PREFERENCES); + ImmutableList mediaButtonPreferences = + mediaButtonPreferencesArrayList != null + ? BundleCollectionUtil.fromBundleList( + b -> CommandButton.fromBundle(b, sessionInterfaceVersion), + mediaButtonPreferencesArrayList) : ImmutableList.of(); @Nullable List commandButtonsForMediaItemsArrayList = @@ -212,6 +241,7 @@ import java.util.List; IMediaSession.Stub.asInterface(sessionBinder), sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, sessionCommands, playerCommandsFromSession, diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index da535383bd..010440a67f 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -51,7 +51,6 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.util.Arrays; -import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -299,19 +298,20 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi @Override public final MediaNotification createNotification( MediaSession mediaSession, - ImmutableList customLayout, + ImmutableList mediaButtonPreferences, MediaNotification.ActionFactory actionFactory, Callback onNotificationChangedCallback) { ensureNotificationChannel(); - ImmutableList.Builder customLayoutWithEnabledCommandButtonsOnly = + // TODO: b/332877990 - More accurately reflect media button preferences in the notification. + ImmutableList.Builder mediaButtonPreferencesWithEnabledCommandButtonsOnly = new ImmutableList.Builder<>(); - for (int i = 0; i < customLayout.size(); i++) { - CommandButton button = customLayout.get(i); + for (int i = 0; i < mediaButtonPreferences.size(); i++) { + CommandButton button = mediaButtonPreferences.get(i); if (button.sessionCommand != null && button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM && button.isEnabled) { - customLayoutWithEnabledCommandButtonsOnly.add(customLayout.get(i)); + mediaButtonPreferencesWithEnabledCommandButtonsOnly.add(mediaButtonPreferences.get(i)); } } Player player = mediaSession.getPlayer(); @@ -325,7 +325,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi getMediaButtons( mediaSession, player.getAvailableCommands(), - customLayoutWithEnabledCommandButtonsOnly.build(), + mediaButtonPreferencesWithEnabledCommandButtonsOnly.build(), !Util.shouldShowPlayButton( player, mediaSession.getShowPlayButtonIfPlaybackIsSuppressed())), builder, @@ -424,18 +424,18 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi * be customized by defining the index of the command in compact view of up to 3 commands in their * extras with key {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}. * - *

To make the custom layout and commands work, you need to {@linkplain - * MediaSession#setCustomLayout(List) set the custom layout of commands} and add the custom + *

To make the media button preferences and custom commands work, you need to {@linkplain + * MediaSession#setMediaButtonPreferences set the media button preferences} and add the custom * commands to the available commands when a controller {@linkplain * MediaSession.Callback#onConnect(MediaSession, MediaSession.ControllerInfo) connects to the - * session}. Controllers that connect after you called {@link MediaSession#setCustomLayout(List)} - * need the custom command set in {@link MediaSession.Callback#onPostConnect(MediaSession, - * MediaSession.ControllerInfo)} also. + * session}. Controllers that connect after you called {@link + * MediaSession#setMediaButtonPreferences} need the custom command set in {@link + * MediaSession.Callback#onPostConnect(MediaSession, MediaSession.ControllerInfo)} too. * * @param session The media session. * @param playerCommands The available player commands. - * @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of - * commands}. + * @param mediaButtonPreferences The {@linkplain MediaSession#setMediaButtonPreferences media + * button preferences}. * @param showPauseButton Whether the notification should show a pause button (e.g., because the * player is currently playing content), otherwise show a play button to start playback. * @return The ordered list of command buttons to be placed on the notification. @@ -443,7 +443,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi protected ImmutableList getMediaButtons( MediaSession session, Player.Commands playerCommands, - ImmutableList customLayout, + ImmutableList mediaButtonPreferences, boolean showPauseButton) { // Skip to previous action. ImmutableList.Builder commandButtons = new ImmutableList.Builder<>(); @@ -488,8 +488,8 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi .setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description)) .build()); } - for (int i = 0; i < customLayout.size(); i++) { - CommandButton button = customLayout.get(i); + for (int i = 0; i < mediaButtonPreferences.size(); i++) { + CommandButton button = mediaButtonPreferences.get(i); if (button.sessionCommand != null && button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) { commandButtons.add(button); diff --git a/libraries/session/src/main/java/androidx/media3/session/LegacyConversions.java b/libraries/session/src/main/java/androidx/media3/session/LegacyConversions.java index 6107180e24..7602c190e1 100644 --- a/libraries/session/src/main/java/androidx/media3/session/LegacyConversions.java +++ b/libraries/session/src/main/java/androidx/media3/session/LegacyConversions.java @@ -1495,13 +1495,12 @@ import java.util.concurrent.TimeoutException; } /** - * Converts {@link CustomAction} in the {@link PlaybackStateCompat} to the custom layout which is - * the list of the {@link CommandButton}. + * Converts {@link CustomAction} in the {@link PlaybackStateCompat} to media button preferences. * - * @param state playback state - * @return custom layout. Always non-null. + * @param state The {@link PlaybackStateCompat}. + * @return The media button preferences. */ - public static ImmutableList convertToCustomLayout( + public static ImmutableList convertToMediaButtonPreferences( @Nullable PlaybackStateCompat state) { if (state == null) { return ImmutableList.of(); @@ -1510,7 +1509,7 @@ import java.util.concurrent.TimeoutException; if (customActions == null) { return ImmutableList.of(); } - ImmutableList.Builder layout = new ImmutableList.Builder<>(); + ImmutableList.Builder mediaButtonPreferences = new ImmutableList.Builder<>(); for (CustomAction customAction : customActions) { String action = customAction.getAction(); @Nullable Bundle extras = customAction.getExtras(); @@ -1521,15 +1520,16 @@ import java.util.concurrent.TimeoutException; MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT, /* defaultValue= */ CommandButton.ICON_UNDEFINED) : CommandButton.ICON_UNDEFINED; + // TODO: b/332877990 - Set appropriate slots based on available player commands. CommandButton button = new CommandButton.Builder(icon, customAction.getIcon()) .setSessionCommand(new SessionCommand(action, extras == null ? Bundle.EMPTY : extras)) .setDisplayName(customAction.getName()) .setEnabled(true) .build(); - layout.add(button); + mediaButtonPreferences.add(button); } - return layout.build(); + return mediaButtonPreferences.build(); } /** Converts {@link AudioAttributesCompat} into {@link AudioAttributes}. */ 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 8b56611944..cf250de172 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaController.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaController.java @@ -409,6 +409,8 @@ public class MediaController implements Player { /** * Called when the {@linkplain #getCustomLayout() custom layout} changed. * + *

This method will be deprecated, prefer to use {@link #onMediaButtonPreferencesChanged}. + * *

The custom layout can change when either the session {@linkplain * MediaSession#setCustomLayout changes the custom layout}, or when the session {@linkplain * MediaSession#setAvailableCommands(MediaSession.ControllerInfo, SessionCommands, Commands) @@ -424,6 +426,25 @@ public class MediaController implements Player { @UnstableApi default void onCustomLayoutChanged(MediaController controller, List layout) {} + /** + * Called when the {@linkplain #getMediaButtonPreferences() media button preferences} changed. + * + *

The media button preferences can change when either the session {@linkplain + * MediaSession#setMediaButtonPreferences changes the media button preferences}, or when the + * session {@linkplain MediaSession#setAvailableCommands(MediaSession.ControllerInfo, + * SessionCommands, Commands) changes the available commands} for a controller that affect + * whether buttons of the media button preferences are enabled or disabled. + * + *

Note that the {@linkplain CommandButton#isEnabled enabled} flag is set to {@code false} if + * the available commands do not allow to use a button. + * + * @param controller The controller. + * @param mediaButtonPreferences The ordered list of {@linkplain CommandButton command buttons}. + */ + @UnstableApi + default void onMediaButtonPreferencesChanged( + MediaController controller, List mediaButtonPreferences) {} + /** * Called when the available session commands are changed by session. * @@ -1094,6 +1115,8 @@ public class MediaController implements Player { /** * Returns the custom layout. * + *

This method will be deprecated, prefer to use {@link #getMediaButtonPreferences()} instead. + * *

After being connected, a change of the custom layout is reported with {@link * Listener#onCustomLayoutChanged(MediaController, List)}. * @@ -1104,8 +1127,24 @@ public class MediaController implements Player { */ @UnstableApi public final ImmutableList getCustomLayout() { + return getMediaButtonPreferences(); + } + + /** + * Returns the media button preferences. + * + *

After being connected, a change of the media button preferences is reported with {@link + * Listener#onMediaButtonPreferencesChanged(MediaController, List)}. + * + *

Note that the {@linkplain CommandButton#isEnabled enabled} flag is set to {@code false} if + * the available commands do not allow to use a button. + * + * @return The media button preferences. + */ + @UnstableApi + public final ImmutableList getMediaButtonPreferences() { verifyApplicationThread(); - return isConnected() ? impl.getCustomLayout() : ImmutableList.of(); + return isConnected() ? impl.getMediaButtonPreferences() : ImmutableList.of(); } /** @@ -2168,7 +2207,7 @@ public class MediaController implements Player { ListenableFuture sendCustomCommand(SessionCommand command, Bundle args); - ImmutableList getCustomLayout(); + ImmutableList getMediaButtonPreferences(); ImmutableList getCommandButtonsForMediaItem(MediaItem mediaItem); 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 dc75a339ea..8152b2ed53 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -126,7 +126,8 @@ import org.checkerframework.checker.nullness.qual.NonNull; private PlayerInfo playerInfo; @Nullable private PendingIntent sessionActivity; private ImmutableList customLayoutOriginal; - private ImmutableList customLayoutWithUnavailableButtonsDisabled; + private ImmutableList mediaButtonPreferencesOriginal; + private ImmutableList resolvedMediaButtonPreferences; private ImmutableMap commandButtonsForMediaItemsMap; private SessionCommands sessionCommands; private Commands playerCommandsFromSession; @@ -155,7 +156,8 @@ import org.checkerframework.checker.nullness.qual.NonNull; surfaceSize = Size.UNKNOWN; sessionCommands = SessionCommands.EMPTY; customLayoutOriginal = ImmutableList.of(); - customLayoutWithUnavailableButtonsDisabled = ImmutableList.of(); + mediaButtonPreferencesOriginal = ImmutableList.of(); + resolvedMediaButtonPreferences = ImmutableList.of(); commandButtonsForMediaItemsMap = ImmutableMap.of(); playerCommandsFromSession = Commands.EMPTY; playerCommandsFromPlayer = Commands.EMPTY; @@ -745,8 +747,8 @@ import org.checkerframework.checker.nullness.qual.NonNull; } @Override - public ImmutableList getCustomLayout() { - return customLayoutWithUnavailableButtonsDisabled; + public ImmutableList getMediaButtonPreferences() { + return resolvedMediaButtonPreferences; } @Override @@ -2652,9 +2654,13 @@ import org.checkerframework.checker.nullness.qual.NonNull; createIntersectedCommandsEnsuringCommandReleaseAvailable( playerCommandsFromSession, playerCommandsFromPlayer); customLayoutOriginal = result.customLayout; - customLayoutWithUnavailableButtonsDisabled = - CommandButton.copyWithUnavailableButtonsDisabled( - result.customLayout, sessionCommands, intersectedPlayerCommands); + mediaButtonPreferencesOriginal = result.mediaButtonPreferences; + resolvedMediaButtonPreferences = + resolveMediaButtonPreferences( + mediaButtonPreferencesOriginal, + customLayoutOriginal, + sessionCommands, + intersectedPlayerCommands); ImmutableMap.Builder commandButtonsForMediaItems = new ImmutableMap.Builder<>(); for (int i = 0; i < result.commandButtonsForMediaItems.size(); i++) { @@ -2834,13 +2840,17 @@ import org.checkerframework.checker.nullness.qual.NonNull; intersectedPlayerCommandsChanged = !Util.areEqual(intersectedPlayerCommands, prevIntersectedPlayerCommands); } - boolean customLayoutChanged = false; + boolean mediaButtonPreferencesChanged = false; if (sessionCommandsChanged || intersectedPlayerCommandsChanged) { - ImmutableList oldCustomLayout = customLayoutWithUnavailableButtonsDisabled; - customLayoutWithUnavailableButtonsDisabled = - CommandButton.copyWithUnavailableButtonsDisabled( - customLayoutOriginal, sessionCommands, intersectedPlayerCommands); - customLayoutChanged = !customLayoutWithUnavailableButtonsDisabled.equals(oldCustomLayout); + ImmutableList oldMediaButtonPreferences = resolvedMediaButtonPreferences; + resolvedMediaButtonPreferences = + resolveMediaButtonPreferences( + mediaButtonPreferencesOriginal, + customLayoutOriginal, + sessionCommands, + intersectedPlayerCommands); + mediaButtonPreferencesChanged = + !resolvedMediaButtonPreferences.equals(oldMediaButtonPreferences); } if (intersectedPlayerCommandsChanged) { listeners.sendEvent( @@ -2853,12 +2863,14 @@ import org.checkerframework.checker.nullness.qual.NonNull; listener -> listener.onAvailableSessionCommandsChanged(getInstance(), sessionCommands)); } - if (customLayoutChanged) { + if (mediaButtonPreferencesChanged) { getInstance() .notifyControllerListener( - listener -> - listener.onCustomLayoutChanged( - getInstance(), customLayoutWithUnavailableButtonsDisabled)); + listener -> { + listener.onCustomLayoutChanged(getInstance(), resolvedMediaButtonPreferences); + listener.onMediaButtonPreferencesChanged( + getInstance(), resolvedMediaButtonPreferences); + }); } } @@ -2876,50 +2888,84 @@ import org.checkerframework.checker.nullness.qual.NonNull; playerCommandsFromSession, playerCommandsFromPlayer); boolean intersectedPlayerCommandsChanged = !Util.areEqual(intersectedPlayerCommands, prevIntersectedPlayerCommands); - boolean customLayoutChanged = false; + boolean mediaButtonPreferencesChanged = false; if (intersectedPlayerCommandsChanged) { - ImmutableList oldCustomLayout = customLayoutWithUnavailableButtonsDisabled; - customLayoutWithUnavailableButtonsDisabled = - CommandButton.copyWithUnavailableButtonsDisabled( - customLayoutOriginal, sessionCommands, intersectedPlayerCommands); - customLayoutChanged = !customLayoutWithUnavailableButtonsDisabled.equals(oldCustomLayout); + ImmutableList oldMediaButtonPreferences = resolvedMediaButtonPreferences; + resolvedMediaButtonPreferences = + resolveMediaButtonPreferences( + mediaButtonPreferencesOriginal, + customLayoutOriginal, + sessionCommands, + intersectedPlayerCommands); + mediaButtonPreferencesChanged = + !resolvedMediaButtonPreferences.equals(oldMediaButtonPreferences); listeners.sendEvent( /* eventFlag= */ Player.EVENT_AVAILABLE_COMMANDS_CHANGED, listener -> listener.onAvailableCommandsChanged(intersectedPlayerCommands)); } - if (customLayoutChanged) { + if (mediaButtonPreferencesChanged) { getInstance() .notifyControllerListener( - listener -> - listener.onCustomLayoutChanged( - getInstance(), customLayoutWithUnavailableButtonsDisabled)); + listener -> { + listener.onCustomLayoutChanged(getInstance(), resolvedMediaButtonPreferences); + listener.onMediaButtonPreferencesChanged( + getInstance(), resolvedMediaButtonPreferences); + }); } } - // Calling deprecated listener callback method for backwards compatibility. - @SuppressWarnings("deprecation") void onSetCustomLayout(int seq, List layout) { if (!isConnected()) { return; } - ImmutableList oldCustomLayout = customLayoutWithUnavailableButtonsDisabled; + ImmutableList oldMediaButtonPreferences = resolvedMediaButtonPreferences; customLayoutOriginal = ImmutableList.copyOf(layout); - customLayoutWithUnavailableButtonsDisabled = - CommandButton.copyWithUnavailableButtonsDisabled( - layout, sessionCommands, intersectedPlayerCommands); - boolean hasCustomLayoutChanged = - !Objects.equals(customLayoutWithUnavailableButtonsDisabled, oldCustomLayout); + resolvedMediaButtonPreferences = + resolveMediaButtonPreferences( + mediaButtonPreferencesOriginal, layout, sessionCommands, intersectedPlayerCommands); + boolean mediaButtonPreferencesChanged = + !Objects.equals(resolvedMediaButtonPreferences, oldMediaButtonPreferences); getInstance() .notifyControllerListener( listener -> { ListenableFuture future = checkNotNull( - listener.onSetCustomLayout( - getInstance(), customLayoutWithUnavailableButtonsDisabled), + listener.onSetCustomLayout(getInstance(), resolvedMediaButtonPreferences), "MediaController.Listener#onSetCustomLayout() must not return null"); - if (hasCustomLayoutChanged) { - listener.onCustomLayoutChanged( - getInstance(), customLayoutWithUnavailableButtonsDisabled); + if (mediaButtonPreferencesChanged) { + listener.onCustomLayoutChanged(getInstance(), resolvedMediaButtonPreferences); + listener.onMediaButtonPreferencesChanged( + getInstance(), resolvedMediaButtonPreferences); + } + sendControllerResultWhenReady(seq, future); + }); + } + + void onSetMediaButtonPreferences(int seq, List mediaButtonPreferences) { + if (!isConnected()) { + return; + } + ImmutableList oldMediaButtonPreferences = resolvedMediaButtonPreferences; + mediaButtonPreferencesOriginal = ImmutableList.copyOf(mediaButtonPreferences); + resolvedMediaButtonPreferences = + resolveMediaButtonPreferences( + mediaButtonPreferences, + customLayoutOriginal, + sessionCommands, + intersectedPlayerCommands); + boolean mediaButtonPreferencesChanged = + !Objects.equals(resolvedMediaButtonPreferences, oldMediaButtonPreferences); + getInstance() + .notifyControllerListener( + listener -> { + ListenableFuture future = + checkNotNull( + listener.onSetCustomLayout(getInstance(), resolvedMediaButtonPreferences), + "MediaController.Listener#onSetCustomLayout() must not return null"); + if (mediaButtonPreferencesChanged) { + listener.onCustomLayoutChanged(getInstance(), resolvedMediaButtonPreferences); + listener.onMediaButtonPreferencesChanged( + getInstance(), resolvedMediaButtonPreferences); } sendControllerResultWhenReady(seq, future); }); @@ -3277,6 +3323,18 @@ import org.checkerframework.checker.nullness.qual.NonNull; return newMediaItemIndex; } + private static ImmutableList resolveMediaButtonPreferences( + List mediaButtonPreferences, + List customLayout, + SessionCommands sessionCommands, + Player.Commands playerCommands) { + // TODO: b/332877990 - When using custom layout, set correct slots based on available commands. + return CommandButton.copyWithUnavailableButtonsDisabled( + mediaButtonPreferences.isEmpty() ? customLayout : mediaButtonPreferences, + sessionCommands, + playerCommands); + } + private static Commands createIntersectedCommandsEnsuringCommandReleaseAvailable( Commands commandFromSession, Commands commandsFromPlayer) { Commands intersectedCommands = MediaUtils.intersect(commandFromSession, commandsFromPlayer); 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 f3730d85cc..d8e2dc2d7d 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java @@ -202,7 +202,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; maskedPlayerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -268,7 +268,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; /* playerError= */ null), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -391,7 +391,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; maskedPlayerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -432,8 +432,8 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; } @Override - public ImmutableList getCustomLayout() { - return controllerInfo.customLayout; + public ImmutableList getMediaButtonPreferences() { + return controllerInfo.mediaButtonPreferences; } @Override @@ -556,7 +556,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithPlaybackParameters(playbackParameters), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -577,7 +577,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithPlaybackParameters(new PlaybackParameters(speed)), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -671,7 +671,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; maskedPlayerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -736,7 +736,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; maskedPlayerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -790,7 +790,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; maskedPlayerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -858,7 +858,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; maskedPlayerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -972,7 +972,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithRepeatMode(repeatMode), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1000,7 +1000,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithShuffleModeEnabled(shuffleModeEnabled), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1137,7 +1137,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithDeviceVolume(volume, isDeviceMuted), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1170,7 +1170,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithDeviceVolume(volume + 1, isDeviceMuted), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1202,7 +1202,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithDeviceVolume(volume - 1, isDeviceMuted), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1237,7 +1237,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo.copyWithDeviceVolume(volume, muted), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1276,7 +1276,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; Player.PLAYBACK_SUPPRESSION_REASON_NONE), controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, controllerInfo.sessionExtras, /* sessionError= */ null); updateStateMaskedControllerInfo( @@ -1625,13 +1625,18 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; if (notifyConnected) { getInstance().notifyAccepted(); - if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) { + if (!oldControllerInfo.mediaButtonPreferences.equals( + newControllerInfo.mediaButtonPreferences)) { getInstance() .notifyControllerListener( listener -> { ignoreFuture( - listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout)); - listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout); + listener.onSetCustomLayout( + getInstance(), newControllerInfo.mediaButtonPreferences)); + listener.onCustomLayoutChanged( + getInstance(), newControllerInfo.mediaButtonPreferences); + listener.onMediaButtonPreferencesChanged( + getInstance(), newControllerInfo.mediaButtonPreferences); }); } return; @@ -1758,13 +1763,18 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; listener.onAvailableSessionCommandsChanged( getInstance(), newControllerInfo.availableSessionCommands)); } - if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) { + if (!oldControllerInfo.mediaButtonPreferences.equals( + newControllerInfo.mediaButtonPreferences)) { getInstance() .notifyControllerListener( listener -> { ignoreFuture( - listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout)); - listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout); + listener.onSetCustomLayout( + getInstance(), newControllerInfo.mediaButtonPreferences)); + listener.onCustomLayoutChanged( + getInstance(), newControllerInfo.mediaButtonPreferences); + listener.onMediaButtonPreferencesChanged( + getInstance(), newControllerInfo.mediaButtonPreferences); }); } if (newControllerInfo.sessionError != null) { @@ -1913,7 +1923,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; controllerInfo.playerInfo, controllerInfo.availableSessionCommands, controllerInfo.availablePlayerCommands, - controllerInfo.customLayout, + controllerInfo.mediaButtonPreferences, extras, /* sessionError= */ null); getInstance() @@ -1988,7 +1998,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; boolean shuffleModeEnabled; SessionCommands availableSessionCommands; Commands availablePlayerCommands; - ImmutableList customLayout; + ImmutableList mediaButtonPreferences; boolean isQueueChanged = oldLegacyPlayerInfo.queue != newLegacyPlayerInfo.queue; currentTimeline = @@ -2074,11 +2084,12 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; availableSessionCommands = LegacyConversions.convertToSessionCommands( newLegacyPlayerInfo.playbackStateCompat, isSessionReady); - customLayout = - LegacyConversions.convertToCustomLayout(newLegacyPlayerInfo.playbackStateCompat); + mediaButtonPreferences = + LegacyConversions.convertToMediaButtonPreferences( + newLegacyPlayerInfo.playbackStateCompat); } else { availableSessionCommands = oldControllerInfo.availableSessionCommands; - customLayout = oldControllerInfo.customLayout; + mediaButtonPreferences = oldControllerInfo.mediaButtonPreferences; } // Note: Sets the available player command here although it can be obtained before session is // ready. It's to follow the decision on MediaController to disallow any commands before @@ -2164,7 +2175,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; shuffleModeEnabled, availableSessionCommands, availablePlayerCommands, - customLayout, + mediaButtonPreferences, newLegacyPlayerInfo.sessionExtras, playerError, sessionError, @@ -2334,7 +2345,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; boolean shuffleModeEnabled, SessionCommands availableSessionCommands, Commands availablePlayerCommands, - ImmutableList customLayout, + ImmutableList mediaButtonPreferences, Bundle sessionExtras, @Nullable PlaybackException playerError, @Nullable SessionError sessionError, @@ -2411,7 +2422,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; playerInfo, availableSessionCommands, availablePlayerCommands, - customLayout, + mediaButtonPreferences, sessionExtras, sessionError); } @@ -2622,7 +2633,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; public final PlayerInfo playerInfo; public final SessionCommands availableSessionCommands; public final Commands availablePlayerCommands; - public final ImmutableList customLayout; + public final ImmutableList mediaButtonPreferences; public final Bundle sessionExtras; @Nullable public final SessionError sessionError; @@ -2630,7 +2641,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; playerInfo = PlayerInfo.DEFAULT.copyWithTimeline(QueueTimeline.DEFAULT); availableSessionCommands = SessionCommands.EMPTY; availablePlayerCommands = Commands.EMPTY; - customLayout = ImmutableList.of(); + mediaButtonPreferences = ImmutableList.of(); sessionExtras = Bundle.EMPTY; sessionError = null; } @@ -2639,13 +2650,13 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; PlayerInfo playerInfo, SessionCommands availableSessionCommands, Commands availablePlayerCommands, - ImmutableList customLayout, + ImmutableList mediaButtonPreferences, @Nullable Bundle sessionExtras, @Nullable SessionError sessionError) { this.playerInfo = playerInfo; this.availableSessionCommands = availableSessionCommands; this.availablePlayerCommands = availablePlayerCommands; - this.customLayout = customLayout; + this.mediaButtonPreferences = mediaButtonPreferences; this.sessionExtras = sessionExtras == null ? Bundle.EMPTY : sessionExtras; this.sessionError = sessionError; } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerStub.java index 57c6d99d48..babfea061f 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerStub.java @@ -30,6 +30,7 @@ import androidx.media3.common.util.BundleCollectionUtil; import androidx.media3.common.util.Log; import androidx.media3.session.MediaLibraryService.LibraryParams; import androidx.media3.session.PlayerInfo.BundlingExclusions; +import com.google.common.collect.ImmutableList; import java.lang.ref.WeakReference; import java.util.List; import org.checkerframework.checker.nullness.qual.NonNull; @@ -39,7 +40,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; private static final String TAG = "MediaControllerStub"; /** The version of the IMediaController interface. */ - public static final int VERSION_INT = 6; + public static final int VERSION_INT = 7; private final WeakReference controller; @@ -129,6 +130,30 @@ import org.checkerframework.checker.nullness.qual.NonNull; dispatchControllerTaskOnHandler(controller -> controller.onSetCustomLayout(seq, layout)); } + @Override + public void onSetMediaButtonPreferences(int seq, @Nullable List commandButtonBundleList) { + if (commandButtonBundleList == null) { + return; + } + ImmutableList mediaButtonPreferences; + try { + int sessionInterfaceVersion = getSessionInterfaceVersion(); + if (sessionInterfaceVersion == C.INDEX_UNSET) { + // Stale event. + return; + } + mediaButtonPreferences = + BundleCollectionUtil.fromBundleList( + bundle -> CommandButton.fromBundle(bundle, sessionInterfaceVersion), + commandButtonBundleList); + } catch (RuntimeException e) { + Log.w(TAG, "Ignoring malformed Bundle for CommandButton", e); + return; + } + dispatchControllerTaskOnHandler( + controller -> controller.onSetMediaButtonPreferences(seq, mediaButtonPreferences)); + } + @Override public void onAvailableCommandsChangedFromSession( int seq, @Nullable Bundle sessionCommandsBundle, @Nullable Bundle playerCommandsBundle) { 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 554ed2f859..67639fb12c 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java @@ -543,6 +543,10 @@ public abstract class MediaLibraryService extends MediaSessionService { /** * Sets the custom layout of the session. * + *

This method will be deprecated, prefer to use {@link #setMediaButtonPreferences}. Note + * that the media button preferences use {@link CommandButton#slots} to define the allowed + * button placement. + * *

The buttons are converted to custom actions in the legacy media session playback state * for legacy controllers (see {@code * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). When @@ -564,11 +568,42 @@ public abstract class MediaLibraryService extends MediaSessionService { * @return The builder to allow chaining. */ @UnstableApi + @CanIgnoreReturnValue @Override public Builder setCustomLayout(List customLayout) { return super.setCustomLayout(customLayout); } + /** + * Sets the media button preferences. + * + *

The button are converted to custom actions in the legacy media session playback state + * for legacy controllers (see {@code + * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). When + * converting, the {@linkplain SessionCommand#customExtras custom extras of the session + * command} is used for the extras of the legacy custom action. + * + *

Controllers that connect have the media button preferences of the session available with + * the initial connection result by default. Media button preferences specific to a controller + * can be set when the controller {@linkplain MediaSession.Callback#onConnect connects} by + * using an {@link ConnectionResult.AcceptedResultBuilder}. + * + *

Use {@code MediaSession.setMediaButtonPreferences(..)} to update the media button + * preferences during the life time of the session. + * + *

On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is set to + * {@code false} if the available commands of a controller do not allow to use a button. + * + * @param mediaButtonPreferences The ordered list of {@link CommandButton command buttons}. + * @return The builder to allow chaining. + */ + @CanIgnoreReturnValue + @UnstableApi + @Override + public Builder setMediaButtonPreferences(List mediaButtonPreferences) { + return super.setMediaButtonPreferences(mediaButtonPreferences); + } + /** * Sets whether a play button is shown if playback is {@linkplain * Player#getPlaybackSuppressionReason() suppressed}. @@ -660,6 +695,7 @@ public abstract class MediaLibraryService extends MediaSessionService { player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, callback, tokenExtras, @@ -677,6 +713,7 @@ public abstract class MediaLibraryService extends MediaSessionService { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, MediaSession.Callback callback, Bundle tokenExtras, @@ -691,6 +728,7 @@ public abstract class MediaLibraryService extends MediaSessionService { player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, callback, tokenExtras, @@ -708,6 +746,7 @@ public abstract class MediaLibraryService extends MediaSessionService { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, MediaSession.Callback callback, Bundle tokenExtras, @@ -723,6 +762,7 @@ public abstract class MediaLibraryService extends MediaSessionService { player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, (Callback) callback, tokenExtras, 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 db2b779995..e864a64bf3 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 mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, MediaLibrarySession.Callback callback, Bundle tokenExtras, @@ -90,6 +91,7 @@ import java.util.concurrent.Future; player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, callback, tokenExtras, diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java index 3ac4a3a514..847abd4400 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java @@ -27,7 +27,6 @@ import androidx.core.graphics.drawable.IconCompat; import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; import com.google.common.collect.ImmutableList; -import java.util.List; /** A notification for media playbacks. */ public final class MediaNotification { @@ -145,15 +144,15 @@ public final class MediaNotification { * @param mediaSession The media session. * @param actionFactory The {@link ActionFactory} for creating notification {@link * NotificationCompat.Action actions}. - * @param customLayout The custom layout {@linkplain MediaSession#setCustomLayout(List) set by - * the session}. + * @param mediaButtonPreferences The media button preferences {@linkplain + * MediaSession#setMediaButtonPreferences set by the session}. * @param onNotificationChangedCallback A callback that the provider needs to notify when the * notification has changed and needs to be posted again, for example after a bitmap has * been loaded asynchronously. */ MediaNotification createNotification( MediaSession mediaSession, - ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ActionFactory actionFactory, Callback onNotificationChangedCallback); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java index 791c748fbc..14f2406a97 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -159,9 +159,9 @@ import java.util.concurrent.TimeoutException; // Ignore. } } - ImmutableList customLayout = + ImmutableList mediaButtonPreferences = mediaNotificationController != null - ? mediaNotificationController.getCustomLayout() + ? mediaNotificationController.getMediaButtonPreferences() : ImmutableList.of(); MediaNotification.Provider.Callback callback = notification -> @@ -172,7 +172,7 @@ import java.util.concurrent.TimeoutException; () -> { MediaNotification mediaNotification = this.mediaNotificationProvider.createNotification( - session, customLayout, actionFactory, callback); + session, mediaButtonPreferences, actionFactory, callback); mainExecutor.execute( () -> updateNotificationInternal( @@ -320,7 +320,8 @@ import java.util.concurrent.TimeoutException; } @Override - public void onCustomLayoutChanged(MediaController controller, List layout) { + public void onMediaButtonPreferencesChanged( + MediaController controller, List mediaButtonPreferences) { mediaSessionService.onUpdateNotificationInternal( session, /* startInForegroundWhenPaused= */ false); } 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 6f5541d97b..c19bf17c8a 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -369,6 +369,10 @@ public class MediaSession { /** * Sets the custom layout of the session. * + *

This method will be deprecated, prefer to use {@link #setMediaButtonPreferences}. Note + * that the media button preferences use {@link CommandButton#slots} to define the allowed + * button placement. + * *

The button are converted to custom actions in the legacy media session playback state for * legacy controllers (see {@code * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). When @@ -389,12 +393,43 @@ public class MediaSession { * @param customLayout The ordered list of {@link CommandButton command buttons}. * @return The builder to allow chaining. */ + @CanIgnoreReturnValue @UnstableApi @Override public Builder setCustomLayout(List customLayout) { return super.setCustomLayout(customLayout); } + /** + * Sets the media button preferences. + * + *

The button are converted to custom actions in the legacy media session playback state for + * legacy controllers (see {@code + * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). When + * converting, the {@linkplain SessionCommand#customExtras custom extras of the session command} + * is used for the extras of the legacy custom action. + * + *

Controllers that connect have the media button preferences of the session available with + * the initial connection result by default. Media button preferences specific to a controller + * can be set when the controller {@linkplain MediaSession.Callback#onConnect connects} by using + * an {@link ConnectionResult.AcceptedResultBuilder}. + * + *

Use {@code MediaSession.setMediaButtonPreferences(..)} to update the media button + * preferences during the life time of the session. + * + *

On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is set to + * {@code false} if the available commands of a controller do not allow to use a button. + * + * @param mediaButtonPreferences The ordered list of {@link CommandButton command buttons}. + * @return The builder to allow chaining. + */ + @CanIgnoreReturnValue + @UnstableApi + @Override + public Builder setMediaButtonPreferences(List mediaButtonPreferences) { + return super.setMediaButtonPreferences(mediaButtonPreferences); + } + /** * Sets whether periodic position updates should be sent to controllers while playing. If false, * no periodic position updates are sent to controllers. @@ -455,6 +490,7 @@ public class MediaSession { player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, callback, tokenExtras, @@ -682,6 +718,7 @@ public class MediaSession { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, Callback callback, Bundle tokenExtras, @@ -703,6 +740,7 @@ public class MediaSession { player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, callback, tokenExtras, @@ -719,6 +757,7 @@ public class MediaSession { Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, Callback callback, Bundle tokenExtras, @@ -734,6 +773,7 @@ public class MediaSession { player, sessionActivity, customLayout, + mediaButtonPreferences, commandButtonsForMediaItems, callback, tokenExtras, @@ -912,12 +952,12 @@ public class MediaSession { * *

Use this controller info to set {@linkplain #setAvailableCommands(ControllerInfo, * SessionCommands, Player.Commands) available commands} and {@linkplain - * #setCustomLayout(ControllerInfo, List) custom layout} that are consistently applied to the - * media notification on all API levels. + * #setMediaButtonPreferences(ControllerInfo, List) media button preferences} that are + * consistently applied to the media notification on all API levels. * *

Available {@linkplain SessionCommands session commands} of the media notification controller - * are used to enable or disable buttons of the custom layout before it is passed to the - * {@linkplain MediaNotification.Provider#createNotification(MediaSession, ImmutableList, + * are used to enable or disable buttons of the media button preferences before they are passed to + * the {@linkplain MediaNotification.Provider#createNotification(MediaSession, ImmutableList, * MediaNotification.ActionFactory, MediaNotification.Provider.Callback) notification provider}. * Disabled command buttons are not converted to notification actions when using {@link * DefaultMediaNotificationProvider}. This affects the media notification displayed by System UI @@ -925,9 +965,9 @@ public class MediaSession { * *

The available session commands of the media notification controller are used to maintain * custom actions of the platform session (see {@code PlaybackStateCompat.getCustomActions()}). - * Command buttons of the custom layout are disabled or enabled according to the available session - * commands. Disabled command buttons are not converted to custom actions of the platform session. - * This affects the media notification displayed by System UI starting * with API 33. * @@ -969,7 +1009,11 @@ public class MediaSession { } /** - * Sets the custom layout for the given Media3 controller. + * Sets the custom layout for the given controller. + * + *

This method will be deprecated, prefer to use {@link + * #setMediaButtonPreferences(ControllerInfo, List)}. Note that the media button preferences use + * {@link CommandButton#slots} to define the allowed button placement. * *

Make sure to have the session commands of all command buttons of the custom layout * {@linkplain MediaController#getAvailableSessionCommands() available for controllers}. Include @@ -1003,7 +1047,11 @@ public class MediaSession { } /** - * Sets the custom layout that can initially be set when building the session. + * Sets the custom layout for all controllers. + * + *

This method will be deprecated, prefer to use {@link #setMediaButtonPreferences(List)}. Note + * that the media button preferences use {@link CommandButton#slots} to define the allowed button + * placement. * *

Calling this method broadcasts the custom layout to all connected Media3 controllers, * including the {@linkplain #getMediaNotificationControllerInfo() media notification controller}. @@ -1021,13 +1069,78 @@ public class MediaSession { * the controller {@linkplain MediaSession.Callback#onConnect connects} by using an {@link * ConnectionResult.AcceptedResultBuilder}. * - * @param layout The ordered list of {@link CommandButton}. + * @param layout The ordered list of {@linkplain CommandButton command buttons}. */ public final void setCustomLayout(List layout) { checkNotNull(layout, "layout must not be null"); impl.setCustomLayout(ImmutableList.copyOf(layout)); } + /** + * Sets the media button preferences for the given controller. + * + *

Make sure to have the session commands of all command buttons of the media button + * preferences {@linkplain MediaController#getAvailableSessionCommands() available for + * controllers}. Include the custom session commands a controller should be able to send in the + * available commands of the connection result {@linkplain + * MediaSession.Callback#onConnect(MediaSession, ControllerInfo) that your app returns when the + * controller connects}. The {@link CommandButton#isEnabled} flag is set according to the + * available commands of the controller and overrides a value that may have been set by the app. + * + *

On the controller side, {@link + * MediaController.Listener#onMediaButtonPreferencesChanged(MediaController, List)} is only called + * if the new media button preferences are different to the media button preferences the {@link + * MediaController#getMediaButtonPreferences() controller already has available}. Note that this + * comparison uses {@link CommandButton#equals} and therefore ignores {@link + * CommandButton#extras}. + * + *

On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is set to + * {@code false} if the available commands of the controller do not allow to use a button. + * + *

Interoperability: This call has no effect when called for a {@linkplain + * ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}. + * + * @param controller The controller for which to set the media button preferences. + * @param mediaButtonPreferences The ordered list of {@linkplain CommandButton command buttons}. + */ + @UnstableApi + @CanIgnoreReturnValue + public final ListenableFuture setMediaButtonPreferences( + ControllerInfo controller, List mediaButtonPreferences) { + checkNotNull(controller, "controller must not be null"); + checkNotNull(mediaButtonPreferences, "media button preferences must not be null"); + return impl.setMediaButtonPreferences(controller, ImmutableList.copyOf(mediaButtonPreferences)); + } + + /** + * Sets the media button preferences for all controllers. + * + *

Calling this method broadcasts the media button preferences to all connected Media3 + * controllers, including the {@linkplain #getMediaNotificationControllerInfo() media notification + * controller}. + * + *

On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is set to + * {@code false} if the available commands of a controller do not allow to use a button. + * + *

{@link MediaController.Listener#onMediaButtonPreferencesChanged(MediaController, List)} is + * only called if the new media button preferences are different to the media button preferences + * the {@linkplain MediaController#getMediaButtonPreferences() controller already has available}. + * Note that {@link Bundle extras} are ignored when comparing {@linkplain CommandButton command + * buttons}. + * + *

Controllers that connect after calling this method will have the new media button + * preferences available with the initial connection result. Media button preferences specific to + * a controller can be set when the controller {@linkplain MediaSession.Callback#onConnect + * connects} by using an {@link ConnectionResult.AcceptedResultBuilder}. + * + * @param mediaButtonPreferences The ordered list of {@linkplain CommandButton command buttons}. + */ + @UnstableApi + public final void setMediaButtonPreferences(List mediaButtonPreferences) { + checkNotNull(mediaButtonPreferences, "media button preferences must not be null"); + impl.setMediaButtonPreferences(ImmutableList.copyOf(mediaButtonPreferences)); + } + /** * Sets the new available commands for the controller. * @@ -1055,6 +1168,10 @@ public class MediaSession { /** * Returns the custom layout of the session. * + *

This method will be deprecated, prefer to use {@link #getMediaButtonPreferences()} instead. + * Note that the media button preferences use {@link CommandButton#slots} to define the allowed + * button placement. + * *

For informational purpose only. Mutations on the {@link Bundle} of either a {@link * CommandButton} or a {@link SessionCommand} do not have effect. To change the custom layout use * {@link #setCustomLayout(List)} or {@link #setCustomLayout(ControllerInfo, List)}. @@ -1064,6 +1181,18 @@ public class MediaSession { return impl.getCustomLayout(); } + /** + * Returns the media button preferences of the session. + * + *

For informational purpose only. Mutations on the {@link Bundle} of either a {@link + * CommandButton} or a {@link SessionCommand} do not have effect. To change the media button + * preferences use {@link #setMediaButtonPreferences}. + */ + @UnstableApi + public ImmutableList getMediaButtonPreferences() { + return impl.getMediaButtonPreferences(); + } + /** * Broadcasts a custom command to all connected controllers. * @@ -1313,7 +1442,8 @@ public class MediaSession { * *

If this callback is not overridden, it allows all controllers to connect that can access * the session. All session and player commands are made available and the {@linkplain - * MediaSession#getCustomLayout() custom layout of the session} is included. + * MediaSession#getMediaButtonPreferences() media button preferences of the session} are + * included. * *

Note that the player commands in {@link ConnectionResult#availablePlayerCommands} will be * intersected with the {@link Player#getAvailableCommands() available commands} of the @@ -1325,8 +1455,8 @@ public class MediaSession { * returned by {@link MediaController.Builder#buildAsync()}. * *

The controller isn't connected yet, so calls to the controller (e.g. {@link - * #sendCustomCommand}, {@link #setCustomLayout}) will be ignored. Use {@link #onPostConnect} - * for custom initialization of the controller instead. + * #sendCustomCommand}, {@link #setMediaButtonPreferences}) will be ignored. Use {@link + * #onPostConnect} for custom initialization of the controller instead. * *

Interoperability: If a legacy controller is connecting to the session then this callback * may block the main thread, even if it's called on a different application thread. If it's @@ -1346,8 +1476,8 @@ public class MediaSession { * controller. * *

Note that calls to the controller (e.g. {@link #sendCustomCommand}, {@link - * #setCustomLayout}) work here but don't work in {@link #onConnect} because the controller - * isn't connected yet in {@link #onConnect}. + * #setMediaButtonPreferences}) work here but don't work in {@link #onConnect} because the + * controller isn't connected yet in {@link #onConnect}. * * @param session The session for this event. * @param controller The {@linkplain ControllerInfo controller} information. @@ -1738,7 +1868,7 @@ public class MediaSession { /** * A result for {@link Callback#onConnect(MediaSession, ControllerInfo)} to denote the set of - * available commands and the custom layout for a {@link ControllerInfo controller}. + * available commands and the media button preferences for a {@link ControllerInfo controller}. */ public static final class ConnectionResult { @@ -1748,6 +1878,7 @@ public class MediaSession { private SessionCommands availableSessionCommands; private Player.Commands availablePlayerCommands = DEFAULT_PLAYER_COMMANDS; @Nullable private ImmutableList customLayout; + @Nullable private ImmutableList mediaButtonPreferences; @Nullable private Bundle sessionExtras; @Nullable private PendingIntent sessionActivity; @@ -1799,13 +1930,17 @@ public class MediaSession { * Sets the custom layout, overriding the {@linkplain MediaSession#getCustomLayout() custom * layout of the session}. * + *

This method will be deprecated, prefer to use {@link #setMediaButtonPreferences}. Note + * that the media button preferences use {@link CommandButton#slots} to define the allowed + * button placement. + * *

The default is null to indicate that the custom layout of the session should be used. * *

Make sure to have the session commands of all command buttons of the custom layout - * included in the {@linkplain #setAvailableSessionCommands(SessionCommands)} available - * session commands} On the controller side, the {@linkplain CommandButton#isEnabled enabled} - * flag is set to {@code false} if the available commands of the controller do not allow to - * use a button. + * included in the {@linkplain #setAvailableSessionCommands(SessionCommands) available session + * commands}. On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is + * set to {@code false} if the available commands of the controller do not allow to use a + * button. */ @CanIgnoreReturnValue public AcceptedResultBuilder setCustomLayout(@Nullable List customLayout) { @@ -1813,6 +1948,27 @@ public class MediaSession { return this; } + /** + * Sets the media button preferences, overriding the {@linkplain + * MediaSession#getMediaButtonPreferences() media button preferences of the session}. + * + *

The default is null to indicate that the media button preferences of the session should + * be used. + * + *

Make sure to have the session commands of all command buttons of the media button + * preferences included in the {@linkplain #setAvailableSessionCommands(SessionCommands) + * available session commands}. On the controller side, the {@linkplain + * CommandButton#isEnabled enabled} flag is set to {@code false} if the available commands of + * the controller do not allow to use a button. + */ + @CanIgnoreReturnValue + public AcceptedResultBuilder setMediaButtonPreferences( + @Nullable List mediaButtonPreferences) { + this.mediaButtonPreferences = + mediaButtonPreferences == null ? null : ImmutableList.copyOf(mediaButtonPreferences); + return this; + } + /** * Sets the session extras, overriding the {@linkplain MediaSession#getSessionExtras() extras * of the session}. @@ -1844,6 +2000,7 @@ public class MediaSession { availableSessionCommands, availablePlayerCommands, customLayout, + mediaButtonPreferences, sessionExtras, sessionActivity); } @@ -1873,6 +2030,12 @@ public class MediaSession { /** The custom layout or null if the custom layout of the session should be used. */ @UnstableApi @Nullable public final ImmutableList customLayout; + /** + * The media button preferences or null if the media button preferences of the session should be + * used. + */ + @UnstableApi @Nullable public final ImmutableList mediaButtonPreferences; + /** The session extras. */ @UnstableApi @Nullable public final Bundle sessionExtras; @@ -1885,12 +2048,14 @@ public class MediaSession { SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands, @Nullable ImmutableList customLayout, + @Nullable ImmutableList mediaButtonPreferences, @Nullable Bundle sessionExtras, @Nullable PendingIntent sessionActivity) { isAccepted = accepted; this.availableSessionCommands = availableSessionCommands; this.availablePlayerCommands = availablePlayerCommands; this.customLayout = customLayout; + this.mediaButtonPreferences = mediaButtonPreferences; this.sessionExtras = sessionExtras; this.sessionActivity = sessionActivity; } @@ -1900,8 +2065,8 @@ public class MediaSession { * *

Commands are specific to the controller receiving this connection result. * - *

The controller receives {@linkplain MediaSession#getCustomLayout() the custom layout of - * the session}. + *

The controller receives {@linkplain MediaSession#getMediaButtonPreferences() the media + * button preferences of the session}. * *

See {@link AcceptedResultBuilder} for a more flexible way to accept a connection. */ @@ -1912,6 +2077,7 @@ public class MediaSession { availableSessionCommands, availablePlayerCommands, /* customLayout= */ null, + /* mediaButtonPreferences= */ null, /* sessionExtras= */ null, /* sessionActivity= */ null); } @@ -1923,6 +2089,7 @@ public class MediaSession { SessionCommands.EMPTY, Player.Commands.EMPTY, /* customLayout= */ ImmutableList.of(), + /* mediaButtonPreferences= */ ImmutableList.of(), /* sessionExtras= */ Bundle.EMPTY, /* sessionActivity= */ null); } @@ -1943,8 +2110,7 @@ public class MediaSession { PlayerInfo playerInfo, Player.Commands availableCommands, boolean excludeTimeline, - boolean excludeTracks, - int controllerInterfaceVersion) + boolean excludeTracks) throws RemoteException {} default void onPeriodicSessionPositionInfoChanged( @@ -1961,6 +2127,9 @@ public class MediaSession { default void setCustomLayout(int seq, List layout) throws RemoteException {} + default void setMediaButtonPreferences(int seq, List mediaButtonPreferences) + throws RemoteException {} + default void onSessionActivityChanged(int seq, PendingIntent sessionActivity) throws RemoteException {} @@ -2105,6 +2274,7 @@ public class MediaSession { /* package */ @MonotonicNonNull BitmapLoader bitmapLoader; /* package */ boolean playIfSuppressed; /* package */ ImmutableList customLayout; + /* package */ ImmutableList mediaButtonPreferences; /* package */ ImmutableList commandButtonsForMediaItems; /* package */ boolean isPeriodicPositionUpdateEnabled; @@ -2117,6 +2287,7 @@ public class MediaSession { tokenExtras = Bundle.EMPTY; sessionExtras = Bundle.EMPTY; customLayout = ImmutableList.of(); + mediaButtonPreferences = ImmutableList.of(); playIfSuppressed = true; isPeriodicPositionUpdateEnabled = true; commandButtonsForMediaItems = ImmutableList.of(); @@ -2174,6 +2345,13 @@ public class MediaSession { return (BuilderT) this; } + @CanIgnoreReturnValue + @SuppressWarnings("unchecked") + public BuilderT setMediaButtonPreferences(List mediaButtonPreferences) { + this.mediaButtonPreferences = ImmutableList.copyOf(mediaButtonPreferences); + return (BuilderT) this; + } + @CanIgnoreReturnValue @SuppressWarnings("unchecked") public BuilderT setShowPlayButtonIfPlaybackIsSuppressed( 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 9f9c0e045b..b3127736c7 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -152,6 +152,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private long sessionPositionUpdateDelayMs; private boolean isMediaNotificationControllerConnected; private ImmutableList customLayout; + private ImmutableList mediaButtonPreferences; private Bundle sessionExtras; @SuppressWarnings("argument.type.incompatible") // Using this in System.identityHashCode @@ -162,6 +163,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; Player player, @Nullable PendingIntent sessionActivity, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, ImmutableList commandButtonsForMediaItems, MediaSession.Callback callback, Bundle tokenExtras, @@ -183,6 +185,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; sessionId = id; this.sessionActivity = sessionActivity; this.customLayout = customLayout; + this.mediaButtonPreferences = mediaButtonPreferences; this.commandButtonsForMediaItems = commandButtonsForMediaItems; this.callback = callback; this.sessionExtras = sessionExtras; @@ -246,6 +249,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; player, playIfSuppressed, customLayout, + mediaButtonPreferences, connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands, sessionExtras); @@ -272,6 +276,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; player, playIfSuppressed, playerWrapper.getCustomLayout(), + playerWrapper.getMediaButtonPreferences(), playerWrapper.getAvailableSessionCommands(), playerWrapper.getAvailablePlayerCommands(), playerWrapper.getLegacyExtras())); @@ -511,11 +516,45 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; (controller, seq) -> controller.setCustomLayout(seq, customLayout)); } + /** + * Sets the media button preferences for the given {@link MediaController}. + * + * @param controller The controller. + * @param mediaButtonPreferences The media button preferences. + * @return The session result from the controller. + */ + public ListenableFuture setMediaButtonPreferences( + ControllerInfo controller, ImmutableList mediaButtonPreferences) { + if (isMediaNotificationController(controller)) { + playerWrapper.setMediaButtonPreferences(mediaButtonPreferences); + sessionLegacyStub.updateLegacySessionPlaybackState(playerWrapper); + } + return dispatchRemoteControllerTask( + controller, + (controller1, seq) -> controller1.setMediaButtonPreferences(seq, mediaButtonPreferences)); + } + + /** + * Sets the media button preferences of the session and sends the media button preferences to all + * controllers. + */ + public void setMediaButtonPreferences(ImmutableList mediaButtonPreferences) { + this.mediaButtonPreferences = mediaButtonPreferences; + playerWrapper.setMediaButtonPreferences(mediaButtonPreferences); + dispatchRemoteControllerTaskWithoutReturn( + (controller, seq) -> controller.setMediaButtonPreferences(seq, mediaButtonPreferences)); + } + /** Returns the custom layout. */ public ImmutableList getCustomLayout() { return customLayout; } + /** Returns the media button preferences. */ + public ImmutableList getMediaButtonPreferences() { + return mediaButtonPreferences; + } + /** Returns the command buttons for media items. */ public ImmutableList getCommandButtonsForMediaItems() { return commandButtonsForMediaItems; @@ -612,12 +651,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; getPlayerWrapper().getAvailableCommands()); checkStateNotNull(controller.getControllerCb()) .onPlayerInfoChanged( - seq, - playerInfo, - intersectedCommands, - excludeTimeline, - excludeTracks, - controller.getInterfaceVersion()); + seq, playerInfo, intersectedCommands, excludeTimeline, excludeTracks); } catch (DeadObjectException e) { onDeadObjectException(controller); } catch (RemoteException e) { @@ -679,6 +713,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .setAvailableSessionCommands(playerWrapper.getAvailableSessionCommands()) .setAvailablePlayerCommands(playerWrapper.getAvailablePlayerCommands()) .setCustomLayout(playerWrapper.getCustomLayout()) + .setMediaButtonPreferences(playerWrapper.getMediaButtonPreferences()) .build(); } MediaSession.ConnectionResult connectionResult = @@ -691,6 +726,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; connectionResult.customLayout != null ? connectionResult.customLayout : instance.getCustomLayout()); + playerWrapper.setMediaButtonPreferences( + connectionResult.mediaButtonPreferences != null + ? connectionResult.mediaButtonPreferences + : instance.getMediaButtonPreferences()); setAvailableFrameworkControllerCommands( connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands); } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 1019e04351..662b0cb36b 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -1115,6 +1115,11 @@ import org.checkerframework.checker.initialization.qual.Initialized; updateLegacySessionPlaybackState(sessionImpl.getPlayerWrapper()); } + @Override + public void setMediaButtonPreferences(int seq, List mediaButtonPreferences) { + updateLegacySessionPlaybackState(sessionImpl.getPlayerWrapper()); + } + @Override public void onSessionExtrasChanged(int seq, Bundle sessionExtras) { sessionCompat.setExtras(sessionExtras); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java index 5bdaebabe5..6756aa730e 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -761,7 +761,8 @@ public abstract class MediaSessionService extends Service { request.libraryVersion, request.controllerInterfaceVersion, isTrusted, - new MediaSessionStub.Controller2Cb(caller), + new MediaSessionStub.Controller2Cb( + caller, request.controllerInterfaceVersion), request.connectionHints, request.maxCommandsForMediaItems); 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 aa710084c1..7d3ab92733 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,9 @@ import java.util.concurrent.ExecutionException; connectionResult.customLayout != null ? connectionResult.customLayout : sessionImpl.getCustomLayout(), + connectionResult.mediaButtonPreferences != null + ? connectionResult.mediaButtonPreferences + : sessionImpl.getMediaButtonPreferences(), sessionImpl.getCommandButtonsForMediaItems(), connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands, @@ -636,7 +639,7 @@ import java.util.concurrent.ExecutionException; request.libraryVersion, request.controllerInterfaceVersion, sessionManager.isTrustedForMediaControl(remoteUserInfo), - new MediaSessionStub.Controller2Cb(caller), + new MediaSessionStub.Controller2Cb(caller, request.controllerInterfaceVersion), request.connectionHints, request.maxCommandsForMediaItems); connect(caller, controllerInfo); @@ -2006,9 +2009,11 @@ import java.util.concurrent.ExecutionException; /* package */ static final class Controller2Cb implements ControllerCb { private final IMediaController iController; + private final int controllerInterfaceVersion; - public Controller2Cb(IMediaController callback) { - iController = callback; + public Controller2Cb(IMediaController callback, int controllerInterfaceVersion) { + this.iController = callback; + this.controllerInterfaceVersion = controllerInterfaceVersion; } public IBinder getCallbackBinder() { @@ -2032,8 +2037,7 @@ import java.util.concurrent.ExecutionException; PlayerInfo playerInfo, Player.Commands availableCommands, boolean excludeTimeline, - boolean excludeTracks, - int controllerInterfaceVersion) + boolean excludeTracks) throws RemoteException { Assertions.checkState(controllerInterfaceVersion != 0); // The bundling exclusions merge the performance overrides with the available commands. @@ -2072,6 +2076,22 @@ import java.util.concurrent.ExecutionException; sequenceNumber, BundleCollectionUtil.toBundleList(layout, CommandButton::toBundle)); } + @Override + public void setMediaButtonPreferences( + int sequenceNumber, List mediaButtonPreferences) throws RemoteException { + if (controllerInterfaceVersion >= 7) { + iController.onSetMediaButtonPreferences( + sequenceNumber, + BundleCollectionUtil.toBundleList(mediaButtonPreferences, CommandButton::toBundle)); + } else { + // Controller doesn't support media button preferences, send the list as a custom layout. + // TODO: b/332877990 - More accurately reflect media button preferences as custom layout. + iController.onSetCustomLayout( + sequenceNumber, + BundleCollectionUtil.toBundleList(mediaButtonPreferences, CommandButton::toBundle)); + } + } + @Override public void onSessionActivityChanged(int sequenceNumber, PendingIntent sessionActivity) throws RemoteException { diff --git a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java index 71f0ad9cd4..c56f8f7971 100644 --- a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java +++ b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java @@ -87,6 +87,7 @@ import java.util.List; @Nullable private LegacyError legacyError; @Nullable private Bundle legacyExtras; private ImmutableList customLayout; + private ImmutableList mediaButtonPreferences; private SessionCommands availableSessionCommands; private Commands availablePlayerCommands; @@ -94,12 +95,14 @@ import java.util.List; Player player, boolean playIfSuppressed, ImmutableList customLayout, + ImmutableList mediaButtonPreferences, SessionCommands availableSessionCommands, Commands availablePlayerCommands, @Nullable Bundle legacyExtras) { super(player); this.playIfSuppressed = playIfSuppressed; this.customLayout = customLayout; + this.mediaButtonPreferences = mediaButtonPreferences; this.availableSessionCommands = availableSessionCommands; this.availablePlayerCommands = availablePlayerCommands; this.legacyExtras = legacyExtras; @@ -123,10 +126,18 @@ import java.util.List; this.customLayout = customLayout; } + public void setMediaButtonPreferences(ImmutableList mediaButtonPreferences) { + this.mediaButtonPreferences = mediaButtonPreferences; + } + /* package */ ImmutableList getCustomLayout() { return customLayout; } + /* package */ ImmutableList getMediaButtonPreferences() { + return mediaButtonPreferences; + } + public void setLegacyExtras(@Nullable Bundle extras) { if (extras != null) { checkArgument(!extras.containsKey(EXTRAS_KEY_PLAYBACK_SPEED_COMPAT)); @@ -1061,8 +1072,11 @@ import java.util.List; .setBufferedPosition(compatBufferedPosition) .setExtras(extras); - for (int i = 0; i < customLayout.size(); i++) { - CommandButton commandButton = customLayout.get(i); + // TODO: b/332877990 - More accurately reflect media button preferences as custom actions. + List buttonsForCustomActions = + mediaButtonPreferences.isEmpty() ? customLayout : mediaButtonPreferences; + for (int i = 0; i < buttonsForCustomActions.size(); i++) { + CommandButton commandButton = buttonsForCustomActions.get(i); SessionCommand sessionCommand = commandButton.sessionCommand; if (sessionCommand != null && commandButton.isEnabled diff --git a/libraries/session/src/test/java/androidx/media3/session/ConnectionStateTest.java b/libraries/session/src/test/java/androidx/media3/session/ConnectionStateTest.java new file mode 100644 index 0000000000..29fa2ed207 --- /dev/null +++ b/libraries/session/src/test/java/androidx/media3/session/ConnectionStateTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.session; + +import static com.google.common.truth.Truth.assertThat; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import androidx.media3.common.MediaLibraryInfo; +import androidx.media3.common.Player; +import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ConnectionState}. */ +@RunWith(AndroidJUnit4.class) +public class ConnectionStateTest { + + @Test + public void roundTripViaBundle_restoresEqualInstance() { + Context context = ApplicationProvider.getApplicationContext(); + Player player = new TestExoPlayerBuilder(context).build(); + MediaSession session = new MediaSession.Builder(context, player).build(); + Bundle tokenExtras = new Bundle(); + tokenExtras.putString("key", "token"); + Bundle sessionExtras = new Bundle(); + sessionExtras.putString("key", "session"); + ConnectionState connectionState = + new ConnectionState( + MediaLibraryInfo.VERSION_INT, + MediaSessionStub.VERSION_INT, + new MediaSessionStub(session.getImpl()), + /* sessionActivity= */ PendingIntent.getActivity( + context, /* requestCode= */ 0, new Intent(), /* flags= */ 0), + /* customLayout= */ ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_ARTIST) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .build()), + /* mediaButtonPreferences= */ ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_HEART_FILLED) + .setPlayerCommand(Player.COMMAND_PREPARE) + .build()), + /* commandButtonsForMediaItems= */ ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_NEXT) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .build()), + new SessionCommands.Builder().add(new SessionCommand("action", Bundle.EMPTY)).build(), + /* playerCommandsFromSession= */ new Player.Commands.Builder() + .add(Player.COMMAND_GET_AUDIO_ATTRIBUTES) + .build(), + /* playerCommandsFromPlayer= */ new Player.Commands.Builder() + .add(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build(), + tokenExtras, + sessionExtras, + PlayerInfo.DEFAULT.copyWithIsPlaying(true), + session.getPlatformToken()); + + ConnectionState restoredConnectionState = + ConnectionState.fromBundle( + connectionState.toBundleForRemoteProcess(MediaControllerStub.VERSION_INT)); + session.release(); + player.release(); + + assertThat(restoredConnectionState.libraryVersion).isEqualTo(connectionState.libraryVersion); + assertThat(restoredConnectionState.sessionInterfaceVersion) + .isEqualTo(connectionState.sessionInterfaceVersion); + assertThat(restoredConnectionState.sessionActivity).isEqualTo(connectionState.sessionActivity); + assertThat(restoredConnectionState.sessionBinder).isEqualTo(connectionState.sessionBinder); + assertThat(restoredConnectionState.customLayout).isEqualTo(connectionState.customLayout); + assertThat(restoredConnectionState.mediaButtonPreferences) + .isEqualTo(connectionState.mediaButtonPreferences); + assertThat(restoredConnectionState.commandButtonsForMediaItems) + .isEqualTo(connectionState.commandButtonsForMediaItems); + assertThat(restoredConnectionState.sessionCommands).isEqualTo(connectionState.sessionCommands); + assertThat(restoredConnectionState.playerCommandsFromSession) + .isEqualTo(connectionState.playerCommandsFromSession); + assertThat(restoredConnectionState.playerCommandsFromPlayer) + .isEqualTo(connectionState.playerCommandsFromPlayer); + assertThat(restoredConnectionState.tokenExtras.getString("key")).isEqualTo("token"); + assertThat(restoredConnectionState.sessionExtras.getString("key")).isEqualTo("session"); + assertThat(restoredConnectionState.playerInfo.isPlaying).isTrue(); + assertThat(restoredConnectionState.platformToken).isEqualTo(connectionState.platformToken); + } + + @Test + public void + roundTripViaBundle_controllerVersion6OrLower_usesMediaButtonPreferencesAsCustomLayout() { + Context context = ApplicationProvider.getApplicationContext(); + Player player = new TestExoPlayerBuilder(context).build(); + MediaSession session = new MediaSession.Builder(context, player).build(); + ConnectionState connectionState = + new ConnectionState( + MediaLibraryInfo.VERSION_INT, + MediaSessionStub.VERSION_INT, + new MediaSessionStub(session.getImpl()), + /* sessionActivity= */ null, + /* customLayout= */ ImmutableList.of(), + /* mediaButtonPreferences= */ ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_HEART_FILLED) + .setPlayerCommand(Player.COMMAND_PREPARE) + .build()), + /* commandButtonsForMediaItems= */ ImmutableList.of(), + SessionCommands.EMPTY, + /* playerCommandsFromSession= */ Player.Commands.EMPTY, + /* playerCommandsFromPlayer= */ Player.Commands.EMPTY, + /* tokenExtras= */ Bundle.EMPTY, + /* sessionExtras= */ Bundle.EMPTY, + PlayerInfo.DEFAULT, + session.getPlatformToken()); + + ConnectionState restoredConnectionState = + ConnectionState.fromBundle( + connectionState.toBundleForRemoteProcess(/* controllerInterfaceVersion= */ 6)); + session.release(); + player.release(); + + assertThat(restoredConnectionState.customLayout) + .isEqualTo(connectionState.mediaButtonPreferences); + assertThat(restoredConnectionState.mediaButtonPreferences).isEmpty(); + } +} diff --git a/libraries/session/src/test/java/androidx/media3/session/LegacyConversionsTest.java b/libraries/session/src/test/java/androidx/media3/session/LegacyConversionsTest.java index 2fca50659b..2bcacfb63d 100644 --- a/libraries/session/src/test/java/androidx/media3/session/LegacyConversionsTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/LegacyConversionsTest.java @@ -1080,12 +1080,12 @@ public final class LegacyConversionsTest { } @Test - public void convertToCustomLayout_withNull_returnsEmptyList() { - assertThat(LegacyConversions.convertToCustomLayout(null)).isEmpty(); + public void convertToMediaButtonPreferences_withNull_returnsEmptyList() { + assertThat(LegacyConversions.convertToMediaButtonPreferences(null)).isEmpty(); } @Test - public void convertToCustomLayout_withoutIconConstantInExtras() { + public void convertToMediaButtonPreferences_withoutIconConstantInExtras() { String extraKey = "key"; String extraValue = "value"; String actionStr = "action"; @@ -1107,7 +1107,7 @@ public final class LegacyConversionsTest { .addCustomAction(action) .build(); - ImmutableList buttons = LegacyConversions.convertToCustomLayout(state); + ImmutableList buttons = LegacyConversions.convertToMediaButtonPreferences(state); assertThat(buttons).hasSize(1); CommandButton button = buttons.get(0); @@ -1120,7 +1120,7 @@ public final class LegacyConversionsTest { } @Test - public void convertToCustomLayout_withIconConstantInExtras() { + public void convertToMediaButtonPreferences_withIconConstantInExtras() { String actionStr = "action"; String displayName = "display_name"; int iconRes = 21; @@ -1140,7 +1140,7 @@ public final class LegacyConversionsTest { .addCustomAction(action) .build(); - ImmutableList buttons = LegacyConversions.convertToCustomLayout(state); + ImmutableList buttons = LegacyConversions.convertToMediaButtonPreferences(state); assertThat(buttons).hasSize(1); CommandButton button = buttons.get(0); diff --git a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java index 7ca5925bd9..9eaaa94ecd 100644 --- a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java @@ -259,6 +259,112 @@ public class MediaSessionServiceTest { serviceController.destroy(); } + @Test + public void mediaNotificationController_setMediaButtonPreferences_correctNotificationActions() + throws TimeoutException { + SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY); + SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY); + SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY); + SessionCommand command4 = new SessionCommand("command4", Bundle.EMPTY); + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("customAction1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command1) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("customAction2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command2) + .build(); + CommandButton button3 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("customAction3") + .setEnabled(false) + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command3) + .build(); + CommandButton button4 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("customAction4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command4) + .build(); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + MediaSession session = + new MediaSession.Builder(context, player) + .setMediaButtonPreferences(ImmutableList.of(button1, button2, button3, button4)) + .setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + if (session.isMediaNotificationController(controller)) { + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(command1) + .add(command2) + .add(command3) + .build()) + .build(); + } + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session).build(); + } + }) + .build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); + TestService service = serviceController.create().get(); + service.setMediaNotificationProvider( + new DefaultMediaNotificationProvider( + service, + mediaSession -> 2000, + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID, + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID)); + service.addSession(session); + // Play media to create a notification. + player.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset:///media/mp4/sample.mp4"), + MediaItem.fromUri("asset:///media/mp4/sample.mp4"))); + player.prepare(); + player.play(); + runMainLooperUntil(() -> notificationManager.getActiveNotifications().length == 1); + + StatusBarNotification mediaNotification = getStatusBarNotification(2000); + + assertThat(mediaNotification.getNotification().actions).hasLength(5); + assertThat(mediaNotification.getNotification().actions[0].title.toString()) + .isEqualTo("Seek to previous item"); + assertThat(mediaNotification.getNotification().actions[1].title.toString()).isEqualTo("Pause"); + assertThat(mediaNotification.getNotification().actions[2].title.toString()) + .isEqualTo("Seek to next item"); + assertThat(mediaNotification.getNotification().actions[3].title.toString()) + .isEqualTo("customAction1"); + assertThat(mediaNotification.getNotification().actions[4].title.toString()) + .isEqualTo("customAction2"); + + player.pause(); + session.setMediaButtonPreferences( + session.getMediaNotificationControllerInfo(), ImmutableList.of(button2)); + ShadowLooper.idleMainLooper(); + mediaNotification = getStatusBarNotification(2000); + + assertThat(mediaNotification.getNotification().actions).hasLength(4); + assertThat(mediaNotification.getNotification().actions[0].title.toString()) + .isEqualTo("Seek to previous item"); + assertThat(mediaNotification.getNotification().actions[1].title.toString()).isEqualTo("Play"); + assertThat(mediaNotification.getNotification().actions[2].title.toString()) + .isEqualTo("Seek to next item"); + assertThat(mediaNotification.getNotification().actions[3].title.toString()) + .isEqualTo("customAction2"); + session.release(); + player.release(); + serviceController.destroy(); + } + @Test public void mediaNotificationController_setAvailableCommands_correctNotificationActions() throws TimeoutException { @@ -281,7 +387,7 @@ public class MediaSessionServiceTest { MediaSession session = new MediaSession.Builder(context, player) .setId("1") - .setCustomLayout(ImmutableList.of(button1, button2)) + .setMediaButtonPreferences(ImmutableList.of(button1, button2)) .setCallback( new MediaSession.Callback() { @Override diff --git a/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java b/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java index 9790fdf7d7..e0016dc76c 100644 --- a/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java @@ -47,7 +47,8 @@ public class PlayerWrapperTest { new PlayerWrapper( player, /* playIfSuppressed= */ true, - ImmutableList.of(), + /* customLayout= */ ImmutableList.of(), + /* mediaButtonPreferences= */ ImmutableList.of(), SessionCommands.EMPTY, Player.Commands.EMPTY, /* legacyExtras= */ null); diff --git a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl index d74b940323..ca42a686e2 100644 --- a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl +++ b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl @@ -31,6 +31,7 @@ interface IRemoteMediaController { Bundle getConnectedSessionToken(String controllerId); Bundle getSessionExtras(String controllerId); Bundle getCustomLayout(String controllerId); + Bundle getMediaButtonPreferences(String controllerId); Bundle getAvailableCommands(String controllerId); PendingIntent getSessionActivity(String controllerId); void play(String controllerId); diff --git a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaSession.aidl b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaSession.aidl index 115e19e91a..437d53c709 100644 --- a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaSession.aidl +++ b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaSession.aidl @@ -32,6 +32,7 @@ interface IRemoteMediaSession { void release(String sessionId); void setAvailableCommands(String sessionId, in Bundle sessionCommands, in Bundle playerCommands); void setCustomLayout(String sessionId, in List layout); + void setMediaButtonPreferences(String sessionId, in List mediaButtonPreferences); void setSessionExtras(String sessionId, in Bundle extras); void setSessionExtrasForController(String sessionId, in String controllerKey, in Bundle extras); void sendError(String sessionId, String controllerKey, in Bundle SessionError); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java index 9282076b89..6b007ba218 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java @@ -1461,7 +1461,11 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest .build(); } }; - MediaSession mediaSession = createMediaSession(player, callback, customLayout); + MediaSession mediaSession = + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) + .setCallback(callback) + .setCustomLayout(customLayout) + .build(); connectMediaNotificationController(mediaSession); MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); @@ -1621,6 +1625,212 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest releasePlayer(player); } + @Test + public void + playerWithMediaButtonPreferences_sessionBuiltWithMediaButtonPreferences_customActionsInInitialPlaybackState() + throws Exception { + Player player = createDefaultPlayer(); + Bundle extras1 = new Bundle(); + extras1.putString("key1", "value1"); + SessionCommand command1 = new SessionCommand("command1", extras1); + SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY); + SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY); + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("button1") + .setSessionCommand(command1) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button2") + .setSessionCommand(command2) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button3") + .setEnabled(false) + .setSessionCommand(command3) + .build()); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(command1) + .add(command3) + .build()) + .build(); + } + }; + MediaSession mediaSession = + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) + .setCallback(callback) + .setMediaButtonPreferences(mediaButtonPreferences) + .build(); + connectMediaNotificationController(mediaSession); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat(controllerCompat.getPlaybackState().getCustomActions()).hasSize(1); + PlaybackStateCompat.CustomAction customAction = + controllerCompat.getPlaybackState().getCustomActions().get(0); + assertThat(customAction.getAction()).isEqualTo("command1"); + assertThat(customAction.getName().toString()).isEqualTo("button1"); + assertThat(customAction.getIcon()).isEqualTo(R.drawable.media3_icon_play); + assertThat(customAction.getExtras().get("key1")).isEqualTo("value1"); + assertThat(customAction.getExtras().get(MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT)) + .isEqualTo(CommandButton.ICON_PLAY); + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithMediaButtonPreferences_setMediaButtonPreferences_playbackStateChangedWithCustomActionsChanged() + throws Exception { + Player player = createDefaultPlayer(); + Bundle extras1 = new Bundle(); + extras1.putString("key1", "value1"); + Bundle extras2 = new Bundle(); + extras1.putString("key2", "value2"); + SessionCommand command1 = new SessionCommand("command1", extras1); + SessionCommand command2 = new SessionCommand("command2", extras2); + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("button1") + .setSessionCommand(command1) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button2") + .setSessionCommand(command2) + .build()); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build()) + .build(); + } + }; + MediaSession mediaSession = createMediaSession(player, callback); + connectMediaNotificationController(mediaSession); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + List initialCustomActions = + controllerCompat.getPlaybackState().getCustomActions(); + AtomicReference> reportedCustomActions = + new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + controllerCompat.registerCallback( + new MediaControllerCompat.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + reportedCustomActions.set(state.getCustomActions()); + latch.countDown(); + } + }, + threadTestRule.getHandler()); + + getInstrumentation() + .runOnMainSync(() -> mediaSession.setMediaButtonPreferences(mediaButtonPreferences)); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialCustomActions).isEmpty(); + assertThat(reportedCustomActions.get()).hasSize(1); + assertThat(reportedCustomActions.get().get(0).getAction()).isEqualTo("command1"); + assertThat(reportedCustomActions.get().get(0).getName().toString()).isEqualTo("button1"); + assertThat(reportedCustomActions.get().get(0).getIcon()).isEqualTo(R.drawable.media3_icon_play); + assertThat(reportedCustomActions.get().get(0).getExtras().get("key1")).isEqualTo("value1"); + assertThat( + reportedCustomActions + .get() + .get(0) + .getExtras() + .get(MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT)) + .isEqualTo(CommandButton.ICON_PLAY); + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithMediaButtonPreferences_setMediaButtonPreferencesForMediaNotificationController_playbackStateChangedWithCustomActionsChanged() + throws Exception { + Player player = createDefaultPlayer(); + Bundle extras1 = new Bundle(); + extras1.putString("key1", "value1"); + Bundle extras2 = new Bundle(); + extras1.putString("key2", "value2"); + SessionCommand command1 = new SessionCommand("command1", extras1); + SessionCommand command2 = new SessionCommand("command2", extras2); + ImmutableList mediaButtonPreferences = + ImmutableList.of( + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("button1") + .setSessionCommand(command1) + .build(), + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button2") + .setSessionCommand(command2) + .build()); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build()) + .build(); + } + }; + MediaSession mediaSession = createMediaSession(player, callback); + connectMediaNotificationController(mediaSession); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + List initialCustomActions = + controllerCompat.getPlaybackState().getCustomActions(); + AtomicReference> reportedCustomActions = + new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + controllerCompat.registerCallback( + new MediaControllerCompat.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + reportedCustomActions.set(state.getCustomActions()); + latch.countDown(); + } + }, + threadTestRule.getHandler()); + + getInstrumentation() + .runOnMainSync( + () -> + mediaSession.setMediaButtonPreferences( + mediaSession.getMediaNotificationControllerInfo(), mediaButtonPreferences)); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialCustomActions).isEmpty(); + assertThat(reportedCustomActions.get()).hasSize(1); + assertThat(reportedCustomActions.get().get(0).getAction()).isEqualTo("command1"); + assertThat(reportedCustomActions.get().get(0).getName().toString()).isEqualTo("button1"); + assertThat(reportedCustomActions.get().get(0).getIcon()).isEqualTo(R.drawable.media3_icon_play); + assertThat(reportedCustomActions.get().get(0).getExtras().get("key1")).isEqualTo("value1"); + assertThat( + reportedCustomActions + .get() + .get(0) + .getExtras() + .get(MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT)) + .isEqualTo(CommandButton.ICON_PLAY); + mediaSession.release(); + releasePlayer(player); + } + /** * Connect a controller that mimics the media notification controller that is connected by {@link * MediaNotificationManager} when the session is running in the service. @@ -1671,14 +1881,8 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest private static MediaSession createMediaSession( Player player, @Nullable MediaSession.Callback callback) { - return createMediaSession(player, callback, /* customLayout= */ ImmutableList.of()); - } - - private static MediaSession createMediaSession( - Player player, @Nullable MediaSession.Callback callback, List customLayout) { MediaSession.Builder session = - new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) - .setCustomLayout(customLayout); + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player); if (callback != null) { session.setCallback(callback); } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java index faf37a909c..1608360617 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java @@ -552,6 +552,74 @@ public class MediaControllerListenerWithMediaSessionCompatTest { .inOrder(); } + @Test + public void getMediaButtonPreferences() throws Exception { + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_FAST_FORWARD) + .setDisplayName("button2") + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + ConditionVariable onMediaButtonPreferencesChangedCalled = new ConditionVariable(); + List> onMediaButtonPreferencesChangedArguments = new ArrayList<>(); + List> mediaButtonPreferencesFromGetter = new ArrayList<>(); + controllerTestRule.createController( + session.getSessionToken(), + new MediaController.Listener() { + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller, List mediaButtonPreferences) { + onMediaButtonPreferencesChangedArguments.add(mediaButtonPreferences); + mediaButtonPreferencesFromGetter.add(controller.getMediaButtonPreferences()); + onMediaButtonPreferencesChangedCalled.open(); + } + }); + Bundle extras1 = new Bundle(); + extras1.putString("key", "value-1"); + PlaybackStateCompat.CustomAction customAction1 = + new PlaybackStateCompat.CustomAction.Builder( + "command1", "button1", /* icon= */ R.drawable.media3_notification_small_icon) + .setExtras(extras1) + .build(); + Bundle extras2 = new Bundle(); + extras2.putString("key", "value-2"); + extras2.putInt( + MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT, CommandButton.ICON_FAST_FORWARD); + PlaybackStateCompat.CustomAction customAction2 = + new PlaybackStateCompat.CustomAction.Builder( + "command2", "button2", /* icon= */ R.drawable.media3_icon_fast_forward) + .setExtras(extras2) + .build(); + PlaybackStateCompat.Builder playbackState1 = + new PlaybackStateCompat.Builder() + .addCustomAction(customAction1) + .addCustomAction(customAction2); + PlaybackStateCompat.Builder playbackState2 = + new PlaybackStateCompat.Builder().addCustomAction(customAction1); + + session.setPlaybackState(playbackState1.build()); + assertThat(onMediaButtonPreferencesChangedCalled.block(TIMEOUT_MS)).isTrue(); + onMediaButtonPreferencesChangedCalled.close(); + session.setPlaybackState(playbackState2.build()); + assertThat(onMediaButtonPreferencesChangedCalled.block(TIMEOUT_MS)).isTrue(); + + ImmutableList expectedFirstMediaButtonPreferences = + ImmutableList.of(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true)); + ImmutableList expectedSecondMediaButtonPreferences = + ImmutableList.of(button1.copyWithIsEnabled(true)); + assertThat(onMediaButtonPreferencesChangedArguments) + .containsExactly(expectedFirstMediaButtonPreferences, expectedSecondMediaButtonPreferences) + .inOrder(); + assertThat(mediaButtonPreferencesFromGetter) + .containsExactly(expectedFirstMediaButtonPreferences, expectedSecondMediaButtonPreferences) + .inOrder(); + } + @Test public void getCurrentPosition_unknownPlaybackPosition_convertedToZero() throws Exception { session.setPlaybackState( 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 d842e7a33b..2b474bf657 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 @@ -405,6 +405,7 @@ public class MediaControllerTest { button3.copyWithIsEnabled(false), button4.copyWithIsEnabled(true)) .inOrder(); + session.cleanUp(); } @Test @@ -452,6 +453,7 @@ public class MediaControllerTest { assertThat(getterCustomLayouts).hasSize(2); assertThat(getterCustomLayouts.get(0)).containsExactly(button.copyWithIsEnabled(false)); assertThat(getterCustomLayouts.get(1)).containsExactly(button.copyWithIsEnabled(true)); + session.cleanUp(); } @Test @@ -550,6 +552,369 @@ public class MediaControllerTest { session.cleanUp(); } + @Test + public void getMediaButtonPreferences_mediaButtonPreferencesBuiltWithSession_includedOnConnect() + throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null); + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button2") + .setEnabled(false) + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .build(); + CommandButton button4 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .build(); + CommandButton button5 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button5") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_GET_TRACKS) + .build(); + setupMediaButtonPreferences( + session, ImmutableList.of(button1, button2, button3, button4, button5)); + MediaController controller = controllerTestRule.createController(session.getToken()); + + assertThat(threadTestRule.getHandler().postAndSync(controller::getMediaButtonPreferences)) + .containsExactly( + button1.copyWithIsEnabled(true), + button2.copyWithIsEnabled(false), + button3.copyWithIsEnabled(false), + button4.copyWithIsEnabled(true), + button5.copyWithIsEnabled(false)) + .inOrder(); + + session.cleanUp(); + } + + @Test + public void + getMediaButtonPreferences_sessionSetMediaButtonPreferences_mediaButtonPreferencesChanged() + throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null); + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button2") + .setEnabled(false) + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .build(); + CommandButton button4 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command4", Bundle.EMPTY)) + .build(); + CommandButton button5 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button5") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .build(); + CommandButton button6 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button6") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_GET_TRACKS) + .build(); + setupMediaButtonPreferences(session, ImmutableList.of(button1, button3)); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> reportedMediaButtonPreferences = new AtomicReference<>(); + MediaController controller = + controllerTestRule.createController( + session.getToken(), + Bundle.EMPTY, + new MediaController.Listener() { + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller1, List layout) { + reportedMediaButtonPreferences.set(layout); + latch.countDown(); + } + }); + ImmutableList initialMediaButtonPreferencesFromGetter = + threadTestRule.getHandler().postAndSync(controller::getMediaButtonPreferences); + session.setMediaButtonPreferences( + ImmutableList.of(button1, button2, button4, button5, button6)); + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + + ImmutableList newMediaButtonPreferencesFromGetter = + threadTestRule.getHandler().postAndSync(controller::getMediaButtonPreferences); + + assertThat(initialMediaButtonPreferencesFromGetter) + .containsExactly(button1.copyWithIsEnabled(true), button3.copyWithIsEnabled(false)) + .inOrder(); + ImmutableList expectedNewButtons = + ImmutableList.of( + button1.copyWithIsEnabled(true), + button2.copyWithIsEnabled(false), + button4.copyWithIsEnabled(false), + button5.copyWithIsEnabled(true), + button6.copyWithIsEnabled(false)); + assertThat(newMediaButtonPreferencesFromGetter) + .containsExactlyElementsIn(expectedNewButtons) + .inOrder(); + assertThat(reportedMediaButtonPreferences.get()) + .containsExactlyElementsIn(expectedNewButtons) + .inOrder(); + session.cleanUp(); + } + + @Test + public void + getMediaButtonPreferences_setAvailableCommandsOnSession_reportsMediaButtonPreferencesChanged() + throws Exception { + RemoteMediaSession session = createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, null); + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button2") + .setEnabled(false) + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .build(); + CommandButton button4 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_GET_TRACKS) + .build(); + setupMediaButtonPreferences(session, ImmutableList.of(button1, button2, button3, button4)); + CountDownLatch latch = new CountDownLatch(2); + List> reportedMediaButtonPreferencesChanged = new ArrayList<>(); + List> getterMediaButtonPreferencesChanged = new ArrayList<>(); + MediaController.Listener listener = + new MediaController.Listener() { + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller, List layout) { + reportedMediaButtonPreferencesChanged.add(layout); + getterMediaButtonPreferencesChanged.add(controller.getMediaButtonPreferences()); + latch.countDown(); + } + }; + MediaController controller = + controllerTestRule.createController( + session.getToken(), /* connectionHints= */ Bundle.EMPTY, listener); + ImmutableList initialMediaButtonPreferences = + threadTestRule.getHandler().postAndSync(controller::getMediaButtonPreferences); + + // Remove commands in custom layout from available commands. + session.setAvailableCommands(SessionCommands.EMPTY, Player.Commands.EMPTY); + // Add one sesion and player command back. + session.setAvailableCommands( + new SessionCommands.Builder().add(button2.sessionCommand).build(), + new Player.Commands.Builder().add(Player.COMMAND_GET_TRACKS).build()); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialMediaButtonPreferences) + .containsExactly( + button1.copyWithIsEnabled(true), + button2.copyWithIsEnabled(false), + button3.copyWithIsEnabled(true), + button4.copyWithIsEnabled(false)); + assertThat(reportedMediaButtonPreferencesChanged).hasSize(2); + assertThat(reportedMediaButtonPreferencesChanged.get(0)) + .containsExactly( + button1.copyWithIsEnabled(false), + button2.copyWithIsEnabled(false), + button3.copyWithIsEnabled(false), + button4.copyWithIsEnabled(false)) + .inOrder(); + assertThat(reportedMediaButtonPreferencesChanged.get(1)) + .containsExactly( + button1.copyWithIsEnabled(false), + button2.copyWithIsEnabled(false), + button3.copyWithIsEnabled(false), + button4.copyWithIsEnabled(true)) + .inOrder(); + assertThat(getterMediaButtonPreferencesChanged).hasSize(2); + assertThat(getterMediaButtonPreferencesChanged.get(0)) + .containsExactly( + button1.copyWithIsEnabled(false), + button2.copyWithIsEnabled(false), + button3.copyWithIsEnabled(false), + button4.copyWithIsEnabled(false)) + .inOrder(); + assertThat(getterMediaButtonPreferencesChanged.get(1)) + .containsExactly( + button1.copyWithIsEnabled(false), + button2.copyWithIsEnabled(false), + button3.copyWithIsEnabled(false), + button4.copyWithIsEnabled(true)) + .inOrder(); + session.cleanUp(); + } + + @Test + public void + getMediaButtonPreferences_setAvailableCommandsOnPlayer_reportsMediaButtonPreferencesChanged() + throws Exception { + RemoteMediaSession session = createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, null); + CommandButton button = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button") + .setIconResId(R.drawable.media3_notification_small_icon) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .build(); + setupMediaButtonPreferences(session, ImmutableList.of(button)); + CountDownLatch latch = new CountDownLatch(2); + List> reportedMediaButtonPreferences = new ArrayList<>(); + List> getterMediaButtonPreferences = new ArrayList<>(); + MediaController.Listener listener = + new MediaController.Listener() { + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller, List layout) { + reportedMediaButtonPreferences.add(layout); + getterMediaButtonPreferences.add(controller.getMediaButtonPreferences()); + latch.countDown(); + } + }; + MediaController controller = + controllerTestRule.createController( + session.getToken(), /* connectionHints= */ Bundle.EMPTY, listener); + ImmutableList initialMediaButtonPreferences = + threadTestRule.getHandler().postAndSync(controller::getMediaButtonPreferences); + + // Disable player command and then add it back. + session.getMockPlayer().notifyAvailableCommandsChanged(Player.Commands.EMPTY); + session + .getMockPlayer() + .notifyAvailableCommandsChanged( + new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build()); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialMediaButtonPreferences).containsExactly(button.copyWithIsEnabled(true)); + assertThat(reportedMediaButtonPreferences).hasSize(2); + assertThat(reportedMediaButtonPreferences.get(0)) + .containsExactly(button.copyWithIsEnabled(false)); + assertThat(reportedMediaButtonPreferences.get(1)) + .containsExactly(button.copyWithIsEnabled(true)); + assertThat(getterMediaButtonPreferences).hasSize(2); + assertThat(getterMediaButtonPreferences.get(0)) + .containsExactly(button.copyWithIsEnabled(false)); + assertThat(getterMediaButtonPreferences.get(1)).containsExactly(button.copyWithIsEnabled(true)); + session.cleanUp(); + } + + @Test + public void + getMediaButtonPreferences_sessionSetMediaButtonPreferencesNoChange_listenerNotCalledWithEqualPreferences() + throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null); + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button2") + .setEnabled(false) + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .build(); + CommandButton button4 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command4", Bundle.EMPTY)) + .build(); + setupMediaButtonPreferences(session, ImmutableList.of(button1, button2)); + CountDownLatch latch = new CountDownLatch(2); + List> reportedMediaButtonPreferences = new ArrayList<>(); + List> getterMediaButtonPreferences = new ArrayList<>(); + MediaController.Listener listener = + new MediaController.Listener() { + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller, List layout) { + reportedMediaButtonPreferences.add(layout); + getterMediaButtonPreferences.add(controller.getMediaButtonPreferences()); + latch.countDown(); + } + }; + MediaController controller = + controllerTestRule.createController(session.getToken(), Bundle.EMPTY, listener); + ImmutableList initialMediaButtonPreferences = + threadTestRule.getHandler().postAndSync(controller::getMediaButtonPreferences); + + // First call does not trigger onMediaButtonPreferencesChanged. + session.setMediaButtonPreferences(ImmutableList.of(button1, button2)); + session.setMediaButtonPreferences(ImmutableList.of(button3, button4)); + session.setMediaButtonPreferences(ImmutableList.of(button1, button2)); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + CommandButton button1Enabled = button1.copyWithIsEnabled(true); + CommandButton button2Disabled = button2.copyWithIsEnabled(false); + CommandButton button3Disabled = button3.copyWithIsEnabled(false); + CommandButton button4Disabled = button4.copyWithIsEnabled(false); + assertThat(initialMediaButtonPreferences) + .containsExactly(button1Enabled, button2Disabled) + .inOrder(); + assertThat(reportedMediaButtonPreferences) + .containsExactly( + ImmutableList.of(button3Disabled, button4Disabled), + ImmutableList.of(button1Enabled, button2Disabled)) + .inOrder(); + assertThat(getterMediaButtonPreferences) + .containsExactly( + ImmutableList.of(button3Disabled, button4Disabled), + ImmutableList.of(button1Enabled, button2Disabled)) + .inOrder(); + session.cleanUp(); + } + @Test public void getCommandButtonsForMediaItem() throws Exception { RemoteMediaSession session = @@ -2035,4 +2400,22 @@ public class MediaControllerTest { session.setCustomLayout(ImmutableList.copyOf(customLayout)); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); } + + private void setupMediaButtonPreferences( + RemoteMediaSession session, List mediaButtonPreferences) + throws RemoteException, InterruptedException, Exception { + CountDownLatch latch = new CountDownLatch(1); + controllerTestRule.createController( + session.getToken(), + /* connectionHints= */ null, + new MediaController.Listener() { + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller, List layout) { + latch.countDown(); + } + }); + session.setMediaButtonPreferences(ImmutableList.copyOf(mediaButtonPreferences)); + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java index fcd4f9418f..daf81cdb6b 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java @@ -154,7 +154,7 @@ public class MediaSessionCallbackTest { } @Test - public void onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied() + public void onConnect_setCustomLayoutWithMissingSessionCommand_buttonDisabledAndPermissionDenied() throws Exception { CommandButton button1 = new CommandButton.Builder(CommandButton.ICON_PLAY) @@ -169,7 +169,6 @@ public class MediaSessionCallbackTest { .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) .setEnabled(true) .build(); - ImmutableList customLayout = ImmutableList.of(button1, button2); MediaSession.Callback callback = new MediaSession.Callback() { @Override @@ -193,12 +192,7 @@ public class MediaSessionCallbackTest { }; MediaSession session = sessionTestRule.ensureReleaseAfterTest( - new MediaSession.Builder(context, player) - .setCallback(callback) - .setCustomLayout(customLayout) - .setId( - "onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied") - .build()); + new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController remoteController = remoteControllerTestRule.createRemoteController(session.getToken()); @@ -211,6 +205,60 @@ public class MediaSessionCallbackTest { .isEqualTo(RESULT_SUCCESS); } + @Test + public void + onConnect_setMediaButtonPreferencesWithMissingSessionCommand_buttonDisabledAndPermissionDenied() + throws Exception { + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_PLAY) + .setDisplayName("button1") + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .setEnabled(true) + .build(); + CommandButton button1Disabled = button1.copyWithIsEnabled(false); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_PAUSE) + .setDisplayName("button2") + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .setEnabled(true) + .build(); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + return new AcceptedResultBuilder(session) + .setAvailableSessionCommands( + new SessionCommands.Builder().add(button2.sessionCommand).build()) + .setMediaButtonPreferences(ImmutableList.of(button1, button2)) + .build(); + } + + @Override + public ListenableFuture onCustomCommand( + MediaSession session, + ControllerInfo controller, + SessionCommand customCommand, + Bundle args) { + return Futures.immediateFuture(new SessionResult(RESULT_SUCCESS)); + } + }; + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player).setCallback(callback).build()); + RemoteMediaController remoteController = + remoteControllerTestRule.createRemoteController(session.getToken()); + + ImmutableList mediaButtonPreferences = + remoteController.getMediaButtonPreferences(); + + assertThat(mediaButtonPreferences).containsExactly(button1Disabled, button2).inOrder(); + assertThat(remoteController.sendCustomCommand(button1.sessionCommand, Bundle.EMPTY).resultCode) + .isEqualTo(ERROR_PERMISSION_DENIED); + assertThat(remoteController.sendCustomCommand(button2.sessionCommand, Bundle.EMPTY).resultCode) + .isEqualTo(RESULT_SUCCESS); + } + @Test public void onConnect_emptyPlayerCommands_commandReleaseAlwaysIncluded() throws Exception { MediaSession.Callback callback = diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java index 586ec6fb89..b672e1f238 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java @@ -28,6 +28,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import androidx.media3.common.ForwardingPlayer; import androidx.media3.common.MediaItem; @@ -236,8 +237,7 @@ public class MediaSessionServiceTest { } @Test - public void onCreate_mediaNotificationManagerController_correctSessionStateFromOnConnect() - throws Exception { + public void onCreate_withCustomLayout_correctSessionStateFromOnConnect() throws Exception { SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY); SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY); SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY); @@ -349,6 +349,121 @@ public class MediaSessionServiceTest { .blockUntilAllControllersUnbind(TIMEOUT_MS); } + @Test + public void onCreate_withMediaButtonPreferences_correctSessionStateFromOnConnect() + throws Exception { + SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY); + SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY); + SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY); + CommandButton button1 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command1) + .build(); + CommandButton button2 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command2) + .build(); + CommandButton button3 = + new CommandButton.Builder(CommandButton.ICON_UNDEFINED) + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command3) + .build(); + Bundle testHints = new Bundle(); + testHints.putString("test_key", "test_value"); + List controllerInfoList = new ArrayList<>(); + CountDownLatch latch = new CountDownLatch(2); + TestHandler handler = new TestHandler(Looper.getMainLooper()); + ExoPlayer player = + handler.postAndSync( + () -> { + ExoPlayer exoPlayer = new TestExoPlayerBuilder(context).build(); + exoPlayer.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + exoPlayer.prepare(); + return exoPlayer; + }); + MediaSession mediaSession = + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) + .setMediaButtonPreferences(Lists.newArrayList(button1, button2)) + .setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + controllerInfoList.add(controller); + if (session.isMediaNotificationController(controller)) { + latch.countDown(); + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + SessionCommands.EMPTY.buildUpon().add(command1).add(command3).build()) + .setAvailablePlayerCommands(Player.Commands.EMPTY) + .setMediaButtonPreferences(ImmutableList.of(button1, button3)) + .build(); + } + latch.countDown(); + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session).build(); + } + }) + .build(); + TestServiceRegistry.getInstance().setOnGetSessionHandler(controllerInfo -> mediaSession); + MediaControllerCompat mediaControllerCompat = + new MediaControllerCompat( + ApplicationProvider.getApplicationContext(), + MediaSessionCompat.Token.fromToken(mediaSession.getPlatformToken())); + CountDownLatch controllerReady = new CountDownLatch(1); + mediaControllerCompat.registerCallback( + new MediaControllerCompat.Callback() { + @Override + public void onSessionReady() { + controllerReady.countDown(); + } + }, + new Handler(Looper.getMainLooper())); + controllerReady.await(); + List initialCustomActionsInControllerCompat = + mediaControllerCompat.getPlaybackState().getCustomActions(); + + // Start the service by creating a remote controller. + RemoteMediaController remoteController = + controllerTestRule.createRemoteController(token, /* waitForConnection= */ true, testHints); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat( + controllerInfoList + .get(0) + .getConnectionHints() + .getBoolean( + MediaController.KEY_MEDIA_NOTIFICATION_CONTROLLER_FLAG, + /* defaultValue= */ false)) + .isTrue(); + assertThat(TestUtils.equals(controllerInfoList.get(1).getConnectionHints(), testHints)) + .isTrue(); + assertThat(mediaControllerCompat.getPlaybackState().getActions()) + .isEqualTo(PlaybackStateCompat.ACTION_SET_RATING); + assertThat(remoteController.getMediaButtonPreferences()) + .containsExactly(button1.copyWithIsEnabled(false), button2.copyWithIsEnabled(false)) + .inOrder(); + assertThat(initialCustomActionsInControllerCompat).isEmpty(); + assertThat(mediaControllerCompat.getPlaybackState().getCustomActions()).hasSize(2); + PlaybackStateCompat.CustomAction customAction1 = + mediaControllerCompat.getPlaybackState().getCustomActions().get(0); + PlaybackStateCompat.CustomAction customAction2 = + mediaControllerCompat.getPlaybackState().getCustomActions().get(1); + assertThat(customAction1.getAction()).isEqualTo("command1"); + assertThat(customAction1.getName().toString()).isEqualTo("button1"); + assertThat(customAction1.getIcon()).isEqualTo(R.drawable.media3_notification_small_icon); + assertThat(customAction2.getAction()).isEqualTo("command3"); + assertThat(customAction2.getName().toString()).isEqualTo("button3"); + assertThat(customAction2.getIcon()).isEqualTo(R.drawable.media3_notification_small_icon); + mediaSession.release(); + ((MockMediaSessionService) TestServiceRegistry.getInstance().getServiceInstance()) + .blockUntilAllControllersUnbind(TIMEOUT_MS); + } + /** * Tests whether {@link MediaSessionService#onGetSession(ControllerInfo)} is called when * controller tries to connect, with the proper arguments. diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java index fbd0239562..d7c735e65a 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java @@ -866,6 +866,20 @@ public class MediaControllerProviderService extends Service { return bundle; } + @Override + public Bundle getMediaButtonPreferences(String controllerId) throws RemoteException { + MediaController controller = mediaControllerMap.get(controllerId); + ArrayList mediaButtonPreferences = new ArrayList<>(); + ImmutableList commandButtons = + runOnHandler(controller::getMediaButtonPreferences); + for (CommandButton button : commandButtons) { + mediaButtonPreferences.add(button.toBundle()); + } + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList(KEY_COMMAND_BUTTON_LIST, mediaButtonPreferences); + return bundle; + } + @Override public Bundle getAvailableCommands(String controllerId) throws RemoteException { MediaController controller = mediaControllerMap.get(controllerId); 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 2f94eb394b..fc55b0dbb8 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 @@ -622,6 +622,24 @@ public class MediaSessionProviderService extends Service { }); } + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void setMediaButtonPreferences(String sessionId, List mediaButtonPreferences) + throws RemoteException { + if (mediaButtonPreferences == null) { + return; + } + runOnHandler( + () -> { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (Bundle bundle : mediaButtonPreferences) { + builder.add(CommandButton.fromBundle(bundle, MediaSessionStub.VERSION_INT)); + } + MediaSession session = sessionMap.get(sessionId); + session.setMediaButtonPreferences(builder.build()); + }); + } + @Override public void setSessionExtras(String sessionId, Bundle extras) throws RemoteException { runOnHandler(() -> sessionMap.get(sessionId).setSessionExtras(extras)); diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java index b8143f31e8..f17a195c8c 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java @@ -389,6 +389,17 @@ public class RemoteMediaController { return customLayout.build(); } + public ImmutableList getMediaButtonPreferences() throws RemoteException { + Bundle mediaButtonPreferencesBundle = binder.getMediaButtonPreferences(controllerId); + ArrayList list = + mediaButtonPreferencesBundle.getParcelableArrayList(KEY_COMMAND_BUTTON_LIST); + ImmutableList.Builder mediaButtonPreferences = new ImmutableList.Builder<>(); + for (Bundle bundle : list) { + mediaButtonPreferences.add(CommandButton.fromBundle(bundle, MediaSessionStub.VERSION_INT)); + } + return mediaButtonPreferences.build(); + } + public Player.Commands getAvailableCommands() throws RemoteException { Bundle commandsBundle = binder.getAvailableCommands(controllerId); return Player.Commands.fromBundle(commandsBundle); diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java index e7e8abd4b0..ede58ca05d 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java @@ -201,6 +201,15 @@ public class RemoteMediaSession { binder.setCustomLayout(sessionId, bundleList); } + public void setMediaButtonPreferences(List mediaButtonPreferences) + throws RemoteException { + List bundleList = new ArrayList<>(); + for (CommandButton button : mediaButtonPreferences) { + bundleList.add(button.toBundle()); + } + binder.setMediaButtonPreferences(sessionId, bundleList); + } + public void setSessionExtras(Bundle extras) throws RemoteException { binder.setSessionExtras(sessionId, extras); } diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java b/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java index 288079d84a..30f95c6c94 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java @@ -62,6 +62,12 @@ public final class TestMediaBrowserListener implements MediaBrowser.Listener { delegate.onCustomLayoutChanged(controller, layout); } + @Override + public void onMediaButtonPreferencesChanged( + MediaController controller, List mediaButtonPreferences) { + delegate.onMediaButtonPreferencesChanged(controller, mediaButtonPreferences); + } + @Override public void onExtrasChanged(MediaController controller, Bundle extras) { delegate.onExtrasChanged(controller, extras);