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

#minor-release

PiperOrigin-RevId: 547272588
This commit is contained in:
bachinger 2023-07-11 20:59:01 +01:00 committed by Rohit Singh
parent 02b9d8d8b7
commit ea21d27a69
24 changed files with 1176 additions and 151 deletions

View File

@ -91,6 +91,14 @@
* Muxers:
* IMA extension:
* 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.
* UI:
* Add a `Player.Listener` implementation for Wear OS devices that handles
playback suppression due to

View File

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

View File

@ -28,6 +28,7 @@ import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.base.Objects;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.CheckReturnValue;
import java.util.List;
/**
@ -35,7 +36,7 @@ import java.util.List;
* controllers.
*
* @see MediaSession#setCustomLayout(MediaSession.ControllerInfo, List)
* @see MediaController.Listener#onSetCustomLayout(MediaController, List)
* @see MediaController.Listener#onCustomLayoutChanged(MediaController, List)
*/
public final class CommandButton implements Bundleable {
@ -196,6 +197,19 @@ public final class CommandButton implements Bundleable {
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
public boolean equals(@Nullable Object obj) {
if (this == obj) {

View File

@ -24,7 +24,10 @@ import androidx.annotation.Nullable;
import androidx.core.app.BundleCompat;
import androidx.media3.common.Bundleable;
import androidx.media3.common.Player;
import androidx.media3.common.util.BundleableUtil;
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
@ -50,11 +53,14 @@ import androidx.media3.common.util.Util;
public final PlayerInfo playerInfo;
public final ImmutableList<CommandButton> customLayout;
public ConnectionState(
int libraryVersion,
int sessionInterfaceVersion,
IMediaSession sessionBinder,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
SessionCommands sessionCommands,
Player.Commands playerCommandsFromSession,
Player.Commands playerCommandsFromPlayer,
@ -69,6 +75,7 @@ import androidx.media3.common.util.Util;
this.sessionActivity = sessionActivity;
this.tokenExtras = tokenExtras;
this.playerInfo = playerInfo;
this.customLayout = customLayout;
}
// Bundleable implementation.
@ -76,6 +83,7 @@ import androidx.media3.common.util.Util;
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_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_PLAYER_COMMANDS_FROM_SESSION = Util.intToStringMaxRadix(4);
private static final String FIELD_PLAYER_COMMANDS_FROM_PLAYER = Util.intToStringMaxRadix(5);
@ -83,7 +91,7 @@ import androidx.media3.common.util.Util;
private static final String FIELD_PLAYER_INFO = Util.intToStringMaxRadix(7);
private static final String FIELD_SESSION_INTERFACE_VERSION = Util.intToStringMaxRadix(8);
// Next field key = 9
// Next field key = 10
@Override
public Bundle toBundle() {
@ -91,6 +99,10 @@ import androidx.media3.common.util.Util;
bundle.putInt(FIELD_LIBRARY_VERSION, libraryVersion);
BundleCompat.putBinder(bundle, FIELD_SESSION_BINDER, sessionBinder.asBinder());
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_PLAYER_COMMANDS_FROM_SESSION, playerCommandsFromSession.toBundle());
bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER, playerCommandsFromPlayer.toBundle());
@ -114,6 +126,12 @@ import androidx.media3.common.util.Util;
bundle.getInt(FIELD_SESSION_INTERFACE_VERSION, /* defaultValue= */ 0);
IBinder sessionBinder = checkNotNull(BundleCompat.getBinder(bundle, FIELD_SESSION_BINDER));
@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);
SessionCommands sessionCommands =
sessionCommandsBundle == null
@ -142,6 +160,7 @@ import androidx.media3.common.util.Util;
sessionInterfaceVersion,
IMediaSession.Stub.asInterface(sessionBinder),
sessionActivity,
customLayout,
sessionCommands,
playerCommandsFromSession,
playerCommandsFromPlayer,

View File

@ -58,6 +58,7 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.Size;
import androidx.media3.common.util.UnstableApi;
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.ListenableFuture;
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}.
*
* <p>Return a {@link ListenableFuture} to reply with a {@link SessionResult} to the session
* asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link
* Futures#immediateFuture(Object)}.
* <p>This method will be deprecated. Use {@link #onCustomLayoutChanged(MediaController, List)}
* instead.
*
* <p>The default implementation returns a {@link ListenableFuture} of {@link
* SessionResult#RESULT_ERROR_NOT_SUPPORTED}.
* <p>There is a slight difference in behaviour. This to be deprecated method may be
* 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.
* @param layout The ordered list of {@link CommandButton}.
* @return The result of handling the custom layout.
* <p>Further, when the available commands of a controller change in a way that affect whether
* buttons of the custom layout are enabled or disabled, the new callback {@link
* #onCustomLayoutChanged(MediaController, List)} is called, in which case the deprecated
* callback isn't called.
*/
default ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
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.
*
@ -935,6 +952,20 @@ public class MediaController implements Player {
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}. */
@UnstableApi
@Override
@ -1985,6 +2016,8 @@ public class MediaController implements Player {
ListenableFuture<SessionResult> sendCustomCommand(SessionCommand command, Bundle args);
ImmutableList<CommandButton> getCustomLayout();
Timeline getCurrentTimeline();
void setMediaItem(MediaItem mediaItem);

View File

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

View File

@ -441,6 +441,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
return controllerCompat.getSessionActivity();
}
@Override
public ImmutableList<CommandButton> getCustomLayout() {
return controllerInfo.customLayout;
}
@Override
@Nullable
public PlaybackException getPlayerError() {
@ -1512,6 +1517,8 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
mediaItemTransitionReason);
}
// Calling deprecated listener callback method for backwards compatibility.
@SuppressWarnings("deprecation")
private void updateControllerInfo(
boolean notifyConnected,
LegacyPlayerInfo newLegacyPlayerInfo,
@ -1531,9 +1538,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) {
getInstance()
.notifyControllerListener(
listener ->
ignoreFuture(
listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout)));
listener -> {
ignoreFuture(
listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout));
listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout);
});
}
return;
}
@ -1662,9 +1671,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) {
getInstance()
.notifyControllerListener(
listener ->
ignoreFuture(
listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout)));
listener -> {
ignoreFuture(
listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout));
listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout);
});
}
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.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.List;
/**
* 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 {
@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
* MediaBrowser#getLibraryRoot(LibraryParams)}.
@ -439,6 +432,35 @@ public abstract class MediaLibraryService extends MediaSessionService {
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}.
*
@ -452,7 +474,14 @@ public abstract class MediaLibraryService extends MediaSessionService {
bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
}
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,
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaSession.Callback callback,
Bundle tokenExtras,
BitmapLoader bitmapLoader) {
super(context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader);
super(
context, id, player, sessionActivity, customLayout, callback, tokenExtras, bitmapLoader);
}
@Override
@ -473,6 +504,7 @@ public abstract class MediaLibraryService extends MediaSessionService {
String id,
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaSession.Callback callback,
Bundle tokenExtras,
BitmapLoader bitmapLoader) {
@ -482,6 +514,7 @@ public abstract class MediaLibraryService extends MediaSessionService {
id,
player,
sessionActivity,
customLayout,
(Callback) callback,
tokenExtras,
bitmapLoader);

View File

@ -76,10 +76,20 @@ import java.util.concurrent.Future;
String id,
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaLibrarySession.Callback callback,
Bundle tokenExtras,
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.callback = callback;
subscriptions = new ArrayMap<>();

View File

@ -32,6 +32,7 @@ import android.os.Looper;
import android.os.RemoteException;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.view.KeyEvent;
import androidx.annotation.GuardedBy;
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.Util;
import androidx.media3.session.MediaLibraryService.LibraryParams;
import androidx.media3.session.MediaLibraryService.MediaLibrarySession;
import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Longs;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.DoNotMock;
import java.util.HashMap;
import java.util.List;
@ -348,6 +351,32 @@ public class MediaSession {
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}.
*
@ -361,7 +390,14 @@ public class MediaSession {
bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
}
return new MediaSession(
context, id, player, sessionActivity, callback, extras, checkNotNull(bitmapLoader));
context,
id,
player,
sessionActivity,
customLayout,
callback,
extras,
checkNotNull(bitmapLoader));
}
}
@ -550,6 +586,7 @@ public class MediaSession {
String id,
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
Callback callback,
Bundle tokenExtras,
BitmapLoader bitmapLoader) {
@ -559,7 +596,16 @@ public class MediaSession {
}
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(
@ -567,11 +613,20 @@ public class MediaSession {
String id,
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
Callback callback,
Bundle tokenExtras,
BitmapLoader bitmapLoader) {
return new MediaSessionImpl(
this, context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader);
this,
context,
id,
player,
sessionActivity,
customLayout,
callback,
tokenExtras,
bitmapLoader);
}
/* package */ MediaSessionImpl getImpl() {
@ -697,54 +752,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
* examples. Note: {@code layout[i]} means a {@link CommandButton} at index {@code i} in the given
* list.
* <p>Make sure to have the session commands of all command buttons of the custom layout
* {@linkplain MediaController#getAvailableSessionCommands() available for controllers}. Include
* the custom session commands a controller should be able to send in the available commands of
* the connection result {@linkplain MediaSession.Callback#onConnect(MediaSession, ControllerInfo)
* that your app returns when the controller connects}. The {@link CommandButton#isEnabled} flag
* is set according to the available commands of the controller and overrides a value that may
* have been set by the app.
*
* <table>
* <caption>Examples of controller's UI layout</caption>
* <tr>
* <th>Controller UI layout</th>
* <th>Layout example</th>
* </tr>
* <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>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 {@link
* MediaController#getCustomLayout() controller already has available}.
*
* <p>It's up to controller's decision how to represent the layout in its own UI.
*
* <p>Interoperability: This call has no effect when called for a {@linkplain
* ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}.
*
* @param controller The controller to specify layout.
* @param layout The ordered list of {@link CommandButton}.
* @param controller The controller for which to set the custom layout.
* @param layout The ordered list of {@linkplain CommandButton command buttons}.
*/
public final ListenableFuture<SessionResult> setCustomLayout(
ControllerInfo controller, List<CommandButton> layout) {
@ -754,18 +783,29 @@ public class MediaSession {
}
/**
* Broadcasts the custom layout to all connected Media3 controllers and converts the buttons to
* custom actions in the legacy media session playback state (see {@code
* PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}) for legacy
* controllers.
* Sets the custom layout that can initially be set when building the session.
*
* <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.
*
* <p>Media3 controllers that connect after calling this method will not receive the broadcast.
* You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link
* MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers
* aware of the custom layout.
* <p>Controllers that connect after calling this method will have the new custom layout available
* with the initial connection result. A custom layout specific to a controller can be set when
* the controller {@linkplain MediaSession.Callback#onConnect connects} by using an {@link
* ConnectionResult.AcceptedResultBuilder}.
*
* @param layout The ordered list of {@link CommandButton}.
*/
@ -798,6 +838,18 @@ public class MediaSession {
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.
*
@ -974,18 +1026,22 @@ public class MediaSession {
/**
* 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#accept(SessionCommands, Player.Commands)}. By default it allows all
* connection requests and commands.
* ConnectionResult result} for the controller by using {@link
* ConnectionResult#accept(SessionCommands, Player.Commands)} or the {@link
* 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
* 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
* commands.
*
* <p>You can reject the connection by returning {@link ConnectionResult#reject()}}. In that
* case, the controller will get {@link SecurityException} when resolving the {@link
* ListenableFuture} returned by {@link MediaController.Builder#buildAsync()}.
* <p>Returning {@link ConnectionResult#reject()} rejects the connection. In that case, the
* controller will get {@link SecurityException} when resolving the {@link ListenableFuture}
* returned by {@link MediaController.Builder#buildAsync()}.
*
* <p>The controller isn't connected yet, so calls to the controller (e.g. {@link
* #sendCustomCommand}, {@link #setCustomLayout}) will be ignored. Use {@link #onPostConnect}
@ -1001,10 +1057,7 @@ public class MediaSession {
* @return The {@link ConnectionResult}.
*/
default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) {
SessionCommands sessionCommands =
new SessionCommands.Builder().addAllSessionCommands().build();
Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build();
return ConnectionResult.accept(sessionCommands, playerCommands);
return new ConnectionResult.AcceptedResultBuilder(session).build();
}
/**
@ -1345,10 +1398,97 @@ public class MediaSession {
/**
* 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 {
/** 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. */
public final boolean isAccepted;
@ -1358,25 +1498,44 @@ public class MediaSession {
/** Available player commands. */
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. */
private ConnectionResult(
boolean accepted,
SessionCommands availableSessionCommands,
Player.Commands availablePlayerCommands) {
Player.Commands availablePlayerCommands,
@Nullable ImmutableList<CommandButton> customLayout) {
isAccepted = accepted;
this.availableSessionCommands = checkNotNull(availableSessionCommands);
this.availablePlayerCommands = checkNotNull(availablePlayerCommands);
this.availableSessionCommands = availableSessionCommands;
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(
SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands) {
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() {
return new ConnectionResult(
/* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY);
/* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY, ImmutableList.of());
}
}
@ -1536,9 +1695,8 @@ public class MediaSession {
}
/**
* A base class for {@link MediaSession.Builder} and {@link
* MediaLibraryService.MediaLibrarySession.Builder}. Any changes to this class should be also
* applied to the subclasses.
* A base class for {@link MediaSession.Builder} and {@link MediaLibrarySession.Builder}. Any
* changes to this class should be also applied to the subclasses.
*/
/* package */ abstract static class BuilderBase<
SessionT extends MediaSession,
@ -1553,6 +1711,8 @@ public class MediaSession {
/* package */ Bundle extras;
/* package */ @MonotonicNonNull BitmapLoader bitmapLoader;
/* package */ ImmutableList<CommandButton> customLayout;
public BuilderBase(Context context, Player player, CallbackT callback) {
this.context = checkNotNull(context);
this.player = checkNotNull(player);
@ -1560,6 +1720,7 @@ public class MediaSession {
id = "";
this.callback = callback;
extras = Bundle.EMPTY;
customLayout = ImmutableList.of();
}
@SuppressWarnings("unchecked")
@ -1592,6 +1753,12 @@ public class MediaSession {
return (BuilderT) this;
}
@SuppressWarnings("unchecked")
public BuilderT setCustomLayout(List<CommandButton> customLayout) {
this.customLayout = ImmutableList.copyOf(customLayout);
return (BuilderT) this;
}
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
private long sessionPositionUpdateDelayMs;
private ImmutableList<CommandButton> customLayout;
public MediaSessionImpl(
MediaSession instance,
@ -135,6 +136,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
String id,
Player player,
@Nullable PendingIntent sessionActivity,
ImmutableList<CommandButton> customLayout,
MediaSession.Callback callback,
Bundle tokenExtras,
BitmapLoader bitmapLoader) {
@ -147,6 +149,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity;
this.customLayout = customLayout;
mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper());
@ -187,6 +190,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
PlayerWrapper playerWrapper = new PlayerWrapper(player);
this.playerWrapper = playerWrapper;
this.playerWrapper.setCustomLayout(customLayout);
postOrRun(
applicationHandler,
() ->
@ -209,6 +213,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private void setPlayerInternal(
@Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) {
playerWrapper = newPlayerWrapper;
playerWrapper.setCustomLayout(customLayout);
if (oldPlayerWrapper != null) {
oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener));
}
@ -307,7 +312,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
}
public void setCustomLayout(List<CommandButton> layout) {
playerWrapper.setCustomLayout(ImmutableList.copyOf(layout));
customLayout = ImmutableList.copyOf(layout);
playerWrapper.setCustomLayout(customLayout);
dispatchRemoteControllerTaskWithoutReturn(
(controller, seq) -> controller.setCustomLayout(seq, layout));
}
@ -503,6 +509,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return sessionActivity;
}
protected ImmutableList<CommandButton> getCustomLayout() {
return customLayout;
}
@UnstableApi
protected void setSessionActivity(PendingIntent sessionActivity) {
if (Objects.equals(this.sessionActivity, sessionActivity)) {

View File

@ -522,6 +522,9 @@ import java.util.concurrent.ExecutionException;
MediaSessionStub.VERSION_INT,
MediaSessionStub.this,
sessionImpl.getSessionActivity(),
connectionResult.customLayout != null
? connectionResult.customLayout
: sessionImpl.getCustomLayout(),
connectionResult.availableSessionCommands,
connectionResult.availablePlayerCommands,
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 pageSize,
in Bundle libraryParams);
Bundle getCustomLayout(String controllerId);
Bundle getItem(String controllerId, String mediaId);
Bundle search(String controllerId, String query, in Bundle libraryParams);
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_CURRENT_TRACKS = "currentTracks";
public static final String KEY_AVAILABLE_COMMANDS = "availableCommands";
public static final String KEY_COMMAND_BUTTON_LIST = "command_button_list";
// SessionCompat arguments
public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken";

View File

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

View File

@ -43,6 +43,7 @@ import androidx.media3.common.Timeline;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Consumer;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.test.session.R;
import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@ -1435,6 +1436,93 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
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(
MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException {
LinkedBlockingDeque<PlaybackStateCompat> playbackStateCompats = new LinkedBlockingDeque<>();
@ -1477,13 +1565,19 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
}
private static MediaSession createMediaSession(Player player) {
return createMediaSession(player, null);
return createMediaSession(player, /* callback= */ null);
}
private static MediaSession createMediaSession(
Player player, @Nullable MediaSession.Callback callback) {
return createMediaSession(player, callback, /* customLayout= */ ImmutableList.of());
}
private static MediaSession createMediaSession(
Player player, @Nullable MediaSession.Callback callback, List<CommandButton> customLayout) {
MediaSession.Builder session =
new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player);
new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player)
.setCustomLayout(customLayout);
if (callback != null) {
session.setCallback(callback);
}

View File

@ -34,7 +34,9 @@ import androidx.media3.common.DeviceInfo;
import androidx.media3.common.FlagSet;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.ConditionVariable;
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.HandlerThreadTestRule;
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.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
@ -361,4 +364,87 @@ public class MediaControllerListenerWithMediaSessionCompatTest {
assertThat(deviceVolumeOnEvents.get()).isEqualTo(50);
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.SUPPORT_APP_PACKAGE_NAME;
import static androidx.media3.test.session.common.MediaSessionConstants.KEY_AVAILABLE_SESSION_COMMANDS;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE;
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.VideoSize;
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.MainLooperTestRule;
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.filters.LargeTest;
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.List;
import java.util.concurrent.CountDownLatch;
@ -169,6 +173,255 @@ public class MediaControllerTest {
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
public void getPackageName() throws Exception {
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
@ -1461,4 +1714,21 @@ public class MediaControllerTest {
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.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_SUCCESS;
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.Rating;
import androidx.media3.common.StarRating;
import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder;
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.MainLooperTestRule;
import androidx.media3.test.session.common.TestUtils;
@ -130,6 +133,66 @@ public class MediaSessionCallbackTest {
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
public void onPostConnect_afterConnected() throws Exception {
CountDownLatch latch = new CountDownLatch(1);

View File

@ -17,6 +17,7 @@ package androidx.media3.session;
import static androidx.media3.common.util.Assertions.checkState;
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 java.util.concurrent.TimeUnit.MILLISECONDS;
@ -809,6 +810,19 @@ public class MediaControllerProviderService extends Service {
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
public Bundle getItem(String controllerId, String mediaId) throws RemoteException {
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.TEST_COMMAND_GET_TRACKS;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CONTROLLER_LISTENER_SESSION_REJECTS;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE;
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);
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:
{
SessionCommands availableSessionCommands =

View File

@ -16,6 +16,7 @@
package androidx.media3.session;
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.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS;
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.test.session.common.IRemoteMediaController;
import androidx.media3.test.session.common.TestUtils;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
@ -349,6 +352,16 @@ public class RemoteMediaController {
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
////////////////////////////////////////////////////////////////////////////////

View File

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