diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5bc0f4effb..e3c47058b1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -18,6 +18,9 @@ * Muxers: * IMA extension: * Session: + * Add `MediaSession.Callback.onPlayerInteractionFinished` to inform + sessions when a series of player interactions from a specific controller + finished. * UI: * Add customisation of various icons in `PlayerControlView` through xml attributes to allow different drawables per `PlayerView` instance, diff --git a/libraries/session/src/main/java/androidx/media3/session/ConnectedControllersManager.java b/libraries/session/src/main/java/androidx/media3/session/ConnectedControllersManager.java index f81c6ee9b4..f884313369 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectedControllersManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectedControllersManager.java @@ -24,6 +24,7 @@ import androidx.collection.ArrayMap; import androidx.media3.common.Player; import androidx.media3.session.MediaSession.ControllerInfo; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import java.lang.ref.WeakReference; @@ -233,10 +234,13 @@ import org.checkerframework.checker.nullness.qual.NonNull; } } - public void addToCommandQueue(ControllerInfo controllerInfo, AsyncCommand asyncCommand) { + public void addToCommandQueue( + ControllerInfo controllerInfo, @Player.Command int command, AsyncCommand asyncCommand) { synchronized (lock) { @Nullable ConnectedControllerRecord info = controllerRecords.get(controllerInfo); if (info != null) { + info.commandQueuePlayerCommands = + info.commandQueuePlayerCommands.buildUpon().add(command).build(); info.commandQueue.add(asyncCommand); } } @@ -245,7 +249,21 @@ import org.checkerframework.checker.nullness.qual.NonNull; public void flushCommandQueue(ControllerInfo controllerInfo) { synchronized (lock) { @Nullable ConnectedControllerRecord info = controllerRecords.get(controllerInfo); - if (info == null || info.commandQueueIsFlushing || info.commandQueue.isEmpty()) { + if (info == null) { + return; + } + Player.Commands commandQueuePlayerCommands = info.commandQueuePlayerCommands; + info.commandQueuePlayerCommands = Player.Commands.EMPTY; + info.commandQueue.add( + () -> { + @Nullable MediaSessionImpl sessionImpl = this.sessionImpl.get(); + if (sessionImpl != null) { + sessionImpl.onPlayerInteractionFinishedOnHandler( + controllerInfo, commandQueuePlayerCommands); + } + return Futures.immediateVoidFuture(); + }); + if (info.commandQueueIsFlushing) { return; } info.commandQueueIsFlushing = true; @@ -299,6 +317,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; public SessionCommands sessionCommands; public Player.Commands playerCommands; public boolean commandQueueIsFlushing; + public Player.Commands commandQueuePlayerCommands; public ConnectedControllerRecord( T controllerKey, @@ -310,6 +329,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; this.sessionCommands = sessionCommands; this.playerCommands = playerCommands; this.commandQueue = new ArrayDeque<>(); + this.commandQueuePlayerCommands = Player.Commands.EMPTY; } } } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java index d23837b84c..e1746fdcef 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -314,7 +314,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; } private void dispatchRemoteSessionTaskWithPlayerCommandAndWaitForFuture(RemoteSessionTask task) { - // Do not send a flush command queue message as we are actively waiting for task. + flushCommandQueueHandler.sendFlushCommandQueueMessage(); ListenableFuture future = dispatchRemoteSessionTask(iSession, task, /* addToPendingMaskingOperations= */ true); try { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java index 22e772a6e4..95567e0786 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -1300,7 +1300,7 @@ public class MediaSession { * callback returns quickly to avoid blocking the main thread for a long period of time. * * @param session The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. * @return The {@link ConnectionResult}. */ default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) { @@ -1316,7 +1316,7 @@ public class MediaSession { * isn't connected yet in {@link #onConnect}. * * @param session The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. */ default void onPostConnect(MediaSession session, ControllerInfo controller) {} @@ -1328,7 +1328,7 @@ public class MediaSession { * controller APIs. * * @param session The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. */ default void onDisconnected(MediaSession session, ControllerInfo controller) {} @@ -1356,7 +1356,7 @@ public class MediaSession { * Futures#immediateFuture(Object)}. * * @param session The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. * @param mediaId The media id. * @param rating The new rating from the controller. * @see SessionCommand#COMMAND_CODE_SESSION_SET_RATING @@ -1379,7 +1379,7 @@ public class MediaSession { * Futures#immediateFuture(Object)}. * * @param session The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. * @param rating The new rating from the controller. * @see SessionCommand#COMMAND_CODE_SESSION_SET_RATING */ @@ -1407,7 +1407,7 @@ public class MediaSession { * Futures#immediateFuture(Object)}. * * @param session The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. * @param customCommand The custom command. * @param args A {@link Bundle} for additional arguments. May be empty. * @return The result of handling the custom command. @@ -1469,7 +1469,7 @@ public class MediaSession { * as appropriate once the {@link MediaItem} has been resolved. * * @param mediaSession The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. * @param mediaItems The list of requested {@link MediaItem media items}. * @return A {@link ListenableFuture} for the list of resolved {@link MediaItem media items} * that are playable by the underlying {@link Player}. @@ -1535,7 +1535,7 @@ public class MediaSession { * as appropriate once the {@link MediaItem} has been resolved. * * @param mediaSession The session for this event. - * @param controller The controller information. + * @param controller The {@linkplain ControllerInfo controller} information. * @param mediaItems The list of requested {@linkplain MediaItem media items}. * @param startIndex The start index in the {@link MediaItem} list from which to start playing, * or {@link C#INDEX_UNSET C.INDEX_UNSET} to start playing from the default index in the @@ -1578,8 +1578,9 @@ public class MediaSession { * {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} available. * * @param mediaSession The media session for which playback resumption is requested. - * @param controller The controller that requests the playback resumption. This may be a short - * living controller created only for issuing a play command for resuming playback. + * @param controller The {@linkplain ControllerInfo controller} that requests the playback + * resumption. This may be a short living controller created only for issuing a play command + * for resuming playback. * @return The {@linkplain MediaItemsWithStartPosition playlist} to resume playback with. */ @UnstableApi @@ -1604,7 +1605,8 @@ public class MediaSession { * to your session. * * @param session The session that received the media button event. - * @param controllerInfo The controller to which the media button event is attributed to. + * @param controllerInfo The {@linkplain ControllerInfo controller} to which the media button + * event is attributed to. * @param intent The media button intent. * @return True if the event was handled, false otherwise. */ @@ -1613,6 +1615,27 @@ public class MediaSession { MediaSession session, ControllerInfo controllerInfo, Intent intent) { return false; } + + /** + * Called after all concurrent interactions with {@linkplain MediaSession#getPlayer() the + * session player} from a controller have finished. + * + *

A controller may call multiple {@link Player} methods within a single {@link Looper} + * message. Those {@link Player} method calls are batched together and once finished, this + * callback is called to signal that no further {@link Player} interactions coming from this + * controller are expected for now. + * + *

Apps can use this callback if they need to trigger different logic depending on whether + * certain methods are called together, for example just {@link Player#setMediaItems}, or {@link + * Player#setMediaItems} and {@link Player#play} together. + * + * @param session The {@link MediaSession} that received the {@link Player} calls. + * @param controllerInfo The {@linkplain ControllerInfo controller} sending the calls. + * @param playerCommands The set of {@link Player.Commands} used to send these calls. + */ + @UnstableApi + default void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) {} } /** Representation of a list of {@linkplain MediaItem media items} and where to start playing. */ diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java index 80f958c8d6..df170e4d01 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -748,6 +748,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; "Callback.onSetMediaItems must return a non-null future"); } + protected void onPlayerInteractionFinishedOnHandler( + ControllerInfo controller, Player.Commands playerCommands) { + callback.onPlayerInteractionFinished( + instance, resolveControllerInfoForCallback(controller), playerCommands); + } + public void connectFromService(IMediaController caller, ControllerInfo controllerInfo) { sessionStub.connect(caller, controllerInfo); } @@ -888,7 +894,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * * @param controller The controller requesting to play. */ - /* package */ void handleMediaControllerPlayRequest(ControllerInfo controller) { + /* package */ void handleMediaControllerPlayRequest( + ControllerInfo controller, boolean callOnPlayerInteractionFinished) { if (!onPlayRequested()) { // Request denied, e.g. due to missing foreground service abilities. return; @@ -899,6 +906,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; boolean canAddMediaItems = playerWrapper.isCommandAvailable(COMMAND_SET_MEDIA_ITEM) || playerWrapper.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS); + ControllerInfo controllerForRequest = resolveControllerInfoForCallback(controller); + Player.Commands playCommand = + new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build(); if (hasCurrentMediaItem || !canAddMediaItems) { // No playback resumption needed or possible. if (!hasCurrentMediaItem) { @@ -908,20 +918,31 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + " missing available commands"); } Util.handlePlayButtonAction(playerWrapper); + if (callOnPlayerInteractionFinished) { + onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); + } } else { @Nullable ListenableFuture future = checkNotNull( - callback.onPlaybackResumption(instance, resolveControllerInfoForCallback(controller)), + callback.onPlaybackResumption(instance, controllerForRequest), "Callback.onPlaybackResumption must return a non-null future"); Futures.addCallback( future, new FutureCallback() { @Override public void onSuccess(MediaItemsWithStartPosition mediaItemsWithStartPosition) { - MediaUtils.setMediaItemsWithStartIndexAndPosition( - playerWrapper, mediaItemsWithStartPosition); - Util.handlePlayButtonAction(playerWrapper); + callWithControllerForCurrentRequestSet( + controllerForRequest, + () -> { + MediaUtils.setMediaItemsWithStartIndexAndPosition( + playerWrapper, mediaItemsWithStartPosition); + Util.handlePlayButtonAction(playerWrapper); + if (callOnPlayerInteractionFinished) { + onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); + } + }) + .run(); } @Override @@ -943,6 +964,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } // Play as requested even if playback resumption fails. Util.handlePlayButtonAction(playerWrapper); + if (callOnPlayerInteractionFinished) { + onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); + } } }, this::postOrRunOnApplicationHandler); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 37600890d3..093d014a4a 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -341,7 +341,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; controller -> Util.handlePlayPauseButtonAction( sessionImpl.getPlayerWrapper(), sessionImpl.shouldPlayIfSuppressed()), - remoteUserInfo); + remoteUserInfo, + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -349,7 +350,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_PREPARE, controller -> sessionImpl.getPlayerWrapper().prepare(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -379,8 +381,11 @@ import org.checkerframework.checker.initialization.qual.Initialized; public void onPlay() { dispatchSessionTaskWithPlayerCommand( COMMAND_PLAY_PAUSE, - sessionImpl::handleMediaControllerPlayRequest, - sessionCompat.getCurrentControllerInfo()); + controller -> + sessionImpl.handleMediaControllerPlayRequest( + controller, /* callOnPlayerInteractionFinished= */ true), + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ false); } @Override @@ -411,7 +416,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_PLAY_PAUSE, controller -> Util.handlePauseButtonAction(sessionImpl.getPlayerWrapper()), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -419,7 +425,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_STOP, controller -> sessionImpl.getPlayerWrapper().stop(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -427,7 +434,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, controller -> sessionImpl.getPlayerWrapper().seekTo(pos), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -436,12 +444,14 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_TO_NEXT, controller -> sessionImpl.getPlayerWrapper().seekToNext(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } else { dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, controller -> sessionImpl.getPlayerWrapper().seekToNextMediaItem(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } } @@ -451,12 +461,14 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_TO_PREVIOUS, controller -> sessionImpl.getPlayerWrapper().seekToPrevious(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } else { dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, controller -> sessionImpl.getPlayerWrapper().seekToPreviousMediaItem(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } } @@ -468,7 +480,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_SET_SPEED_AND_PITCH, controller -> sessionImpl.getPlayerWrapper().setPlaybackSpeed(speed), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -484,7 +497,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; // see: {@link MediaUtils#convertToQueueItem}. playerWrapper.seekToDefaultPosition((int) queueId); }, - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -492,7 +506,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_FORWARD, controller -> sessionImpl.getPlayerWrapper().seekForward(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -500,7 +515,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; dispatchSessionTaskWithPlayerCommand( COMMAND_SEEK_BACK, controller -> sessionImpl.getPlayerWrapper().seekBack(), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -543,7 +559,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; .getPlayerWrapper() .setRepeatMode( LegacyConversions.convertToRepeatMode(playbackStateCompatRepeatMode)), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -554,7 +571,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; sessionImpl .getPlayerWrapper() .setShuffleModeEnabled(LegacyConversions.convertToShuffleModeEnabled(shuffleMode)), - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } @Override @@ -595,7 +613,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; } } }, - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ true); } public ControllerCb getControllerLegacyCbForBroadcast() { @@ -611,7 +630,10 @@ import org.checkerframework.checker.initialization.qual.Initialized; } private void dispatchSessionTaskWithPlayerCommand( - @Player.Command int command, SessionTask task, @Nullable RemoteUserInfo remoteUserInfo) { + @Player.Command int command, + SessionTask task, + @Nullable RemoteUserInfo remoteUserInfo, + boolean callOnPlayerInteractionFinished) { if (sessionImpl.isReleased()) { return; } @@ -673,6 +695,10 @@ import org.checkerframework.checker.initialization.qual.Initialized; } }) .run(); + if (callOnPlayerInteractionFinished) { + sessionImpl.onPlayerInteractionFinishedOnHandler( + controller, new Player.Commands.Builder().add(command).build()); + } }); } @@ -829,6 +855,12 @@ import org.checkerframework.checker.initialization.qual.Initialized; if (play) { player.playIfCommandAvailable(); } + sessionImpl.onPlayerInteractionFinishedOnHandler( + controller, + new Player.Commands.Builder() + .addAll(COMMAND_SET_MEDIA_ITEM, COMMAND_PREPARE) + .addIf(COMMAND_PLAY_PAUSE, play) + .build()); })); } @@ -839,7 +871,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; }, MoreExecutors.directExecutor()); }, - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ false); } private void handleOnAddQueueItem(@Nullable MediaDescriptionCompat description, int index) { @@ -872,6 +905,11 @@ import org.checkerframework.checker.initialization.qual.Initialized; } else { sessionImpl.getPlayerWrapper().addMediaItems(index, mediaItems); } + sessionImpl.onPlayerInteractionFinishedOnHandler( + controller, + new Player.Commands.Builder() + .add(COMMAND_CHANGE_MEDIA_ITEMS) + .build()); })); } @@ -882,7 +920,8 @@ import org.checkerframework.checker.initialization.qual.Initialized; }, MoreExecutors.directExecutor()); }, - sessionCompat.getCurrentControllerInfo()); + sessionCompat.getCurrentControllerInfo(), + /* callOnPlayerInteractionFinished= */ false); } private static void sendCustomCommandResultWhenReady( diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java index db0f71f13e..074757498a 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -327,13 +327,18 @@ import java.util.concurrent.ExecutionException; return; } if (command == COMMAND_SET_VIDEO_SURFACE) { + // Call surface changes immediately to ensure they are handled within the calling + // methods stack. Also add a placeholder task to the regular command queue for proper + // task tracking (e.g. to send onPlayerInteractionFinished). sessionImpl .callWithControllerForCurrentRequestSet( controller, () -> task.run(sessionImpl, controller, sequenceNumber)) .run(); + connectedControllersManager.addToCommandQueue( + controller, command, Futures::immediateVoidFuture); } else { connectedControllersManager.addToCommandQueue( - controller, () -> task.run(sessionImpl, controller, sequenceNumber)); + controller, command, () -> task.run(sessionImpl, controller, sequenceNumber)); } }); } finally { @@ -724,7 +729,8 @@ import java.util.concurrent.ExecutionException; if (impl == null || impl.isReleased()) { return; } - impl.handleMediaControllerPlayRequest(controller); + impl.handleMediaControllerPlayRequest( + controller, /* callOnPlayerInteractionFinished= */ false); })); } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java index ca0fcd16d6..723d656974 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.session; +import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE; 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; @@ -38,6 +39,7 @@ import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.Player; import androidx.media3.common.Rating; import androidx.media3.common.StarRating; +import androidx.media3.common.util.ConditionVariable; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder; import androidx.media3.session.MediaSession.ControllerInfo; @@ -1553,6 +1555,119 @@ public class MediaSessionCallbackTest { .inOrder(); } + @Test + public void onPlayerInteractionFinished_withSingleControllerCall_calledWithMatchingCommand() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player).setCallback(callback).build()); + RemoteMediaController controller = + remoteControllerTestRule.createRemoteController(session.getToken()); + + controller.setPlayWhenReady(true); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_PLAY_WHEN_READY)).isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo(new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build()); + } + + @Test + public void onPlayerInteractionFinished_withMultipleControllerCalls_calledWithMatchingCommands() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player).setCallback(callback).build()); + RemoteMediaController controller = + remoteControllerTestRule.createRemoteController(session.getToken()); + + controller.setMediaItemsPreparePlayAddItemsSeek( + ImmutableList.of(MediaItem.fromUri("https://uri1")), + ImmutableList.of(MediaItem.fromUri("https://uri2")), + /* seekIndex= */ 1); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION)) + .isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_ADD_MEDIA_ITEMS)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SEEK_TO_WITH_MEDIA_ITEM_INDEX)) + .isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo( + new Player.Commands.Builder() + .addAll( + Player.COMMAND_CHANGE_MEDIA_ITEMS, + Player.COMMAND_PREPARE, + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build()); + } + + @Test + public void onPlayerInteractionFinished_withPlaybackResumption_calledWithMatchingCommands() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ListenableFuture onPlaybackResumption( + MediaSession mediaSession, ControllerInfo controller) { + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + MediaTestUtils.createMediaItems(2), + /* startIndex= */ 1, + /* startPositionMs= */ 123L)); + } + + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player).setCallback(callback).build()); + RemoteMediaController controller = + remoteControllerTestRule.createRemoteController(session.getToken()); + + controller.play(); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX)) + .isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo(new Player.Commands.Builder().add(COMMAND_PLAY_PAUSE).build()); + } + private void postToPlayerAndSync(TestHandler.TestRunnable r) { try { playerThreadTestRule.getHandler().postAndSync(r); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java index 31fffe067f..2e7704b339 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java @@ -18,6 +18,7 @@ package androidx.media3.session; import static androidx.media.MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER; import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE; import static androidx.media3.common.Player.COMMAND_PREPARE; +import static androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEM; import static androidx.media3.common.Player.STATE_ENDED; import static androidx.media3.common.Player.STATE_IDLE; import static androidx.media3.common.Player.STATE_READY; @@ -39,6 +40,7 @@ import android.os.Bundle; import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.RatingCompat; import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.MediaSessionCompat.QueueItem; import android.support.v4.media.session.PlaybackStateCompat; import android.view.KeyEvent; @@ -51,6 +53,7 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.Player; import androidx.media3.common.Rating; import androidx.media3.common.StarRating; +import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.Log; import androidx.media3.common.util.Util; import androidx.media3.session.MediaSession.ControllerInfo; @@ -255,7 +258,7 @@ public class MediaSessionCallbackWithMediaControllerCompatTest { public void play_whileIdleWithoutPrepareCommandAvailable_callsJustPlay() throws Exception { player.playbackState = STATE_IDLE; player.commands = - new Player.Commands.Builder().addAllCommands().remove(Player.COMMAND_PREPARE).build(); + new Player.Commands.Builder().addAllCommands().remove(COMMAND_PREPARE).build(); session = new MediaSession.Builder(context, player) .setId("play") @@ -776,7 +779,7 @@ public class MediaSessionCallbackWithMediaControllerCompatTest { player.commands = new Player.Commands.Builder() .addAllCommands() - .removeAll(Player.COMMAND_SET_MEDIA_ITEM, Player.COMMAND_CHANGE_MEDIA_ITEMS) + .removeAll(COMMAND_SET_MEDIA_ITEM, Player.COMMAND_CHANGE_MEDIA_ITEMS) .build(); session = new MediaSession.Builder(context, player).setId("dispatchMediaButtonEvent").build(); controller = @@ -1979,6 +1982,212 @@ public class MediaSessionCallbackWithMediaControllerCompatTest { assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isFalse(); } + @Test + public void onPlayerInteractionFinished_withSimpleControllerCall_calledWithMatchingCommand() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new TestSessionCallback() { + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + session = + new MediaSession.Builder(context, player) + .setId("onPlayerInteractionFinished_simpleCall") + .setCallback(callback) + .build(); + controller = + new RemoteMediaControllerCompat( + context, + MediaSessionCompat.Token.fromToken(session.getPlatformToken()), + /* waitForConnection= */ true); + + controller.getTransportControls().setPlaybackSpeed(2f); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_PLAYBACK_SPEED)).isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo(new Player.Commands.Builder().add(Player.COMMAND_SET_SPEED_AND_PITCH).build()); + } + + @Test + public void onPlayerInteractionFinished_withPlaybackResumption_calledWithMatchingCommand() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new TestSessionCallback() { + @Override + public ListenableFuture onPlaybackResumption( + MediaSession mediaSession, ControllerInfo controller) { + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + MediaTestUtils.createMediaItems(2), + /* startIndex= */ 1, + /* startPositionMs= */ 123L)); + } + + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + session = + new MediaSession.Builder(context, player) + .setId("onPlayerInteractionFinished_playbackResumption") + .setCallback(callback) + .build(); + controller = + new RemoteMediaControllerCompat( + context, + MediaSessionCompat.Token.fromToken(session.getPlatformToken()), + /* waitForConnection= */ true); + + controller.getTransportControls().play(); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX)) + .isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo(new Player.Commands.Builder().add(COMMAND_PLAY_PAUSE).build()); + } + + @Test + public void onPlayerInteractionFinished_withPlayFromUri_calledWithMatchingCommands() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new TestSessionCallback() { + @Override + public ListenableFuture> onAddMediaItems( + MediaSession mediaSession, ControllerInfo controller, List mediaItems) { + return Futures.immediateFuture(MediaTestUtils.createMediaItems(2)); + } + + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + session = + new MediaSession.Builder(context, player) + .setId("onPlayerInteractionFinished_playFromUri") + .setCallback(callback) + .build(); + controller = + new RemoteMediaControllerCompat( + context, + MediaSessionCompat.Token.fromToken(session.getPlatformToken()), + /* waitForConnection= */ true); + + controller.getTransportControls().playFromUri(Uri.parse("https://uri"), Bundle.EMPTY); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION)) + .isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo( + new Player.Commands.Builder() + .addAll(COMMAND_SET_MEDIA_ITEM, COMMAND_PREPARE, COMMAND_PLAY_PAUSE) + .build()); + } + + @Test + public void onPlayerInteractionFinished_withPrepareFromUri_calledWithMatchingCommands() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new TestSessionCallback() { + @Override + public ListenableFuture> onAddMediaItems( + MediaSession mediaSession, ControllerInfo controller, List mediaItems) { + return Futures.immediateFuture(MediaTestUtils.createMediaItems(2)); + } + + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + session = + new MediaSession.Builder(context, player) + .setId("onPlayerInteractionFinished_prepareFromUri") + .setCallback(callback) + .build(); + controller = + new RemoteMediaControllerCompat( + context, + MediaSessionCompat.Token.fromToken(session.getPlatformToken()), + /* waitForConnection= */ true); + + controller.getTransportControls().prepareFromUri(Uri.parse("https://uri"), Bundle.EMPTY); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION)) + .isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isFalse(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo( + new Player.Commands.Builder().addAll(COMMAND_SET_MEDIA_ITEM, COMMAND_PREPARE).build()); + } + + @Test + public void onPlayerInteractionFinished_withAddQueueItem_calledWithMatchingCommand() + throws Exception { + AtomicReference onPlayerInteractionFinishedCommands = new AtomicReference<>(); + ConditionVariable onPlayerInteractionFinishedCalled = new ConditionVariable(); + MediaSession.Callback callback = + new TestSessionCallback() { + @Override + public ListenableFuture> onAddMediaItems( + MediaSession mediaSession, ControllerInfo controller, List mediaItems) { + return Futures.immediateFuture(MediaTestUtils.createMediaItems(2)); + } + + @Override + public void onPlayerInteractionFinished( + MediaSession session, ControllerInfo controllerInfo, Player.Commands playerCommands) { + onPlayerInteractionFinishedCommands.set(playerCommands); + onPlayerInteractionFinishedCalled.open(); + } + }; + session = + new MediaSession.Builder(context, player) + .setId("onPlayerInteractionFinished_prepareFromUri") + .setCallback(callback) + .build(); + controller = + new RemoteMediaControllerCompat( + context, + MediaSessionCompat.Token.fromToken(session.getPlatformToken()), + /* waitForConnection= */ true); + + controller.addQueueItem(new MediaDescriptionCompat.Builder().setMediaId("id").build()); + assertThat(onPlayerInteractionFinishedCalled.block(TIMEOUT_MS)).isTrue(); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_ADD_MEDIA_ITEMS)).isTrue(); + assertThat(onPlayerInteractionFinishedCommands.get()) + .isEqualTo(new Player.Commands.Builder().add(Player.COMMAND_CHANGE_MEDIA_ITEMS).build()); + } + private static class TestSessionCallback implements MediaSession.Callback { @Override diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java index 70bb2bedf3..6b9ab66892 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java @@ -371,8 +371,10 @@ public class RemoteMediaController { throws RemoteException { binder.setMediaItemsPreparePlayAddItemsSeek( controllerId, - BundleCollectionUtil.toBundleList(initialMediaItems, MediaItem::toBundle), - BundleCollectionUtil.toBundleList(addedMediaItems, MediaItem::toBundle), + BundleCollectionUtil.toBundleList( + initialMediaItems, MediaItem::toBundleIncludeLocalConfiguration), + BundleCollectionUtil.toBundleList( + addedMediaItems, MediaItem::toBundleIncludeLocalConfiguration), seekIndex); }