Add custom layout to the state of the MediaController

This change also marks the buttons of the custom layout as
enabled/disabled according to available commands in the controller.
Accordingly, `CommandButton.Builder.setEnabled(boolean)` is deprecated
because the value is overridden by the library.

Issue: androidx/media#38

PiperOrigin-RevId: 547272588
(cherry picked from commit ea21d27a69)
This commit is contained in:
bachinger 2023-07-11 20:59:01 +01:00 committed by Tianyi Feng
parent 996755c22b
commit 7d35f18732
24 changed files with 1177 additions and 151 deletions

View file

@ -13,6 +13,15 @@
Previously indent and tab offset were included when limiting the cue Previously indent and tab offset were included when limiting the cue
length to 32 characters (which was technically correct by the spec) length to 32 characters (which was technically correct by the spec)
([#11019](https://github.com/google/ExoPlayer/issues/11019)). ([#11019](https://github.com/google/ExoPlayer/issues/11019)).
* Session:
* Add custom layout to the state of the controller and provide a getter to
access it. When the custom layout changes,
`MediaController.Listener.onCustomLayoutChanged` is called. The callback
`MediaController.Listener.onSetCustomLayout()` is deprecated. Apps that
want to send different custom layouts to different Media3 controller can
do this in `MediaSession.Callback.onConnect` by using an
`AcceptedResultBuilder` to make sure the custom layout is available to
the controller when connection completes.
* Test Utilities: * Test Utilities:
* Add a `nanoTime()` method to `Clock` to provide override support of * Add a `nanoTime()` method to `Clock` to provide override support of
`System.nanoTime()` `System.nanoTime()`

View file

@ -33,6 +33,7 @@ import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.* import androidx.media3.session.*
import androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED import androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED
import androidx.media3.session.MediaSession.ConnectionResult
import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSession.ControllerInfo
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
@ -45,8 +46,6 @@ class PlaybackService : MediaLibraryService() {
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var customCommands: List<CommandButton> private lateinit var customCommands: List<CommandButton>
private var customLayout = ImmutableList.of<CommandButton>()
companion object { companion object {
private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch" private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch"
private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri" private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri"
@ -70,7 +69,6 @@ class PlaybackService : MediaLibraryService() {
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY) SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY)
) )
) )
customLayout = ImmutableList.of(customCommands[0])
initializeSessionAndPlayer() initializeSessionAndPlayer()
setListener(MediaSessionServiceListener()) setListener(MediaSessionServiceListener())
} }
@ -95,28 +93,16 @@ class PlaybackService : MediaLibraryService() {
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback { private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
override fun onConnect( override fun onConnect(session: MediaSession, controller: ControllerInfo): ConnectionResult {
session: MediaSession, val availableSessionCommands =
controller: ControllerInfo ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
for (commandButton in customCommands) { for (commandButton in customCommands) {
// Add custom command to available session commands. // Add custom command to available session commands.
commandButton.sessionCommand?.let { availableSessionCommands.add(it) } commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
} }
return MediaSession.ConnectionResult.accept( return ConnectionResult.AcceptedResultBuilder(session)
availableSessionCommands.build(), .setAvailableSessionCommands(availableSessionCommands.build())
connectionResult.availablePlayerCommands .build()
)
}
override fun onPostConnect(session: MediaSession, controller: ControllerInfo) {
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
// Let Media3 controller (for instance the MediaNotificationProvider) know about the custom
// layout right after it connected.
ignoreFuture(mediaLibrarySession.setCustomLayout(controller, customLayout))
}
} }
override fun onCustomCommand( override fun onCustomCommand(
@ -129,16 +115,12 @@ class PlaybackService : MediaLibraryService() {
// Enable shuffling. // Enable shuffling.
player.shuffleModeEnabled = true player.shuffleModeEnabled = true
// Change the custom layout to contain the `Disable shuffling` command. // Change the custom layout to contain the `Disable shuffling` command.
customLayout = ImmutableList.of(customCommands[1]) session.setCustomLayout(ImmutableList.of(customCommands[1]))
// Send the updated custom layout to controllers.
session.setCustomLayout(customLayout)
} else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) { } else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) {
// Disable shuffling. // Disable shuffling.
player.shuffleModeEnabled = false player.shuffleModeEnabled = false
// Change the custom layout to contain the `Enable shuffling` command. // Change the custom layout to contain the `Enable shuffling` command.
customLayout = ImmutableList.of(customCommands[0]) session.setCustomLayout(ImmutableList.of(customCommands[0]))
// Send the updated custom layout to controllers.
session.setCustomLayout(customLayout)
} }
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
} }
@ -241,12 +223,9 @@ class PlaybackService : MediaLibraryService() {
mediaLibrarySession = mediaLibrarySession =
MediaLibrarySession.Builder(this, player, librarySessionCallback) MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getSingleTopActivity()) .setSessionActivity(getSingleTopActivity())
.setCustomLayout(ImmutableList.of(customCommands[0]))
.setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this))) .setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this)))
.build() .build()
if (!customLayout.isEmpty()) {
// Send custom layout to legacy session.
mediaLibrarySession.setCustomLayout(customLayout)
}
} }
private fun getSingleTopActivity(): PendingIntent { private fun getSingleTopActivity(): PendingIntent {

View file

@ -28,6 +28,7 @@ import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.CheckReturnValue;
import java.util.List; import java.util.List;
/** /**
@ -35,7 +36,7 @@ import java.util.List;
* controllers. * controllers.
* *
* @see MediaSession#setCustomLayout(MediaSession.ControllerInfo, List) * @see MediaSession#setCustomLayout(MediaSession.ControllerInfo, List)
* @see MediaController.Listener#onSetCustomLayout(MediaController, List) * @see MediaController.Listener#onCustomLayoutChanged(MediaController, List)
*/ */
public final class CommandButton implements Bundleable { public final class CommandButton implements Bundleable {
@ -196,6 +197,19 @@ public final class CommandButton implements Bundleable {
this.isEnabled = enabled; this.isEnabled = enabled;
} }
/** Returns a copy with the new {@link #isEnabled} flag. */
@CheckReturnValue
/* package */ CommandButton copyWithIsEnabled(boolean isEnabled) {
// Because this method is supposed to be used by the library only, this method has been chosen
// over the conventional `buildUpon` approach. This aims for keeping this separate from the
// public Builder-API used by apps.
if (this.isEnabled == isEnabled) {
return this;
}
return new CommandButton(
sessionCommand, playerCommand, iconResId, displayName, new Bundle(extras), isEnabled);
}
@Override @Override
public boolean equals(@Nullable Object obj) { public boolean equals(@Nullable Object obj) {
if (this == obj) { if (this == obj) {

View file

@ -24,7 +24,10 @@ import androidx.annotation.Nullable;
import androidx.core.app.BundleCompat; import androidx.core.app.BundleCompat;
import androidx.media3.common.Bundleable; import androidx.media3.common.Bundleable;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.util.BundleableUtil;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import java.util.List;
/** /**
* Created by {@link MediaSession} to send its state to the {@link MediaController} when the * Created by {@link MediaSession} to send its state to the {@link MediaController} when the
@ -50,11 +53,14 @@ import androidx.media3.common.util.Util;
public final PlayerInfo playerInfo; public final PlayerInfo playerInfo;
public final ImmutableList<CommandButton> customLayout;
public ConnectionState( public ConnectionState(
int libraryVersion, int libraryVersion,
int sessionInterfaceVersion, int sessionInterfaceVersion,
IMediaSession sessionBinder, IMediaSession sessionBinder,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
SessionCommands sessionCommands, SessionCommands sessionCommands,
Player.Commands playerCommandsFromSession, Player.Commands playerCommandsFromSession,
Player.Commands playerCommandsFromPlayer, Player.Commands playerCommandsFromPlayer,
@ -69,6 +75,7 @@ import androidx.media3.common.util.Util;
this.sessionActivity = sessionActivity; this.sessionActivity = sessionActivity;
this.tokenExtras = tokenExtras; this.tokenExtras = tokenExtras;
this.playerInfo = playerInfo; this.playerInfo = playerInfo;
this.customLayout = customLayout;
} }
// Bundleable implementation. // Bundleable implementation.
@ -76,13 +83,14 @@ import androidx.media3.common.util.Util;
private static final String FIELD_LIBRARY_VERSION = Util.intToStringMaxRadix(0); private static final String FIELD_LIBRARY_VERSION = Util.intToStringMaxRadix(0);
private static final String FIELD_SESSION_BINDER = Util.intToStringMaxRadix(1); 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_SESSION_ACTIVITY = Util.intToStringMaxRadix(2);
private static final String FIELD_CUSTOM_LAYOUT = Util.intToStringMaxRadix(9);
private static final String FIELD_SESSION_COMMANDS = Util.intToStringMaxRadix(3); private static final String FIELD_SESSION_COMMANDS = Util.intToStringMaxRadix(3);
private static final String FIELD_PLAYER_COMMANDS_FROM_SESSION = Util.intToStringMaxRadix(4); private static final String FIELD_PLAYER_COMMANDS_FROM_SESSION = Util.intToStringMaxRadix(4);
private static final String FIELD_PLAYER_COMMANDS_FROM_PLAYER = Util.intToStringMaxRadix(5); private static final String FIELD_PLAYER_COMMANDS_FROM_PLAYER = Util.intToStringMaxRadix(5);
private static final String FIELD_TOKEN_EXTRAS = Util.intToStringMaxRadix(6); private static final String FIELD_TOKEN_EXTRAS = Util.intToStringMaxRadix(6);
private static final String FIELD_PLAYER_INFO = Util.intToStringMaxRadix(7); private static final String FIELD_PLAYER_INFO = Util.intToStringMaxRadix(7);
private static final String FIELD_SESSION_INTERFACE_VERSION = Util.intToStringMaxRadix(8); private static final String FIELD_SESSION_INTERFACE_VERSION = Util.intToStringMaxRadix(8);
// Next field key = 9 // Next field key = 10
@Override @Override
public Bundle toBundle() { public Bundle toBundle() {
@ -90,6 +98,10 @@ import androidx.media3.common.util.Util;
bundle.putInt(FIELD_LIBRARY_VERSION, libraryVersion); bundle.putInt(FIELD_LIBRARY_VERSION, libraryVersion);
BundleCompat.putBinder(bundle, FIELD_SESSION_BINDER, sessionBinder.asBinder()); BundleCompat.putBinder(bundle, FIELD_SESSION_BINDER, sessionBinder.asBinder());
bundle.putParcelable(FIELD_SESSION_ACTIVITY, sessionActivity); bundle.putParcelable(FIELD_SESSION_ACTIVITY, sessionActivity);
if (!customLayout.isEmpty()) {
bundle.putParcelableArrayList(
FIELD_CUSTOM_LAYOUT, BundleableUtil.toBundleArrayList(customLayout));
}
bundle.putBundle(FIELD_SESSION_COMMANDS, sessionCommands.toBundle()); bundle.putBundle(FIELD_SESSION_COMMANDS, sessionCommands.toBundle());
bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_SESSION, playerCommandsFromSession.toBundle()); bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_SESSION, playerCommandsFromSession.toBundle());
bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER, playerCommandsFromPlayer.toBundle()); bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER, playerCommandsFromPlayer.toBundle());
@ -113,6 +125,12 @@ import androidx.media3.common.util.Util;
bundle.getInt(FIELD_SESSION_INTERFACE_VERSION, /* defaultValue= */ 0); bundle.getInt(FIELD_SESSION_INTERFACE_VERSION, /* defaultValue= */ 0);
IBinder sessionBinder = checkNotNull(BundleCompat.getBinder(bundle, FIELD_SESSION_BINDER)); IBinder sessionBinder = checkNotNull(BundleCompat.getBinder(bundle, FIELD_SESSION_BINDER));
@Nullable PendingIntent sessionActivity = bundle.getParcelable(FIELD_SESSION_ACTIVITY); @Nullable PendingIntent sessionActivity = bundle.getParcelable(FIELD_SESSION_ACTIVITY);
@Nullable
List<Bundle> commandButtonArrayList = bundle.getParcelableArrayList(FIELD_CUSTOM_LAYOUT);
ImmutableList<CommandButton> customLayout =
commandButtonArrayList != null
? BundleableUtil.fromBundleList(CommandButton.CREATOR, commandButtonArrayList)
: ImmutableList.of();
@Nullable Bundle sessionCommandsBundle = bundle.getBundle(FIELD_SESSION_COMMANDS); @Nullable Bundle sessionCommandsBundle = bundle.getBundle(FIELD_SESSION_COMMANDS);
SessionCommands sessionCommands = SessionCommands sessionCommands =
sessionCommandsBundle == null sessionCommandsBundle == null
@ -141,6 +159,7 @@ import androidx.media3.common.util.Util;
sessionInterfaceVersion, sessionInterfaceVersion,
IMediaSession.Stub.asInterface(sessionBinder), IMediaSession.Stub.asInterface(sessionBinder),
sessionActivity, sessionActivity,
customLayout,
sessionCommands, sessionCommands,
playerCommandsFromSession, playerCommandsFromSession,
playerCommandsFromPlayer, playerCommandsFromPlayer,

View file

@ -58,6 +58,7 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.Size; import androidx.media3.common.util.Size;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CanIgnoreReturnValue;
@ -343,22 +344,38 @@ public class MediaController implements Player {
/** /**
* Called when the session sets the custom layout through {@link MediaSession#setCustomLayout}. * Called when the session sets the custom layout through {@link MediaSession#setCustomLayout}.
* *
* <p>Return a {@link ListenableFuture} to reply with a {@link SessionResult} to the session * <p>This method will be deprecated. Use {@link #onCustomLayoutChanged(MediaController, List)}
* asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link * instead.
* Futures#immediateFuture(Object)}.
* *
* <p>The default implementation returns a {@link ListenableFuture} of {@link * <p>There is a slight difference in behaviour. This to be deprecated method may be
* SessionResult#RESULT_ERROR_NOT_SUPPORTED}. * consecutively called with an unchanged custom layout passed into it, in which case the new
* {@link #onCustomLayoutChanged(MediaController, List)} isn't called again for equal arguments.
* *
* @param controller The controller. * <p>Further, when the available commands of a controller change in a way that affect whether
* @param layout The ordered list of {@link CommandButton}. * buttons of the custom layout are enabled or disabled, the new callback {@link
* @return The result of handling the custom layout. * #onCustomLayoutChanged(MediaController, List)} is called, in which case the deprecated
* callback isn't called.
*/ */
default ListenableFuture<SessionResult> onSetCustomLayout( default ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) { MediaController controller, List<CommandButton> layout) {
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)); return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED));
} }
/**
* Called when the {@linkplain #getCustomLayout() custom layout} changed.
*
* <p>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)
* changes the available commands} for a controller that affect whether buttons of the custom
* layout are enabled or disabled.
*
* @param controller The controller.
* @param layout The ordered list of {@linkplain CommandButton command buttons}.
*/
@UnstableApi
default void onCustomLayoutChanged(MediaController controller, List<CommandButton> layout) {}
/** /**
* Called when the available session commands are changed by session. * Called when the available session commands are changed by session.
* *
@ -935,6 +952,20 @@ public class MediaController implements Player {
return createDisconnectedFuture(); return createDisconnectedFuture();
} }
/**
* Returns the custom layout.
*
* <p>After being connected, a change of the custom layout is reported with {@link
* Listener#onCustomLayoutChanged(MediaController, List)}.
*
* @return The custom layout.
*/
@UnstableApi
public final ImmutableList<CommandButton> getCustomLayout() {
verifyApplicationThread();
return isConnected() ? impl.getCustomLayout() : ImmutableList.of();
}
/** Returns {@code null}. */ /** Returns {@code null}. */
@UnstableApi @UnstableApi
@Override @Override
@ -1982,6 +2013,8 @@ public class MediaController implements Player {
ListenableFuture<SessionResult> sendCustomCommand(SessionCommand command, Bundle args); ListenableFuture<SessionResult> sendCustomCommand(SessionCommand command, Bundle args);
ImmutableList<CommandButton> getCustomLayout();
Timeline getCurrentTimeline(); Timeline getCurrentTimeline();
void setMediaItem(MediaItem mediaItem); void setMediaItem(MediaItem mediaItem);

View file

@ -88,6 +88,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
@ -119,6 +120,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
private boolean released; private boolean released;
private PlayerInfo playerInfo; private PlayerInfo playerInfo;
@Nullable private PendingIntent sessionActivity; @Nullable private PendingIntent sessionActivity;
private ImmutableList<CommandButton> customLayout;
private SessionCommands sessionCommands; private SessionCommands sessionCommands;
private Commands playerCommandsFromSession; private Commands playerCommandsFromSession;
private Commands playerCommandsFromPlayer; private Commands playerCommandsFromPlayer;
@ -143,6 +145,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
playerInfo = PlayerInfo.DEFAULT; playerInfo = PlayerInfo.DEFAULT;
surfaceSize = Size.UNKNOWN; surfaceSize = Size.UNKNOWN;
sessionCommands = SessionCommands.EMPTY; sessionCommands = SessionCommands.EMPTY;
customLayout = ImmutableList.of();
playerCommandsFromSession = Commands.EMPTY; playerCommandsFromSession = Commands.EMPTY;
playerCommandsFromPlayer = Commands.EMPTY; playerCommandsFromPlayer = Commands.EMPTY;
intersectedPlayerCommands = intersectedPlayerCommands =
@ -521,11 +524,6 @@ import org.checkerframework.checker.nullness.qual.NonNull;
seekToInternalByOffset(getSeekForwardIncrement()); seekToInternalByOffset(getSeekForwardIncrement());
} }
@Override
public PendingIntent getSessionActivity() {
return sessionActivity;
}
@Override @Override
public void setPlayWhenReady(boolean playWhenReady) { public void setPlayWhenReady(boolean playWhenReady) {
if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) {
@ -710,6 +708,16 @@ import org.checkerframework.checker.nullness.qual.NonNull;
(iSession, seq) -> iSession.onCustomCommand(controllerStub, seq, command.toBundle(), args)); (iSession, seq) -> iSession.onCustomCommand(controllerStub, seq, command.toBundle(), args));
} }
@Override
public PendingIntent getSessionActivity() {
return sessionActivity;
}
@Override
public ImmutableList<CommandButton> getCustomLayout() {
return customLayout;
}
@Override @Override
public Timeline getCurrentTimeline() { public Timeline getCurrentTimeline() {
return playerInfo.timeline; return playerInfo.timeline;
@ -2477,6 +2485,8 @@ import org.checkerframework.checker.nullness.qual.NonNull;
playerCommandsFromPlayer = result.playerCommandsFromPlayer; playerCommandsFromPlayer = result.playerCommandsFromPlayer;
intersectedPlayerCommands = intersectedPlayerCommands =
createIntersectedCommands(playerCommandsFromSession, playerCommandsFromPlayer); createIntersectedCommands(playerCommandsFromSession, playerCommandsFromPlayer);
customLayout =
getEnabledCustomLayout(result.customLayout, intersectedPlayerCommands, sessionCommands);
playerInfo = result.playerInfo; playerInfo = result.playerInfo;
try { try {
// Implementation for the local binder is no-op, // Implementation for the local binder is no-op,
@ -2636,8 +2646,13 @@ import org.checkerframework.checker.nullness.qual.NonNull;
intersectedPlayerCommandsChanged = intersectedPlayerCommandsChanged =
!Util.areEqual(intersectedPlayerCommands, prevIntersectedPlayerCommands); !Util.areEqual(intersectedPlayerCommands, prevIntersectedPlayerCommands);
} }
boolean customLayoutChanged = false;
if (sessionCommandsChanged) { if (sessionCommandsChanged) {
this.sessionCommands = sessionCommands; this.sessionCommands = sessionCommands;
ImmutableList<CommandButton> oldCustomLayout = customLayout;
customLayout =
getEnabledCustomLayout(customLayout, intersectedPlayerCommands, sessionCommands);
customLayoutChanged = !customLayout.equals(oldCustomLayout);
} }
if (intersectedPlayerCommandsChanged) { if (intersectedPlayerCommandsChanged) {
listeners.sendEvent( listeners.sendEvent(
@ -2650,6 +2665,11 @@ import org.checkerframework.checker.nullness.qual.NonNull;
listener -> listener ->
listener.onAvailableSessionCommandsChanged(getInstance(), sessionCommands)); listener.onAvailableSessionCommandsChanged(getInstance(), sessionCommands));
} }
if (customLayoutChanged) {
getInstance()
.notifyControllerListener(
listener -> listener.onCustomLayoutChanged(getInstance(), customLayout));
}
} }
void onAvailableCommandsChangedFromPlayer(Commands commandsFromPlayer) { void onAvailableCommandsChangedFromPlayer(Commands commandsFromPlayer) {
@ -2672,27 +2692,25 @@ import org.checkerframework.checker.nullness.qual.NonNull;
} }
} }
// Calling deprecated listener callback method for backwards compatibility.
@SuppressWarnings("deprecation")
void onSetCustomLayout(int seq, List<CommandButton> layout) { void onSetCustomLayout(int seq, List<CommandButton> layout) {
if (!isConnected()) { if (!isConnected()) {
return; return;
} }
List<CommandButton> validatedCustomLayout = new ArrayList<>(); ImmutableList<CommandButton> oldCustomLayout = customLayout;
for (int i = 0; i < layout.size(); i++) { customLayout = getEnabledCustomLayout(layout, intersectedPlayerCommands, sessionCommands);
CommandButton button = layout.get(i); boolean hasCustomLayoutChanged = !Objects.equals(customLayout, oldCustomLayout);
if (intersectedPlayerCommands.contains(button.playerCommand)
|| (button.sessionCommand != null && sessionCommands.contains(button.sessionCommand))
|| (button.playerCommand != Player.COMMAND_INVALID
&& sessionCommands.contains(button.playerCommand))) {
validatedCustomLayout.add(button);
}
}
getInstance() getInstance()
.notifyControllerListener( .notifyControllerListener(
listener -> { listener -> {
ListenableFuture<SessionResult> future = ListenableFuture<SessionResult> future =
checkNotNull( checkNotNull(
listener.onSetCustomLayout(getInstance(), validatedCustomLayout), listener.onSetCustomLayout(getInstance(), customLayout),
"MediaController.Listener#onSetCustomLayout() must not return null"); "MediaController.Listener#onSetCustomLayout() must not return null");
if (hasCustomLayoutChanged) {
listener.onCustomLayoutChanged(getInstance(), customLayout);
}
sendControllerResultWhenReady(seq, future); sendControllerResultWhenReady(seq, future);
}); });
} }
@ -2705,7 +2723,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
.notifyControllerListener(listener -> listener.onExtrasChanged(getInstance(), extras)); .notifyControllerListener(listener -> listener.onExtrasChanged(getInstance(), extras));
} }
void onSetSessionActivity(int seq, PendingIntent sessionActivity) { public void onSetSessionActivity(int seq, PendingIntent sessionActivity) {
if (!isConnected()) { if (!isConnected()) {
return; return;
} }
@ -2734,6 +2752,23 @@ import org.checkerframework.checker.nullness.qual.NonNull;
} }
} }
private static ImmutableList<CommandButton> getEnabledCustomLayout(
List<CommandButton> customLayout,
Player.Commands playerCommands,
SessionCommands sessionCommands) {
ImmutableList.Builder<CommandButton> availableCustomLayout = new ImmutableList.Builder<>();
for (int i = 0; i < customLayout.size(); i++) {
CommandButton button = customLayout.get(i);
boolean isEnabled =
playerCommands.contains(button.playerCommand)
|| (button.sessionCommand != null && sessionCommands.contains(button.sessionCommand))
|| (button.playerCommand != Player.COMMAND_INVALID
&& sessionCommands.contains(button.playerCommand));
availableCustomLayout.add(button.copyWithIsEnabled(isEnabled));
}
return availableCustomLayout.build();
}
@Player.RepeatMode @Player.RepeatMode
private static int convertRepeatModeForNavigation(@Player.RepeatMode int repeatMode) { private static int convertRepeatModeForNavigation(@Player.RepeatMode int repeatMode) {
return repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_OFF : repeatMode; return repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_OFF : repeatMode;

View file

@ -441,6 +441,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
return controllerCompat.getSessionActivity(); return controllerCompat.getSessionActivity();
} }
@Override
public ImmutableList<CommandButton> getCustomLayout() {
return controllerInfo.customLayout;
}
@Override @Override
@Nullable @Nullable
public PlaybackException getPlayerError() { public PlaybackException getPlayerError() {
@ -1512,6 +1517,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
mediaItemTransitionReason); mediaItemTransitionReason);
} }
// Calling deprecated listener callback method for backwards compatibility.
@SuppressWarnings("deprecation")
private void updateControllerInfo( private void updateControllerInfo(
boolean notifyConnected, boolean notifyConnected,
LegacyPlayerInfo newLegacyPlayerInfo, LegacyPlayerInfo newLegacyPlayerInfo,
@ -1531,9 +1538,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) { if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) {
getInstance() getInstance()
.notifyControllerListener( .notifyControllerListener(
listener -> listener -> {
ignoreFuture( ignoreFuture(
listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout))); listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout));
listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout);
});
} }
return; return;
} }
@ -1662,9 +1671,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) { if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) {
getInstance() getInstance()
.notifyControllerListener( .notifyControllerListener(
listener -> listener -> {
ignoreFuture( ignoreFuture(
listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout))); listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout));
listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout);
});
} }
listeners.flushEvents(); listeners.flushEvents();
} }

View file

@ -41,6 +41,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.List;
/** /**
* Superclass to be extended by services hosting {@link MediaLibrarySession media library sessions}. * Superclass to be extended by services hosting {@link MediaLibrarySession media library sessions}.
@ -124,14 +125,6 @@ public abstract class MediaLibraryService extends MediaSessionService {
*/ */
public interface Callback extends MediaSession.Callback { public interface Callback extends MediaSession.Callback {
@Override
default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) {
SessionCommands sessionCommands =
new SessionCommands.Builder().addAllLibraryCommands().addAllSessionCommands().build();
Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build();
return ConnectionResult.accept(sessionCommands, playerCommands);
}
/** /**
* Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link * Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link
* MediaBrowser#getLibraryRoot(LibraryParams)}. * MediaBrowser#getLibraryRoot(LibraryParams)}.
@ -439,6 +432,35 @@ public abstract class MediaLibraryService extends MediaSessionService {
return super.setBitmapLoader(bitmapLoader); return super.setBitmapLoader(bitmapLoader);
} }
/**
* Sets the custom layout of the session.
*
* <p>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
* converting, the {@linkplain SessionCommand#customExtras custom extras of the session
* command} is used for the extras of the legacy custom action.
*
* <p>Controllers that connect have the custom layout of the session available with the
* initial connection result by default. A custom layout specific to a controller can be set
* when the controller {@linkplain MediaLibrarySession.Callback#onConnect connects} by using
* an {@link ConnectionResult.AcceptedResultBuilder}.
*
* <p>On the controller side, {@link CommandButton#isEnabled} is overridden according to the
* available commands of the controller.
*
* <p>Use {@link MediaSession#setCustomLayout} to update the custom layout during the lifetime
* of the session.
*
* @param customLayout The ordered list of {@link CommandButton command buttons}.
* @return The builder to allow chaining.
*/
@UnstableApi
@Override
public Builder setCustomLayout(List<CommandButton> customLayout) {
return super.setCustomLayout(customLayout);
}
/** /**
* Builds a {@link MediaLibrarySession}. * Builds a {@link MediaLibrarySession}.
* *
@ -452,7 +474,14 @@ public abstract class MediaLibraryService extends MediaSessionService {
bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader()); bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
} }
return new MediaLibrarySession( return new MediaLibrarySession(
context, id, player, sessionActivity, callback, extras, checkNotNull(bitmapLoader)); context,
id,
player,
sessionActivity,
customLayout,
callback,
extras,
checkNotNull(bitmapLoader));
} }
} }
@ -461,10 +490,12 @@ public abstract class MediaLibraryService extends MediaSessionService {
String id, String id,
Player player, Player player,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaSession.Callback callback, MediaSession.Callback callback,
Bundle tokenExtras, Bundle tokenExtras,
BitmapLoader bitmapLoader) { BitmapLoader bitmapLoader) {
super(context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); super(
context, id, player, sessionActivity, customLayout, callback, tokenExtras, bitmapLoader);
} }
@Override @Override
@ -473,6 +504,7 @@ public abstract class MediaLibraryService extends MediaSessionService {
String id, String id,
Player player, Player player,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaSession.Callback callback, MediaSession.Callback callback,
Bundle tokenExtras, Bundle tokenExtras,
BitmapLoader bitmapLoader) { BitmapLoader bitmapLoader) {
@ -482,6 +514,7 @@ public abstract class MediaLibraryService extends MediaSessionService {
id, id,
player, player,
sessionActivity, sessionActivity,
customLayout,
(Callback) callback, (Callback) callback,
tokenExtras, tokenExtras,
bitmapLoader); bitmapLoader);

View file

@ -76,10 +76,20 @@ import java.util.concurrent.Future;
String id, String id,
Player player, Player player,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaLibrarySession.Callback callback, MediaLibrarySession.Callback callback,
Bundle tokenExtras, Bundle tokenExtras,
BitmapLoader bitmapLoader) { BitmapLoader bitmapLoader) {
super(instance, context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); super(
instance,
context,
id,
player,
sessionActivity,
customLayout,
callback,
tokenExtras,
bitmapLoader);
this.instance = instance; this.instance = instance;
this.callback = callback; this.callback = callback;
subscriptions = new ArrayMap<>(); subscriptions = new ArrayMap<>();

View file

@ -32,6 +32,7 @@ import android.os.Looper;
import android.os.RemoteException; import android.os.RemoteException;
import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.view.KeyEvent; import android.view.KeyEvent;
import androidx.annotation.GuardedBy; import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -59,11 +60,13 @@ import androidx.media3.common.util.BitmapLoader;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.session.MediaLibraryService.LibraryParams; import androidx.media3.session.MediaLibraryService.LibraryParams;
import androidx.media3.session.MediaLibraryService.MediaLibrarySession;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Longs; import com.google.common.primitives.Longs;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.DoNotMock; import com.google.errorprone.annotations.DoNotMock;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -347,6 +350,32 @@ public class MediaSession {
return super.setBitmapLoader(bitmapLoader); return super.setBitmapLoader(bitmapLoader);
} }
/**
* Sets the custom layout of the session.
*
* <p>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.
*
* <p>Controllers that connect have the custom layout of the session available with the initial
* connection result by default. A custom layout specific to a controller can be set when the
* controller {@linkplain MediaSession.Callback#onConnect connects} by using an {@link
* ConnectionResult.AcceptedResultBuilder}.
*
* <p>Use {@code MediaSession.setCustomLayout(..)} to update the custom layout during the life
* time of the session.
*
* @param customLayout The ordered list of {@link CommandButton command buttons}.
* @return The builder to allow chaining.
*/
@UnstableApi
@Override
public Builder setCustomLayout(List<CommandButton> customLayout) {
return super.setCustomLayout(customLayout);
}
/** /**
* Builds a {@link MediaSession}. * Builds a {@link MediaSession}.
* *
@ -360,7 +389,14 @@ public class MediaSession {
bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader()); bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
} }
return new MediaSession( return new MediaSession(
context, id, player, sessionActivity, callback, extras, checkNotNull(bitmapLoader)); context,
id,
player,
sessionActivity,
customLayout,
callback,
extras,
checkNotNull(bitmapLoader));
} }
} }
@ -548,6 +584,7 @@ public class MediaSession {
String id, String id,
Player player, Player player,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
Callback callback, Callback callback,
Bundle tokenExtras, Bundle tokenExtras,
BitmapLoader bitmapLoader) { BitmapLoader bitmapLoader) {
@ -557,7 +594,16 @@ public class MediaSession {
} }
SESSION_ID_TO_SESSION_MAP.put(id, this); SESSION_ID_TO_SESSION_MAP.put(id, this);
} }
impl = createImpl(context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); impl =
createImpl(
context,
id,
player,
sessionActivity,
customLayout,
callback,
tokenExtras,
bitmapLoader);
} }
/* package */ MediaSessionImpl createImpl( /* package */ MediaSessionImpl createImpl(
@ -565,11 +611,20 @@ public class MediaSession {
String id, String id,
Player player, Player player,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
Callback callback, Callback callback,
Bundle tokenExtras, Bundle tokenExtras,
BitmapLoader bitmapLoader) { BitmapLoader bitmapLoader) {
return new MediaSessionImpl( return new MediaSessionImpl(
this, context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); this,
context,
id,
player,
sessionActivity,
customLayout,
callback,
tokenExtras,
bitmapLoader);
} }
/* package */ MediaSessionImpl getImpl() { /* package */ MediaSessionImpl getImpl() {
@ -695,54 +750,28 @@ public class MediaSession {
} }
/** /**
* Requests that controllers set the ordered list of {@link CommandButton} to build UI with it. * Sets the custom layout for the given Media3 controller.
* *
* <p>It's up to controller's decision how to represent the layout in its own UI. Here are some * <p>Make sure to have the session commands of all command buttons of the custom layout
* examples. Note: {@code layout[i]} means a {@link CommandButton} at index {@code i} in the given * {@linkplain MediaController#getAvailableSessionCommands() available for controllers}. Include
* list. * 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.
* *
* <table> * <p>On the controller side, {@link
* <caption>Examples of controller's UI layout</caption> * MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the
* <tr> * new custom layout is different to the custom layout the {@link
* <th>Controller UI layout</th> * MediaController#getCustomLayout() controller already has available}.
* <th>Layout example</th> *
* </tr> * <p>It's up to controller's decision how to represent the layout in its own UI.
* <tr>
* <td>
* Row with 3 icons
* </td>
* <td style="white-space: nowrap;">
* {@code layout[1]} {@code layout[0]} {@code layout[2]}
* </td>
* </tr>
* <tr>
* <td>
* Row with 5 icons
* </td>
* <td style="white-space: nowrap;">
* {@code layout[3]} {@code layout[1]} {@code layout[0]} {@code layout[2]} {@code layout[4]}
* </td>
* </tr>
* <tr>
* <td rowspan="2">
* Row with 5 icons and an overflow icon, and another expandable row with 5 extra icons
* </td>
* <td style="white-space: nowrap;">
* {@code layout[5]} {@code layout[6]} {@code layout[7]} {@code layout[8]} {@code layout[9]}
* </td>
* </tr>
* <tr>
* <td style="white-space: nowrap;">
* {@code layout[3]} {@code layout[1]} {@code layout[0]} {@code layout[2]} {@code layout[4]}
* </td>
* </tr>
* </table>
* *
* <p>Interoperability: This call has no effect when called for a {@linkplain * <p>Interoperability: This call has no effect when called for a {@linkplain
* ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}. * ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}.
* *
* @param controller The controller to specify layout. * @param controller The controller for which to set the custom layout.
* @param layout The ordered list of {@link CommandButton}. * @param layout The ordered list of {@linkplain CommandButton command buttons}.
*/ */
public final ListenableFuture<SessionResult> setCustomLayout( public final ListenableFuture<SessionResult> setCustomLayout(
ControllerInfo controller, List<CommandButton> layout) { ControllerInfo controller, List<CommandButton> layout) {
@ -752,18 +781,29 @@ public class MediaSession {
} }
/** /**
* Broadcasts the custom layout to all connected Media3 controllers and converts the buttons to * Sets the custom layout that can initially be set when building the session.
* custom actions in the legacy media session playback state (see {@code
* PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}) for legacy
* controllers.
* *
* <p>When converting, the {@link SessionCommand#customExtras custom extras of the session * <p>Calling this method broadcasts the custom layout to all connected Media3 controllers and
* converts the {@linkplain CommandButton command buttons} to {@linkplain
* PlaybackStateCompat.CustomAction custom actions of the playback state} of the platform media
* session (see {@code
* PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). The {@link
* CommandButton#isEnabled} flag is set according to the available commands of the controller and
* overrides a value that has been set by the app. The platform media session won't see any
* commands that are disabled.
*
* <p>On the controller side, {@link
* MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the
* new custom layout is different to the custom layout the {@linkplain
* MediaController#getCustomLayout() controller already has available}.
*
* <p>When converting, the {@linkplain SessionCommand#customExtras custom extras of the session
* command} is used for the extras of the legacy custom action. * command} is used for the extras of the legacy custom action.
* *
* <p>Media3 controllers that connect after calling this method will not receive the broadcast. * <p>Controllers that connect after calling this method will have the new custom layout available
* You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link * with the initial connection result. A custom layout specific to a controller can be set when
* MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers * the controller {@linkplain MediaSession.Callback#onConnect connects} by using an {@link
* aware of the custom layout. * ConnectionResult.AcceptedResultBuilder}.
* *
* @param layout The ordered list of {@link CommandButton}. * @param layout The ordered list of {@link CommandButton}.
*/ */
@ -796,6 +836,18 @@ public class MediaSession {
impl.setAvailableCommands(controller, sessionCommands, playerCommands); impl.setAvailableCommands(controller, sessionCommands, playerCommands);
} }
/**
* Returns the custom layout of the session.
*
* <p>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)}.
*/
@UnstableApi
public ImmutableList<CommandButton> getCustomLayout() {
return impl.getCustomLayout();
}
/** /**
* Broadcasts a custom command to all connected controllers. * Broadcasts a custom command to all connected controllers.
* *
@ -972,18 +1024,22 @@ public class MediaSession {
/** /**
* Called when a controller is about to connect to this session. Return a {@link * Called when a controller is about to connect to this session. Return a {@link
* ConnectionResult result} containing available commands for the controller by using {@link * ConnectionResult result} for the controller by using {@link
* ConnectionResult#accept(SessionCommands, Player.Commands)}. By default it allows all * ConnectionResult#accept(SessionCommands, Player.Commands)} or the {@link
* connection requests and commands. * ConnectionResult.AcceptedResultBuilder}.
*
* <p>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.
* *
* <p>Note that the player commands in {@link ConnectionResult#availablePlayerCommands} will be * <p>Note that the player commands in {@link ConnectionResult#availablePlayerCommands} will be
* intersected with the {@link Player#getAvailableCommands() available commands} of the * intersected with the {@link Player#getAvailableCommands() available commands} of the
* underlying {@link Player} and the controller will only be able to call the commonly available * underlying {@link Player} and the controller will only be able to call the commonly available
* commands. * commands.
* *
* <p>You can reject the connection by returning {@link ConnectionResult#reject()}}. In that * <p>Returning {@link ConnectionResult#reject()} rejects the connection. In that case, the
* case, the controller will get {@link SecurityException} when resolving the {@link * controller will get {@link SecurityException} when resolving the {@link ListenableFuture}
* ListenableFuture} returned by {@link MediaController.Builder#buildAsync()}. * returned by {@link MediaController.Builder#buildAsync()}.
* *
* <p>The controller isn't connected yet, so calls to the controller (e.g. {@link * <p>The controller isn't connected yet, so calls to the controller (e.g. {@link
* #sendCustomCommand}, {@link #setCustomLayout}) will be ignored. Use {@link #onPostConnect} * #sendCustomCommand}, {@link #setCustomLayout}) will be ignored. Use {@link #onPostConnect}
@ -999,10 +1055,7 @@ public class MediaSession {
* @return The {@link ConnectionResult}. * @return The {@link ConnectionResult}.
*/ */
default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) { default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) {
SessionCommands sessionCommands = return new ConnectionResult.AcceptedResultBuilder(session).build();
new SessionCommands.Builder().addAllSessionCommands().build();
Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build();
return ConnectionResult.accept(sessionCommands, playerCommands);
} }
/** /**
@ -1341,10 +1394,97 @@ public class MediaSession {
/** /**
* A result for {@link Callback#onConnect(MediaSession, ControllerInfo)} to denote the set of * A result for {@link Callback#onConnect(MediaSession, ControllerInfo)} to denote the set of
* commands that are available for the given {@link ControllerInfo controller}. * available commands and the custom layout for a {@link ControllerInfo controller}.
*/ */
public static final class ConnectionResult { public static final class ConnectionResult {
/** A builder for {@link ConnectionResult} instances to accept a connection. */
@UnstableApi
public static class AcceptedResultBuilder {
private SessionCommands availableSessionCommands;
private Player.Commands availablePlayerCommands = DEFAULT_PLAYER_COMMANDS;
@Nullable private ImmutableList<CommandButton> customLayout;
/**
* Creates an instance.
*
* @param mediaSession The session for which to create a {@link ConnectionResult}.
*/
public AcceptedResultBuilder(MediaSession mediaSession) {
availableSessionCommands =
mediaSession instanceof MediaLibrarySession
? DEFAULT_SESSION_AND_LIBRARY_COMMANDS
: DEFAULT_SESSION_COMMANDS;
}
/**
* Sets the session commands that are available to the controller that gets this result
* returned when {@linkplain Callback#onConnect(MediaSession, ControllerInfo) connecting}.
*
* <p>The default is {@link ConnectionResult#DEFAULT_SESSION_AND_LIBRARY_COMMANDS} for a
* {@link MediaLibrarySession} and {@link ConnectionResult#DEFAULT_SESSION_COMMANDS} for a
* {@link MediaSession}.
*/
@CanIgnoreReturnValue
public AcceptedResultBuilder setAvailableSessionCommands(
SessionCommands availableSessionCommands) {
this.availableSessionCommands = checkNotNull(availableSessionCommands);
return this;
}
/**
* Sets the player commands that are available to the controller that gets this result
* returned when {@linkplain Callback#onConnect(MediaSession, ControllerInfo) connecting}.
*
* <p>This set of available player commands is intersected with the actual player commands
* supported by a player. The resulting intersection is the set of commands actually being
* available to a controller.
*
* <p>The default is {@link ConnectionResult#DEFAULT_PLAYER_COMMANDS}.
*/
@CanIgnoreReturnValue
public AcceptedResultBuilder setAvailablePlayerCommands(
Player.Commands availablePlayerCommands) {
this.availablePlayerCommands = checkNotNull(availablePlayerCommands);
return this;
}
/**
* Sets the custom layout, overriding the {@linkplain MediaSession#getCustomLayout() custom
* layout of the session}.
*
* <p>The default is null to indicate that the custom layout of the session should be used.
*
* <p>Make sure to have the session commands of all command buttons of the custom layout
* included in the {@linkplain #setAvailableSessionCommands(SessionCommands)} available
* session commands}.
*/
@CanIgnoreReturnValue
public AcceptedResultBuilder setCustomLayout(
@Nullable ImmutableList<CommandButton> customLayout) {
this.customLayout = customLayout;
return this;
}
/** Returns a new {@link ConnectionResult} instance for accepting a connection. */
public ConnectionResult build() {
return new ConnectionResult(
/* accepted= */ true, availableSessionCommands, availablePlayerCommands, customLayout);
}
}
@UnstableApi
public static final SessionCommands DEFAULT_SESSION_COMMANDS =
new SessionCommands.Builder().addAllSessionCommands().build();
@UnstableApi
public static final SessionCommands DEFAULT_SESSION_AND_LIBRARY_COMMANDS =
new SessionCommands.Builder().addAllLibraryCommands().addAllSessionCommands().build();
@UnstableApi
public static final Player.Commands DEFAULT_PLAYER_COMMANDS =
new Player.Commands.Builder().addAllCommands().build();
/** Whether the connection request is accepted or not. */ /** Whether the connection request is accepted or not. */
public final boolean isAccepted; public final boolean isAccepted;
@ -1354,25 +1494,44 @@ public class MediaSession {
/** Available player commands. */ /** Available player commands. */
public final Player.Commands availablePlayerCommands; public final Player.Commands availablePlayerCommands;
/** The custom layout or null if the custom layout of the session should be used. */
@UnstableApi @Nullable public final ImmutableList<CommandButton> customLayout;
/** Creates a new instance with the given available session and player commands. */ /** Creates a new instance with the given available session and player commands. */
private ConnectionResult( private ConnectionResult(
boolean accepted, boolean accepted,
SessionCommands availableSessionCommands, SessionCommands availableSessionCommands,
Player.Commands availablePlayerCommands) { Player.Commands availablePlayerCommands,
@Nullable ImmutableList<CommandButton> customLayout) {
isAccepted = accepted; isAccepted = accepted;
this.availableSessionCommands = checkNotNull(availableSessionCommands); this.availableSessionCommands = availableSessionCommands;
this.availablePlayerCommands = checkNotNull(availablePlayerCommands); this.availablePlayerCommands = availablePlayerCommands;
this.customLayout = customLayout;
} }
/**
* Creates a connection result with the given session and player commands.
*
* <p>Commands are specific to the controller receiving this connection result.
*
* <p>The controller receives {@linkplain MediaSession#getCustomLayout() the custom layout of
* the session}.
*
* <p>See {@link AcceptedResultBuilder} for a more flexible way to accept a connection.
*/
public static ConnectionResult accept( public static ConnectionResult accept(
SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands) { SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands) {
return new ConnectionResult( return new ConnectionResult(
/* accepted= */ true, availableSessionCommands, availablePlayerCommands); /* accepted= */ true,
availableSessionCommands,
availablePlayerCommands,
/* customLayout= */ null);
} }
/** Creates a {@link ConnectionResult} to reject a connection. */
public static ConnectionResult reject() { public static ConnectionResult reject() {
return new ConnectionResult( return new ConnectionResult(
/* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY); /* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY, ImmutableList.of());
} }
} }
@ -1532,9 +1691,8 @@ public class MediaSession {
} }
/** /**
* A base class for {@link MediaSession.Builder} and {@link * A base class for {@link MediaSession.Builder} and {@link MediaLibrarySession.Builder}. Any
* MediaLibraryService.MediaLibrarySession.Builder}. Any changes to this class should be also * changes to this class should be also applied to the subclasses.
* applied to the subclasses.
*/ */
/* package */ abstract static class BuilderBase< /* package */ abstract static class BuilderBase<
SessionT extends MediaSession, SessionT extends MediaSession,
@ -1549,6 +1707,8 @@ public class MediaSession {
/* package */ Bundle extras; /* package */ Bundle extras;
/* package */ @MonotonicNonNull BitmapLoader bitmapLoader; /* package */ @MonotonicNonNull BitmapLoader bitmapLoader;
/* package */ ImmutableList<CommandButton> customLayout;
public BuilderBase(Context context, Player player, CallbackT callback) { public BuilderBase(Context context, Player player, CallbackT callback) {
this.context = checkNotNull(context); this.context = checkNotNull(context);
this.player = checkNotNull(player); this.player = checkNotNull(player);
@ -1556,6 +1716,7 @@ public class MediaSession {
id = ""; id = "";
this.callback = callback; this.callback = callback;
extras = Bundle.EMPTY; extras = Bundle.EMPTY;
customLayout = ImmutableList.of();
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@ -1588,6 +1749,12 @@ public class MediaSession {
return (BuilderT) this; return (BuilderT) this;
} }
@SuppressWarnings("unchecked")
public BuilderT setCustomLayout(List<CommandButton> customLayout) {
this.customLayout = ImmutableList.copyOf(customLayout);
return (BuilderT) this;
}
public abstract SessionT build(); public abstract SessionT build();
} }
} }

View file

@ -128,6 +128,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
// Should be only accessed on the application looper // Should be only accessed on the application looper
private long sessionPositionUpdateDelayMs; private long sessionPositionUpdateDelayMs;
private ImmutableList<CommandButton> customLayout;
public MediaSessionImpl( public MediaSessionImpl(
MediaSession instance, MediaSession instance,
@ -135,6 +136,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
String id, String id,
Player player, Player player,
@Nullable PendingIntent sessionActivity, @Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaSession.Callback callback, MediaSession.Callback callback,
Bundle tokenExtras, Bundle tokenExtras,
BitmapLoader bitmapLoader) { BitmapLoader bitmapLoader) {
@ -147,6 +149,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
sessionStub = new MediaSessionStub(thisRef); sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity; this.sessionActivity = sessionActivity;
this.customLayout = customLayout;
mainHandler = new Handler(Looper.getMainLooper()); mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper()); applicationHandler = new Handler(player.getApplicationLooper());
@ -187,6 +190,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
PlayerWrapper playerWrapper = new PlayerWrapper(player); PlayerWrapper playerWrapper = new PlayerWrapper(player);
this.playerWrapper = playerWrapper; this.playerWrapper = playerWrapper;
this.playerWrapper.setCustomLayout(customLayout);
postOrRun( postOrRun(
applicationHandler, applicationHandler,
() -> () ->
@ -209,6 +213,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private void setPlayerInternal( private void setPlayerInternal(
@Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) {
playerWrapper = newPlayerWrapper; playerWrapper = newPlayerWrapper;
playerWrapper.setCustomLayout(customLayout);
if (oldPlayerWrapper != null) { if (oldPlayerWrapper != null) {
oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener)); oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener));
} }
@ -307,7 +312,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
public void setCustomLayout(List<CommandButton> layout) { public void setCustomLayout(List<CommandButton> layout) {
playerWrapper.setCustomLayout(ImmutableList.copyOf(layout)); customLayout = ImmutableList.copyOf(layout);
playerWrapper.setCustomLayout(customLayout);
dispatchRemoteControllerTaskWithoutReturn( dispatchRemoteControllerTaskWithoutReturn(
(controller, seq) -> controller.setCustomLayout(seq, layout)); (controller, seq) -> controller.setCustomLayout(seq, layout));
} }
@ -503,6 +509,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return sessionActivity; return sessionActivity;
} }
protected ImmutableList<CommandButton> getCustomLayout() {
return customLayout;
}
@UnstableApi @UnstableApi
protected void setSessionActivity(PendingIntent sessionActivity) { protected void setSessionActivity(PendingIntent sessionActivity) {
if (Objects.equals(this.sessionActivity, sessionActivity)) { if (Objects.equals(this.sessionActivity, sessionActivity)) {

View file

@ -522,6 +522,9 @@ import java.util.concurrent.ExecutionException;
MediaSessionStub.VERSION_INT, MediaSessionStub.VERSION_INT,
MediaSessionStub.this, MediaSessionStub.this,
sessionImpl.getSessionActivity(), sessionImpl.getSessionActivity(),
connectionResult.customLayout != null
? connectionResult.customLayout
: sessionImpl.getCustomLayout(),
connectionResult.availableSessionCommands, connectionResult.availableSessionCommands,
connectionResult.availablePlayerCommands, connectionResult.availablePlayerCommands,
playerWrapper.getAvailableCommands(), playerWrapper.getAvailableCommands(),

View file

@ -0,0 +1,137 @@
/*
* Copyright 2023 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.content.Context;
import androidx.annotation.Nullable;
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;
import org.robolectric.Robolectric;
import org.robolectric.android.controller.ServiceController;
@RunWith(AndroidJUnit4.class)
public class ConnectionResultTest {
@Test
public void acceptedResultBuilder_builtWidthMediaSession_correctDefaults() {
Context context = ApplicationProvider.getApplicationContext();
MediaSession mediaSession =
new MediaSession.Builder(context, new TestExoPlayerBuilder(context).build()).build();
MediaSession.ConnectionResult connectionResult =
new MediaSession.ConnectionResult.AcceptedResultBuilder(mediaSession).build();
assertThat(connectionResult.availableSessionCommands)
.isEqualTo(MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS);
assertThat(connectionResult.availablePlayerCommands)
.isEqualTo(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS);
assertThat(connectionResult.customLayout).isNull();
assertThat(connectionResult.isAccepted).isTrue();
mediaSession.getPlayer().release();
mediaSession.release();
}
@Test
public void acceptedResultBuilder_builtWidthMediaSession_correctlyOverridden() {
Context context = ApplicationProvider.getApplicationContext();
MediaSession mediaSession =
new MediaSession.Builder(context, new TestExoPlayerBuilder(context).build()).build();
MediaSession.ConnectionResult connectionResult =
new MediaSession.ConnectionResult.AcceptedResultBuilder(mediaSession)
.setAvailableSessionCommands(SessionCommands.EMPTY)
.setAvailablePlayerCommands(Player.Commands.EMPTY)
.setCustomLayout(ImmutableList.of())
.build();
assertThat(connectionResult.availableSessionCommands.commands).isEmpty();
assertThat(connectionResult.availablePlayerCommands.size()).isEqualTo(0);
assertThat(connectionResult.customLayout).isEmpty();
assertThat(connectionResult.isAccepted).isTrue();
mediaSession.getPlayer().release();
mediaSession.release();
}
@Test
public void
acceptedResultBuilder_builtWidthMediaLibrarySession_correctDefaultLibrarySessionCommands() {
Context context = ApplicationProvider.getApplicationContext();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get();
MediaSession mediaLibrarySession =
new MediaLibraryService.MediaLibrarySession.Builder(
service,
new TestExoPlayerBuilder(context).build(),
new MediaLibraryService.MediaLibrarySession.Callback() {})
.build();
MediaSession.ConnectionResult connectionResult =
new MediaSession.ConnectionResult.AcceptedResultBuilder(mediaLibrarySession).build();
assertThat(connectionResult.availableSessionCommands)
.isEqualTo(MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS);
assertThat(connectionResult.availablePlayerCommands)
.isEqualTo(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS);
assertThat(connectionResult.customLayout).isNull();
assertThat(connectionResult.isAccepted).isTrue();
mediaLibrarySession.getPlayer().release();
mediaLibrarySession.release();
serviceController.destroy();
}
@Test
public void accept() {
SessionCommands sessionCommands =
new SessionCommands.Builder().add(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM).build();
Player.Commands playerCommands =
new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build();
MediaSession.ConnectionResult connectionResult =
MediaSession.ConnectionResult.accept(sessionCommands, playerCommands);
assertThat(connectionResult.availableSessionCommands).isEqualTo(sessionCommands);
assertThat(connectionResult.availablePlayerCommands).isEqualTo(playerCommands);
assertThat(connectionResult.customLayout).isNull();
assertThat(connectionResult.isAccepted).isTrue();
}
@Test
public void reject() {
MediaSession.ConnectionResult connectionResult = MediaSession.ConnectionResult.reject();
assertThat(connectionResult.availableSessionCommands.commands).isEmpty();
assertThat(connectionResult.availablePlayerCommands.size()).isEqualTo(0);
assertThat(connectionResult.customLayout).isEmpty();
assertThat(connectionResult.isAccepted).isFalse();
}
private static final class TestService extends MediaLibraryService {
@Nullable
@Override
public MediaLibrarySession onGetSession(MediaSession.ControllerInfo controllerInfo) {
return null;
}
}
}

View file

@ -99,6 +99,7 @@ interface IRemoteMediaController {
int page, int page,
int pageSize, int pageSize,
in Bundle libraryParams); in Bundle libraryParams);
Bundle getCustomLayout(String controllerId);
Bundle getItem(String controllerId, String mediaId); Bundle getItem(String controllerId, String mediaId);
Bundle search(String controllerId, String query, in Bundle libraryParams); Bundle search(String controllerId, String query, in Bundle libraryParams);
Bundle getSearchResult( Bundle getSearchResult(

View file

@ -102,6 +102,7 @@ public class CommonConstants {
public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters"; public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters";
public static final String KEY_CURRENT_TRACKS = "currentTracks"; public static final String KEY_CURRENT_TRACKS = "currentTracks";
public static final String KEY_AVAILABLE_COMMANDS = "availableCommands"; public static final String KEY_AVAILABLE_COMMANDS = "availableCommands";
public static final String KEY_COMMAND_BUTTON_LIST = "command_button_list";
// SessionCompat arguments // SessionCompat arguments
public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken"; public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken";

View file

@ -20,6 +20,7 @@ public class MediaSessionConstants {
// Test method names // Test method names
public static final String TEST_GET_SESSION_ACTIVITY = "testGetSessionActivity"; public static final String TEST_GET_SESSION_ACTIVITY = "testGetSessionActivity";
public static final String TEST_GET_CUSTOM_LAYOUT = "testGetCustomLayout";
public static final String TEST_WITH_CUSTOM_COMMANDS = "testWithCustomCommands"; public static final String TEST_WITH_CUSTOM_COMMANDS = "testWithCustomCommands";
public static final String TEST_CONTROLLER_LISTENER_SESSION_REJECTS = "connection_sessionRejects"; public static final String TEST_CONTROLLER_LISTENER_SESSION_REJECTS = "connection_sessionRejects";
public static final String TEST_IS_SESSION_COMMAND_AVAILABLE = "testIsSessionCommandAvailable"; public static final String TEST_IS_SESSION_COMMAND_AVAILABLE = "testIsSessionCommandAvailable";

View file

@ -43,6 +43,7 @@ import androidx.media3.common.Timeline;
import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Consumer; import androidx.media3.common.util.Consumer;
import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.test.session.R;
import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
@ -1435,6 +1436,93 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
threadTestRule.getHandler().postAndSync(player::release); threadTestRule.getHandler().postAndSync(player::release);
} }
@Test
public void
playerWithCustomLayout_sessionBuiltWithCustomLayout_customActionsInInitialPlaybackState()
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<CommandButton> customLayout =
ImmutableList.of(
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_play)
.setSessionCommand(command1)
.build(),
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_pause)
.setSessionCommand(command2)
.build());
MediaSession mediaSession = createMediaSession(player, /* callback= */ null, customLayout);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
// Wait until a playback state is sent to the controller.
PlaybackStateCompat firstPlaybackState =
getFirstPlaybackState(controllerCompat, threadTestRule.getHandler());
assertThat(MediaUtils.convertToCustomLayout(firstPlaybackState))
.containsExactly(
customLayout.get(0).copyWithIsEnabled(true),
customLayout.get(1).copyWithIsEnabled(true))
.inOrder();
mediaSession.release();
releasePlayer(player);
}
@Test
public void playerWithCustomLayout_setCustomLayout_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<CommandButton> customLayout =
ImmutableList.of(
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_play)
.setSessionCommand(command1)
.build(),
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_pause)
.setSessionCommand(command2)
.build());
MediaSession mediaSession = createMediaSession(player);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
AtomicReference<List<CommandButton>> reportedCustomLayout = new AtomicReference<>();
// Wait until a playback state is sent to the controller.
getFirstPlaybackState(controllerCompat, threadTestRule.getHandler());
CountDownLatch latch = new CountDownLatch(1);
controllerCompat.registerCallback(
new MediaControllerCompat.Callback() {
@Override
public void onPlaybackStateChanged(PlaybackStateCompat state) {
reportedCustomLayout.set(MediaUtils.convertToCustomLayout(state));
latch.countDown();
}
},
threadTestRule.getHandler());
getInstrumentation().runOnMainSync(() -> mediaSession.setCustomLayout(customLayout));
assertThat(reportedCustomLayout.get())
.containsExactly(
customLayout.get(0).copyWithIsEnabled(true),
customLayout.get(1).copyWithIsEnabled(true));
mediaSession.release();
releasePlayer(player);
}
private PlaybackStateCompat getFirstPlaybackState( private PlaybackStateCompat getFirstPlaybackState(
MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException { MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException {
LinkedBlockingDeque<PlaybackStateCompat> playbackStateCompats = new LinkedBlockingDeque<>(); LinkedBlockingDeque<PlaybackStateCompat> playbackStateCompats = new LinkedBlockingDeque<>();
@ -1477,13 +1565,19 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
} }
private static MediaSession createMediaSession(Player player) { private static MediaSession createMediaSession(Player player) {
return createMediaSession(player, null); return createMediaSession(player, /* callback= */ null);
} }
private static MediaSession createMediaSession( private static MediaSession createMediaSession(
Player player, @Nullable MediaSession.Callback callback) { Player player, @Nullable MediaSession.Callback callback) {
return createMediaSession(player, callback, /* customLayout= */ ImmutableList.of());
}
private static MediaSession createMediaSession(
Player player, @Nullable MediaSession.Callback callback, List<CommandButton> customLayout) {
MediaSession.Builder session = MediaSession.Builder session =
new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player); new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player)
.setCustomLayout(customLayout);
if (callback != null) { if (callback != null) {
session.setCallback(callback); session.setCallback(callback);
} }

View file

@ -34,7 +34,9 @@ import androidx.media3.common.DeviceInfo;
import androidx.media3.common.FlagSet; import androidx.media3.common.FlagSet;
import androidx.media3.common.MediaMetadata; import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.test.session.R;
import androidx.media3.test.session.common.CommonConstants; import androidx.media3.test.session.common.CommonConstants;
import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.media3.test.session.common.MainLooperTestRule; import androidx.media3.test.session.common.MainLooperTestRule;
@ -42,6 +44,7 @@ import androidx.media3.test.session.common.TestUtils;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList; import java.util.ArrayList;
@ -361,4 +364,87 @@ public class MediaControllerListenerWithMediaSessionCompatTest {
assertThat(deviceVolumeOnEvents.get()).isEqualTo(50); assertThat(deviceVolumeOnEvents.get()).isEqualTo(50);
assertThat(getEventsAsList(onEvents.get())).contains(Player.EVENT_DEVICE_VOLUME_CHANGED); assertThat(getEventsAsList(onEvents.get())).contains(Player.EVENT_DEVICE_VOLUME_CHANGED);
} }
@Test
public void getCustomLayout() throws Exception {
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command1", Bundle.EMPTY))
.build();
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command2", Bundle.EMPTY))
.build();
ConditionVariable onSetCustomLayoutCalled = new ConditionVariable();
ConditionVariable onCustomLayoutChangedCalled = new ConditionVariable();
List<List<CommandButton>> setCustomLayoutArguments = new ArrayList<>();
List<List<CommandButton>> customLayoutChangedArguments = new ArrayList<>();
List<List<CommandButton>> customLayoutFromGetter = new ArrayList<>();
controllerTestRule.createController(
session.getSessionToken(),
new MediaController.Listener() {
@Override
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
setCustomLayoutArguments.add(layout);
onSetCustomLayoutCalled.open();
return MediaController.Listener.super.onSetCustomLayout(controller, layout);
}
@Override
public void onCustomLayoutChanged(
MediaController controller, List<CommandButton> layout) {
customLayoutChangedArguments.add(layout);
customLayoutFromGetter.add(controller.getCustomLayout());
onCustomLayoutChangedCalled.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");
PlaybackStateCompat.CustomAction customAction2 =
new PlaybackStateCompat.CustomAction.Builder(
"command2", "button2", /* icon= */ R.drawable.media3_notification_small_icon)
.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(onSetCustomLayoutCalled.block(TIMEOUT_MS)).isTrue();
assertThat(onCustomLayoutChangedCalled.block(TIMEOUT_MS)).isTrue();
onSetCustomLayoutCalled.close();
onCustomLayoutChangedCalled.close();
session.setPlaybackState(playbackState2.build());
assertThat(onSetCustomLayoutCalled.block(TIMEOUT_MS)).isTrue();
assertThat(onCustomLayoutChangedCalled.block(TIMEOUT_MS)).isTrue();
ImmutableList<CommandButton> expectedFirstCustomLayout =
ImmutableList.of(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true));
ImmutableList<CommandButton> expectedSecondCustomLayout =
ImmutableList.of(button1.copyWithIsEnabled(true));
assertThat(setCustomLayoutArguments)
.containsExactly(expectedFirstCustomLayout, expectedSecondCustomLayout)
.inOrder();
assertThat(customLayoutChangedArguments)
.containsExactly(expectedFirstCustomLayout, expectedSecondCustomLayout)
.inOrder();
assertThat(customLayoutFromGetter)
.containsExactly(expectedFirstCustomLayout, expectedSecondCustomLayout)
.inOrder();
}
} }

View file

@ -21,6 +21,7 @@ import static androidx.media3.session.MediaUtils.createPlayerCommandsWithout;
import static androidx.media3.test.session.common.CommonConstants.DEFAULT_TEST_NAME; import static androidx.media3.test.session.common.CommonConstants.DEFAULT_TEST_NAME;
import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME; import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME;
import static androidx.media3.test.session.common.MediaSessionConstants.KEY_AVAILABLE_SESSION_COMMANDS; import static androidx.media3.test.session.common.MediaSessionConstants.KEY_AVAILABLE_SESSION_COMMANDS;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE;
import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS;
@ -57,6 +58,7 @@ import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks; import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize; import androidx.media3.common.VideoSize;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.test.session.R;
import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.media3.test.session.common.MainLooperTestRule; import androidx.media3.test.session.common.MainLooperTestRule;
import androidx.media3.test.session.common.PollingCheck; import androidx.media3.test.session.common.PollingCheck;
@ -65,6 +67,8 @@ import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@ -169,6 +173,255 @@ public class MediaControllerTest {
session.cleanUp(); session.cleanUp();
} }
@Test
public void getCustomLayout_customLayoutBuiltWithSession_includedOnConnect() throws Exception {
RemoteMediaSession session =
createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null);
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command1", Bundle.EMPTY))
.build();
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command2", Bundle.EMPTY))
.build();
CommandButton button3 =
new CommandButton.Builder()
.setDisplayName("button3")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command3", Bundle.EMPTY))
.build();
setupCustomLayout(session, ImmutableList.of(button1, button2, button3));
MediaController controller = controllerTestRule.createController(session.getToken());
assertThat(threadTestRule.getHandler().postAndSync(controller::getCustomLayout))
.containsExactly(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true), button3)
.inOrder();
session.cleanUp();
}
@Test
public void getCustomLayout_sessionSetCustomLayout_customLayoutChanged() throws Exception {
RemoteMediaSession session =
createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null);
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command1", Bundle.EMPTY))
.build();
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command2", Bundle.EMPTY))
.build();
CommandButton button3 =
new CommandButton.Builder()
.setDisplayName("button3")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command3", Bundle.EMPTY))
.build();
CommandButton button4 =
new CommandButton.Builder()
.setDisplayName("button4")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command4", Bundle.EMPTY))
.build();
setupCustomLayout(session, ImmutableList.of(button1, button2));
CountDownLatch latch = new CountDownLatch(2);
AtomicReference<List<CommandButton>> reportedCustomLayout = new AtomicReference<>();
AtomicReference<List<CommandButton>> reportedCustomLayoutChanged = new AtomicReference<>();
MediaController controller =
controllerTestRule.createController(
session.getToken(),
Bundle.EMPTY,
new MediaController.Listener() {
@Override
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller1, List<CommandButton> layout) {
latch.countDown();
reportedCustomLayout.set(layout);
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
@Override
public void onCustomLayoutChanged(
MediaController controller1, List<CommandButton> layout) {
reportedCustomLayoutChanged.set(layout);
latch.countDown();
}
});
ImmutableList<CommandButton> initialCustomLayoutFromGetter =
threadTestRule.getHandler().postAndSync(controller::getCustomLayout);
session.setCustomLayout(ImmutableList.of(button3, button4));
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
ImmutableList<CommandButton> newCustomLayoutFromGetter =
threadTestRule.getHandler().postAndSync(controller::getCustomLayout);
assertThat(initialCustomLayoutFromGetter)
.containsExactly(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true))
.inOrder();
assertThat(newCustomLayoutFromGetter).containsExactly(button3, button4).inOrder();
assertThat(reportedCustomLayout.get()).containsExactly(button3, button4).inOrder();
assertThat(reportedCustomLayoutChanged.get()).containsExactly(button3, button4).inOrder();
session.cleanUp();
}
@Test
public void getCustomLayout_setAvailableCommandsAddOrRemoveCommands_reportsCustomLayoutChanged()
throws Exception {
RemoteMediaSession session = createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, null);
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command1", Bundle.EMPTY))
.build();
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command2", Bundle.EMPTY))
.build();
setupCustomLayout(session, ImmutableList.of(button1, button2));
CountDownLatch latch = new CountDownLatch(2);
List<List<CommandButton>> reportedCustomLayoutChanged = new ArrayList<>();
List<List<CommandButton>> getterCustomLayoutChanged = new ArrayList<>();
MediaController.Listener listener =
new MediaController.Listener() {
@Override
public void onCustomLayoutChanged(
MediaController controller, List<CommandButton> layout) {
reportedCustomLayoutChanged.add(layout);
getterCustomLayoutChanged.add(controller.getCustomLayout());
latch.countDown();
}
};
MediaController controller =
controllerTestRule.createController(
session.getToken(), /* connectionHints= */ Bundle.EMPTY, listener);
ImmutableList<CommandButton> initialCustomLayout =
threadTestRule.getHandler().postAndSync(controller::getCustomLayout);
// Remove commands in custom layout from available commands.
session.setAvailableCommands(SessionCommands.EMPTY, Player.Commands.EMPTY);
// Add one command back.
session.setAvailableCommands(
new SessionCommands.Builder().add(button2.sessionCommand).build(), Player.Commands.EMPTY);
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(initialCustomLayout)
.containsExactly(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true));
assertThat(reportedCustomLayoutChanged).hasSize(2);
assertThat(reportedCustomLayoutChanged.get(0)).containsExactly(button1, button2).inOrder();
assertThat(reportedCustomLayoutChanged.get(1))
.containsExactly(button1, button2.copyWithIsEnabled(true))
.inOrder();
assertThat(getterCustomLayoutChanged).hasSize(2);
assertThat(getterCustomLayoutChanged.get(0)).containsExactly(button1, button2).inOrder();
assertThat(getterCustomLayoutChanged.get(1))
.containsExactly(button1, button2.copyWithIsEnabled(true))
.inOrder();
}
@Test
public void getCustomLayout_sessionSetCustomLayoutNoChange_listenerNotCalledWithEqualLayout()
throws Exception {
RemoteMediaSession session =
createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null);
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command1", Bundle.EMPTY))
.build();
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command2", Bundle.EMPTY))
.build();
CommandButton button3 =
new CommandButton.Builder()
.setDisplayName("button3")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command3", Bundle.EMPTY))
.build();
CommandButton button4 =
new CommandButton.Builder()
.setDisplayName("button4")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(new SessionCommand("command4", Bundle.EMPTY))
.build();
setupCustomLayout(session, ImmutableList.of(button1, button2));
CountDownLatch latch = new CountDownLatch(5);
List<List<CommandButton>> reportedCustomLayout = new ArrayList<>();
List<List<CommandButton>> getterCustomLayout = new ArrayList<>();
List<List<CommandButton>> reportedCustomLayoutChanged = new ArrayList<>();
List<List<CommandButton>> getterCustomLayoutChanged = new ArrayList<>();
MediaController.Listener listener =
new MediaController.Listener() {
@Override
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
reportedCustomLayout.add(layout);
getterCustomLayout.add(controller.getCustomLayout());
latch.countDown();
return MediaController.Listener.super.onSetCustomLayout(controller, layout);
}
@Override
public void onCustomLayoutChanged(
MediaController controller, List<CommandButton> layout) {
reportedCustomLayoutChanged.add(layout);
getterCustomLayoutChanged.add(controller.getCustomLayout());
latch.countDown();
}
};
MediaController controller =
controllerTestRule.createController(session.getToken(), Bundle.EMPTY, listener);
ImmutableList<CommandButton> initialCustomLayout =
threadTestRule.getHandler().postAndSync(controller::getCustomLayout);
// First call does not trigger onCustomLayoutChanged.
session.setCustomLayout(ImmutableList.of(button1, button2));
session.setCustomLayout(ImmutableList.of(button3, button4));
session.setCustomLayout(ImmutableList.of(button1, button2));
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
CommandButton button1Enabled = button1.copyWithIsEnabled(true);
CommandButton button2Enabled = button2.copyWithIsEnabled(true);
assertThat(initialCustomLayout).containsExactly(button1Enabled, button2Enabled).inOrder();
assertThat(reportedCustomLayout)
.containsExactly(
ImmutableList.of(button1Enabled, button2Enabled),
ImmutableList.of(button3, button4),
ImmutableList.of(button1Enabled, button2Enabled))
.inOrder();
assertThat(getterCustomLayout)
.containsExactly(
ImmutableList.of(button1Enabled, button2Enabled),
ImmutableList.of(button3, button4),
ImmutableList.of(button1Enabled, button2Enabled))
.inOrder();
assertThat(reportedCustomLayoutChanged)
.containsExactly(
ImmutableList.of(button3, button4), ImmutableList.of(button1Enabled, button2Enabled))
.inOrder();
assertThat(getterCustomLayoutChanged)
.containsExactly(
ImmutableList.of(button3, button4), ImmutableList.of(button1Enabled, button2Enabled))
.inOrder();
session.cleanUp();
}
@Test @Test
public void getPackageName() throws Exception { public void getPackageName() throws Exception {
MediaController controller = controllerTestRule.createController(remoteSession.getToken()); MediaController controller = controllerTestRule.createController(remoteSession.getToken());
@ -1461,4 +1714,21 @@ public class MediaControllerTest {
return controller.getCurrentMediaItemIndex(); return controller.getCurrentMediaItemIndex();
})); }));
} }
private void setupCustomLayout(RemoteMediaSession session, List<CommandButton> customLayout)
throws RemoteException, InterruptedException, Exception {
CountDownLatch latch = new CountDownLatch(1);
controllerTestRule.createController(
session.getToken(),
/* connectionHints= */ null,
new MediaController.Listener() {
@Override
public void onCustomLayoutChanged(
MediaController controller, List<CommandButton> layout) {
latch.countDown();
}
});
session.setCustomLayout(ImmutableList.copyOf(customLayout));
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
}
} }

View file

@ -17,6 +17,7 @@ package androidx.media3.session;
import static androidx.media3.session.MediaTestUtils.createMediaItem; import static androidx.media3.session.MediaTestUtils.createMediaItem;
import static androidx.media3.session.SessionResult.RESULT_ERROR_INVALID_STATE; import static androidx.media3.session.SessionResult.RESULT_ERROR_INVALID_STATE;
import static androidx.media3.session.SessionResult.RESULT_ERROR_PERMISSION_DENIED;
import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED; import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
import static androidx.media3.session.SessionResult.RESULT_SUCCESS; import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import static androidx.media3.test.session.common.CommonConstants.METADATA_MEDIA_URI; import static androidx.media3.test.session.common.CommonConstants.METADATA_MEDIA_URI;
@ -35,7 +36,9 @@ import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.Rating; import androidx.media3.common.Rating;
import androidx.media3.common.StarRating; import androidx.media3.common.StarRating;
import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder;
import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.session.MediaSession.ControllerInfo;
import androidx.media3.test.session.R;
import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.media3.test.session.common.MainLooperTestRule; import androidx.media3.test.session.common.MainLooperTestRule;
import androidx.media3.test.session.common.TestUtils; import androidx.media3.test.session.common.TestUtils;
@ -130,6 +133,66 @@ public class MediaSessionCallbackTest {
assertThat(controllerInterfaceVersion.get()).isEqualTo(MediaControllerStub.VERSION_INT); assertThat(controllerInterfaceVersion.get()).isEqualTo(MediaControllerStub.VERSION_INT);
} }
@Test
public void onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied()
throws Exception {
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_play)
.setSessionCommand(new SessionCommand("command1", Bundle.EMPTY))
.setEnabled(true)
.build();
CommandButton button1Disabled = button1.copyWithIsEnabled(false);
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_pause)
.setSessionCommand(new SessionCommand("command2", Bundle.EMPTY))
.setEnabled(true)
.build();
ImmutableList<CommandButton> customLayout = ImmutableList.of(button1, button2);
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())
.setCustomLayout(ImmutableList.of(button1, button2))
.build();
}
@Override
public ListenableFuture<SessionResult> 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)
.setCustomLayout(customLayout)
.setId(
"onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied")
.build());
RemoteMediaController remoteController =
controllerTestRule.createRemoteController(session.getToken());
ImmutableList<CommandButton> layout = remoteController.getCustomLayout();
assertThat(layout).containsExactly(button1Disabled, button2).inOrder();
assertThat(remoteController.sendCustomCommand(button1.sessionCommand, Bundle.EMPTY).resultCode)
.isEqualTo(RESULT_ERROR_PERMISSION_DENIED);
assertThat(remoteController.sendCustomCommand(button2.sessionCommand, Bundle.EMPTY).resultCode)
.isEqualTo(RESULT_SUCCESS);
}
@Test @Test
public void onPostConnect_afterConnected() throws Exception { public void onPostConnect_afterConnected() throws Exception {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);

View file

@ -16,6 +16,7 @@
package androidx.media3.session; package androidx.media3.session;
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_CONTROLLER; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_CONTROLLER;
import static androidx.media3.test.session.common.CommonConstants.KEY_COMMAND_BUTTON_LIST;
import static androidx.media3.test.session.common.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS;
import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MILLISECONDS;
@ -800,6 +801,19 @@ public class MediaControllerProviderService extends Service {
return result.toBundle(); return result.toBundle();
} }
@Override
public Bundle getCustomLayout(String controllerId) throws RemoteException {
MediaController controller = mediaControllerMap.get(controllerId);
ArrayList<Bundle> customLayout = new ArrayList<>();
ImmutableList<CommandButton> commandButtons = runOnHandler(controller::getCustomLayout);
for (CommandButton button : commandButtons) {
customLayout.add(button.toBundle());
}
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(KEY_COMMAND_BUTTON_LIST, customLayout);
return bundle;
}
@Override @Override
public Bundle getItem(String controllerId, String mediaId) throws RemoteException { public Bundle getItem(String controllerId, String mediaId) throws RemoteException {
MediaBrowser browser = (MediaBrowser) mediaControllerMap.get(controllerId); MediaBrowser browser = (MediaBrowser) mediaControllerMap.get(controllerId);

View file

@ -60,6 +60,7 @@ import static androidx.media3.test.session.common.MediaSessionConstants.KEY_COMM
import static androidx.media3.test.session.common.MediaSessionConstants.KEY_CONTROLLER; import static androidx.media3.test.session.common.MediaSessionConstants.KEY_CONTROLLER;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_COMMAND_GET_TRACKS; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_COMMAND_GET_TRACKS;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CONTROLLER_LISTENER_SESSION_REJECTS; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CONTROLLER_LISTENER_SESSION_REJECTS;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_TRACKS_CHANGED_VIDEO_TO_AUDIO_TRANSITION; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_TRACKS_CHANGED_VIDEO_TO_AUDIO_TRANSITION;
@ -185,6 +186,23 @@ public class MediaSessionProviderService extends Service {
builder.setSessionActivity(pendingIntent); builder.setSessionActivity(pendingIntent);
break; break;
} }
case TEST_GET_CUSTOM_LAYOUT:
{
builder.setCallback(
new MediaSession.Callback() {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) {
return MediaSession.ConnectionResult.accept(
new SessionCommands.Builder()
.add(new SessionCommand("command1", Bundle.EMPTY))
.add(new SessionCommand("command2", Bundle.EMPTY))
.build(),
Player.Commands.EMPTY);
}
});
break;
}
case TEST_WITH_CUSTOM_COMMANDS: case TEST_WITH_CUSTOM_COMMANDS:
{ {
SessionCommands availableSessionCommands = SessionCommands availableSessionCommands =

View file

@ -16,6 +16,7 @@
package androidx.media3.session; package androidx.media3.session;
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_CONTROLLER; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_CONTROLLER;
import static androidx.media3.test.session.common.CommonConstants.KEY_COMMAND_BUTTON_LIST;
import static androidx.media3.test.session.common.CommonConstants.MEDIA3_CONTROLLER_PROVIDER_SERVICE; import static androidx.media3.test.session.common.CommonConstants.MEDIA3_CONTROLLER_PROVIDER_SERVICE;
import static androidx.media3.test.session.common.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS;
import static com.google.common.truth.Truth.assertWithMessage; import static com.google.common.truth.Truth.assertWithMessage;
@ -40,6 +41,8 @@ import androidx.media3.common.util.BundleableUtil;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.test.session.common.IRemoteMediaController; import androidx.media3.test.session.common.IRemoteMediaController;
import androidx.media3.test.session.common.TestUtils; import androidx.media3.test.session.common.TestUtils;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@ -349,6 +352,16 @@ public class RemoteMediaController {
seekIndex); seekIndex);
} }
public ImmutableList<CommandButton> getCustomLayout() throws RemoteException {
Bundle customLayoutBundle = binder.getCustomLayout(controllerId);
ArrayList<Bundle> list = customLayoutBundle.getParcelableArrayList(KEY_COMMAND_BUTTON_LIST);
ImmutableList.Builder<CommandButton> customLayout = new ImmutableList.Builder<>();
for (Bundle bundle : list) {
customLayout.add(CommandButton.CREATOR.fromBundle(bundle));
}
return customLayout.build();
}
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// Non-public methods // Non-public methods
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////

View file

@ -57,6 +57,11 @@ public final class TestMediaBrowserListener implements MediaBrowser.Listener {
return delegate.onSetCustomLayout(controller, layout); return delegate.onSetCustomLayout(controller, layout);
} }
@Override
public void onCustomLayoutChanged(MediaController controller, List<CommandButton> layout) {
delegate.onCustomLayoutChanged(controller, layout);
}
@Override @Override
public void onExtrasChanged(MediaController controller, Bundle extras) { public void onExtrasChanged(MediaController controller, Bundle extras) {
delegate.onExtrasChanged(controller, extras); delegate.onExtrasChanged(controller, extras);